Initial commit: BalDav health tracker

Tauri 2 + pure JS + SQLite personal health tracking app.

Nutrition module:
- 9299 products in DB (Magnit SSR scraping + KDV)
- Product search with case-insensitive Cyrillic (name_lower column)
- Food logging from product DB or manual entry
- Supplement tracking with per-pill micronutrients
- Micronutrient % strip on Today screen

Fitness/GPS module:
- FitoTrack .ftb and GPX import with seq-based ordering
- Session detail view: map, speed/elevation charts (Chart.js)
- Offline tile caching (IndexedDB) with region download
- Track rendering with colored time-gradient points

UI:
- Fullscreen modals with back-button (history API)
- Dark theme, desktop-responsive layout
- 5 tabs: Today, Products, Track, Supplements, Profile

Tools:
- Magnit/KDV HTML parsers
- Dedup import with discrepancy detection
- FitoTrack backup analysis
This commit is contained in:
4aiman 2026-05-13 10:21:24 +03:00
commit 1affadb05d
22 changed files with 4850 additions and 0 deletions

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
node_modules/
target/
src-tauri/target/
src-tauri/gen/
data/stores/
*.har
*.ftb
*.gpx
debug_*.html
debug_*.json
fitotrack_src/
fitotrack_v156/
fitotrack_*.apk
de.tadris.fitness_*.tar.gz

1157
frontend/css/styles.css Normal file

File diff suppressed because it is too large Load diff

24
frontend/index.html Normal file
View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>BalDav</title>
<link rel="stylesheet" href="lib/leaflet.css" />
<link rel="stylesheet" href="css/styles.css" />
</head>
<body>
<div id="app"></div>
<!-- Load order matters: db.js and bmr.js expose window.DB / window.BMR,
then main.js uses them. Tauri injects window.__TAURI__ before page scripts. -->
<script src="lib/leaflet.js"></script>
<script src="lib/chart.min.js"></script>
<script src="lib/jszip.min.js"></script>
<script src="js/db.js"></script>
<script src="js/bmr.js"></script>
<script src="js/tilecache.js"></script>
<script src="js/gps.js"></script>
<script src="js/main.js"></script>
</body>
</html>

14
frontend/js/bmr.js Normal file
View file

@ -0,0 +1,14 @@
// Mifflin-St Jeor BMR formula.
// Males: BMR = 10*weight + 6.25*height - 5*age - 5
// Females: BMR = 10*weight + 6.25*height - 5*age + 161
function calculateBmr(weightKg, heightCm, age, sex) {
const base = 10 * weightKg + 6.25 * heightCm - 5 * age;
return Math.round(sex === "m" ? base - 5 : base + 161);
}
function getAge(birthYear) {
return new Date().getFullYear() - birthYear;
}
window.BMR = { calculateBmr, getAge };

492
frontend/js/db.js Normal file
View file

@ -0,0 +1,492 @@
// Database layer — talks to tauri-plugin-sql via raw Tauri IPC.
// No imports, no npm. window.__TAURI__ is injected by Tauri (withGlobalTauri: true).
// The DB is preloaded at startup via tauri.conf.json plugins.sql.preload.
const DB_PATH = "sqlite:baldav.db";
function inv(cmd, args) {
return window.__TAURI__.core.invoke(`plugin:sql|${cmd}`, args);
}
async function execute(query, params) {
return inv("execute", { db: DB_PATH, query, values: params ?? [] });
}
async function select(query, params) {
return inv("select", { db: DB_PATH, query, values: params ?? [] });
}
// ---------------------------------------------------------------------------
// Profile (key-value store)
// ---------------------------------------------------------------------------
async function getProfileValue(key) {
const rows = await select("SELECT value FROM profile WHERE key = ?1", [key]);
return rows.length ? rows[0].value : null;
}
async function setProfileValue(key, value) {
await execute(
"INSERT OR REPLACE INTO profile (key, value) VALUES (?1, ?2)",
[key, String(value)]
);
}
async function getFullProfile() {
const rows = await select("SELECT key, value FROM profile");
const p = {};
for (const r of rows) p[r.key] = r.value;
return p;
}
// ---------------------------------------------------------------------------
// Weight log
// ---------------------------------------------------------------------------
async function addWeight(date, time, kg) {
await execute(
"INSERT INTO weight_log (date, time, weight_kg) VALUES (?1, ?2, ?3)",
[date, time, kg]
);
}
async function deleteWeight(id) {
await execute("DELETE FROM weight_log WHERE id = ?1", [id]);
}
async function getWeightsForDate(date) {
return select("SELECT * FROM weight_log WHERE date = ?1 ORDER BY time ASC", [date]);
}
async function getLatestWeight() {
const rows = await select(
"SELECT * FROM weight_log ORDER BY date DESC, time DESC LIMIT 1"
);
return rows.length ? rows[0] : null;
}
async function getRecentWeights(days) {
return select(
`SELECT date, MIN(weight_kg) as weight_kg
FROM weight_log
WHERE date >= date('now', ?1)
GROUP BY date ORDER BY date ASC`,
[`-${days ?? 30} days`]
);
}
// ---------------------------------------------------------------------------
// Sleep log
// ---------------------------------------------------------------------------
async function getSleepForDate(date) {
const rows = await select("SELECT * FROM sleep_log WHERE date = ?1", [date]);
return rows.length ? rows[0] : null;
}
async function saveSleep(date, bedTime, asleepTime, wakeTime, napMinutes, notes) {
const existing = await getSleepForDate(date);
if (existing) {
await execute(
`UPDATE sleep_log SET bed_time=?1, asleep_time=?2, wake_time=?3,
nap_minutes=?4, notes=?5 WHERE date=?6`,
[bedTime, asleepTime, wakeTime, napMinutes ?? 0, notes ?? null, date]
);
} else {
await execute(
`INSERT INTO sleep_log (date, bed_time, asleep_time, wake_time, nap_minutes, notes)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
[date, bedTime, asleepTime, wakeTime, napMinutes ?? 0, notes ?? null]
);
}
}
// ---------------------------------------------------------------------------
// Products
// ---------------------------------------------------------------------------
async function searchProducts(query, limit) {
const q = query.toLowerCase();
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
FROM products
WHERE name_lower LIKE ?1
GROUP BY COALESCE(store_id, id)
ORDER BY
CASE WHEN name_lower LIKE ?1 THEN 0 ELSE 1 END,
name COLLATE NOCASE
LIMIT ?2`,
[`%${q}%`, limit ?? 30]
);
}
async function getProduct(id) {
const rows = await select("SELECT * FROM products WHERE id = ?1", [id]);
return rows.length ? rows[0] : null;
}
async function addProduct(p) {
const r = await execute(
`INSERT INTO products
(name, brand, category, weight_g, kcal_per_100g, protein_per_100g,
fat_per_100g, carbs_per_100g, fiber_per_100g,
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, 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)`,
[
p.name, p.brand ?? null, p.category ?? null, p.weight_g ?? null,
p.kcal_per_100g ?? null, p.protein_per_100g ?? null,
p.fat_per_100g ?? null, p.carbs_per_100g ?? null, p.fiber_per_100g ?? null,
p.vit_a_mcg ?? null, p.vit_c_mg ?? null, p.vit_d_mcg ?? null,
p.vit_e_mg ?? null, p.vit_k_mcg ?? null,
p.vit_b1_mg ?? null, p.vit_b2_mg ?? null, p.vit_b3_mg ?? null,
p.vit_b6_mg ?? null, p.vit_b12_mcg ?? null, p.folate_mcg ?? null,
p.calcium_mg ?? null, p.iron_mg ?? null, p.magnesium_mg ?? null,
p.zinc_mg ?? null, p.copper_mg ?? null,
p.selenium_mcg ?? null, p.potassium_mg ?? null, p.sodium_mg ?? null,
p.source ?? "manual",
]
);
return r.lastInsertId;
}
// ---------------------------------------------------------------------------
// Food log
// ---------------------------------------------------------------------------
async function getFoodForDate(date) {
return select(
`SELECT fl.*, p.name as product_name, p.brand as product_brand,
r.name as recipe_name
FROM food_log fl
LEFT JOIN products p ON fl.product_id = p.id
LEFT JOIN recipes r ON fl.recipe_id = r.id
WHERE fl.date = ?1 ORDER BY fl.time ASC`,
[date]
);
}
async function addFoodManual(date, time, desc, kcal) {
await execute(
"INSERT INTO food_log (date, time, note, amount_g, kcal) VALUES (?1,?2,?3,0,?4)",
[date, time, desc, kcal ?? null]
);
}
async function addFoodEntry(date, time, productId, amountG, note) {
let kcal = null, protein = null, fat = null, carbs = null, fiber = null;
if (productId) {
const p = await getProduct(productId);
if (p && p.kcal_per_100g != null) {
const f = amountG / 100;
kcal = Math.round((p.kcal_per_100g ?? 0) * f);
protein = Math.round((p.protein_per_100g ?? 0) * f * 10) / 10;
fat = Math.round((p.fat_per_100g ?? 0) * f * 10) / 10;
carbs = Math.round((p.carbs_per_100g ?? 0) * f * 10) / 10;
fiber = Math.round((p.fiber_per_100g ?? 0) * f * 10) / 10;
}
}
await execute(
`INSERT INTO food_log
(date, time, product_id, amount_g, kcal, protein_g, fat_g, carbs_g, fiber_g, note)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)`,
[date, time, productId ?? null, amountG,
kcal, protein, fat, carbs, fiber, note ?? null]
);
}
async function updateFoodEntry(id, time, amountG, note, kcalOverride) {
// Recalculate nutrition from product if linked
const rows = await select("SELECT product_id FROM food_log WHERE id = ?1", [id]);
if (!rows.length) return;
const productId = rows[0].product_id;
let kcal = kcalOverride, protein = null, fat = null, carbs = null, fiber = null;
if (productId && kcalOverride == null) {
const p = await getProduct(productId);
if (p && p.kcal_per_100g != null) {
const f = amountG / 100;
kcal = Math.round((p.kcal_per_100g ?? 0) * f);
protein = Math.round((p.protein_per_100g ?? 0) * f * 10) / 10;
fat = Math.round((p.fat_per_100g ?? 0) * f * 10) / 10;
carbs = Math.round((p.carbs_per_100g ?? 0) * f * 10) / 10;
fiber = Math.round((p.fiber_per_100g ?? 0) * f * 10) / 10;
}
}
await execute(
`UPDATE food_log SET time=?1, amount_g=?2, note=?3,
kcal=?4, protein_g=?5, fat_g=?6, carbs_g=?7, fiber_g=?8
WHERE id=?9`,
[time, amountG, note ?? null, kcal, protein, fat, carbs, fiber, id]
);
}
async function deleteFoodEntry(id) {
await execute("DELETE FROM food_log WHERE id = ?1", [id]);
}
// ---------------------------------------------------------------------------
// Activity log
// ---------------------------------------------------------------------------
async function getActivitiesForDate(date) {
return select("SELECT * FROM activity_log WHERE date = ?1 ORDER BY time ASC", [date]);
}
async function addActivity(date, time, type, desc, minutes, kcal) {
await execute(
"INSERT INTO activity_log (date, time, type, desc, minutes, kcal) VALUES (?1,?2,?3,?4,?5,?6)",
[date, time, type ?? "manual", desc, minutes ?? null, kcal ?? null]
);
}
async function deleteActivity(id) {
await execute("DELETE FROM activity_log WHERE id = ?1", [id]);
}
// ---------------------------------------------------------------------------
// Supplements
// ---------------------------------------------------------------------------
async function getSupplements() {
return select("SELECT * FROM supplements ORDER BY name COLLATE NOCASE");
}
async function addSupplement(s) {
const r = await execute(
`INSERT INTO supplements
(name, daily_dose_g, 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)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21)`,
[
s.name, s.daily_dose_g ?? null,
s.vit_a_mcg ?? null, s.vit_c_mg ?? null, s.vit_d_mcg ?? null,
s.vit_e_mg ?? null, s.vit_k_mcg ?? null,
s.vit_b1_mg ?? null, s.vit_b2_mg ?? null, s.vit_b3_mg ?? null,
s.vit_b6_mg ?? null, s.vit_b12_mcg ?? null, s.folate_mcg ?? null,
s.calcium_mg ?? null, s.iron_mg ?? null, s.magnesium_mg ?? null,
s.zinc_mg ?? null, s.copper_mg ?? null, s.selenium_mcg ?? null,
s.potassium_mg ?? null, s.sodium_mg ?? null,
]
);
return r.lastInsertId;
}
async function getSupplement(id) {
const rows = await select("SELECT * FROM supplements WHERE id = ?1", [id]);
return rows.length ? rows[0] : null;
}
async function deleteSupplement(id) {
await execute("DELETE FROM supplement_log WHERE supplement_id = ?1", [id]);
await execute("DELETE FROM supplements WHERE id = ?1", [id]);
}
async function logSupplement(date, time, supplementId, doseG) {
await execute(
"INSERT INTO supplement_log (date, time, supplement_id, dose_g) VALUES (?1,?2,?3,?4)",
[date, time, supplementId, doseG ?? null]
);
}
async function getSupplementLogForDate(date) {
return select(
`SELECT sl.*, s.name as supplement_name, s.daily_dose_g
FROM supplement_log sl
JOIN supplements s ON sl.supplement_id = s.id
WHERE sl.date = ?1 ORDER BY sl.time ASC`,
[date]
);
}
async function deleteSupplementLog(id) {
await execute("DELETE FROM supplement_log WHERE id = ?1", [id]);
}
async function getSupplementTotalsForDate(date) {
const rows = await select(
`SELECT
SUM(COALESCE(sl.dose_g,1) * s.vit_a_mcg) as vit_a_mcg,
SUM(COALESCE(sl.dose_g,1) * s.vit_c_mg) as vit_c_mg,
SUM(COALESCE(sl.dose_g,1) * s.vit_d_mcg) as vit_d_mcg,
SUM(COALESCE(sl.dose_g,1) * s.vit_e_mg) as vit_e_mg,
SUM(COALESCE(sl.dose_g,1) * s.vit_k_mcg) as vit_k_mcg,
SUM(COALESCE(sl.dose_g,1) * s.vit_b1_mg) as vit_b1_mg,
SUM(COALESCE(sl.dose_g,1) * s.vit_b2_mg) as vit_b2_mg,
SUM(COALESCE(sl.dose_g,1) * s.vit_b3_mg) as vit_b3_mg,
SUM(COALESCE(sl.dose_g,1) * s.vit_b6_mg) as vit_b6_mg,
SUM(COALESCE(sl.dose_g,1) * s.vit_b12_mcg) as vit_b12_mcg,
SUM(COALESCE(sl.dose_g,1) * s.folate_mcg) as folate_mcg,
SUM(COALESCE(sl.dose_g,1) * s.calcium_mg) as calcium_mg,
SUM(COALESCE(sl.dose_g,1) * s.iron_mg) as iron_mg,
SUM(COALESCE(sl.dose_g,1) * s.magnesium_mg) as magnesium_mg,
SUM(COALESCE(sl.dose_g,1) * s.zinc_mg) as zinc_mg,
SUM(COALESCE(sl.dose_g,1) * s.copper_mg) as copper_mg,
SUM(COALESCE(sl.dose_g,1) * s.selenium_mcg) as selenium_mcg,
SUM(COALESCE(sl.dose_g,1) * s.potassium_mg) as potassium_mg,
SUM(COALESCE(sl.dose_g,1) * s.sodium_mg) as sodium_mg
FROM supplement_log sl
JOIN supplements s ON sl.supplement_id = s.id
WHERE sl.date = ?1`,
[date]
);
return rows[0] || {};
}
// ---------------------------------------------------------------------------
// Analytics
// ---------------------------------------------------------------------------
async function getFoodTotalsForDate(date) {
const rows = await select(
`SELECT SUM(kcal) as kcal, SUM(protein_g) as protein_g,
SUM(fat_g) as fat_g, SUM(carbs_g) as carbs_g, SUM(fiber_g) as fiber_g
FROM food_log WHERE date = ?1`,
[date]
);
return rows[0] ?? { kcal: 0, protein_g: 0, fat_g: 0, carbs_g: 0, fiber_g: 0 };
}
async function getActivityTotalsForDate(date) {
const rows = await select(
"SELECT SUM(kcal) as kcal, SUM(minutes) as minutes FROM activity_log WHERE date = ?1",
[date]
);
return rows[0] ?? { kcal: 0, minutes: 0 };
}
// ---------------------------------------------------------------------------
// GPS sessions & points
// ---------------------------------------------------------------------------
async function createGpsSession(date, startTime, activityType) {
await execute(
`INSERT INTO gps_sessions (date, start_time, activity_type, source)
VALUES (?1, ?2, ?3, 'recorded')`,
[date, startTime, activityType]
);
const rows = await select("SELECT last_insert_rowid() as id");
return rows[0].id;
}
async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs) {
await execute(
`INSERT INTO gps_points (session_id, ts, lat, lon, altitude_gps_m, speed_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
[sessionId, ts, lat, lon, altGps ?? null, speedMs ?? null]
);
}
async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss) {
await execute(
`UPDATE gps_sessions SET end_time=?1, total_distance_m=?2, total_kcal=?3,
avg_speed_kmh=?4, max_speed_kmh=?5, elevation_gain_m=?6, elevation_loss_m=?7
WHERE id=?8`,
[endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss, id]
);
}
async function getGpsSessions(limit) {
return select(
"SELECT * FROM gps_sessions ORDER BY date DESC, start_time DESC LIMIT ?1",
[limit ?? 50]
);
}
async function getGpsSession(id) {
const rows = await select("SELECT * FROM gps_sessions WHERE id = ?1", [id]);
return rows.length ? rows[0] : null;
}
async function getGpsPoints(sessionId) {
return select("SELECT * FROM gps_points WHERE session_id = ?1 ORDER BY COALESCE(seq, 999999), ts ASC", [sessionId]);
}
async function deleteGpsSession(id) {
await execute("DELETE FROM gps_points WHERE session_id = ?1", [id]);
await execute("DELETE FROM gps_sessions WHERE id = ?1", [id]);
}
async function importGpxSession(session, points) {
await execute(
`INSERT INTO gps_sessions (date, start_time, end_time, activity_type,
total_distance_m, total_kcal, avg_speed_kmh, max_speed_kmh,
elevation_gain_m, elevation_loss_m, source)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)`,
[session.date, session.start_time, session.end_time, session.activity_type,
session.total_distance_m, session.total_kcal, session.avg_speed_kmh,
session.max_speed_kmh, session.elevation_gain_m, session.elevation_loss_m,
session.source ?? "gpx_import"]
);
const rows = await select("SELECT last_insert_rowid() as id");
const sid = rows[0].id;
// Batch insert points — build multi-row INSERT to avoid 1 IPC per point
const BATCH = 50;
for (let i = 0; i < points.length; i += BATCH) {
const chunk = points.slice(i, i + BATCH);
const placeholders = [];
const vals = [];
let idx = 1;
for (const p of chunk) {
placeholders.push(`(?${idx},?${idx+1},?${idx+2},?${idx+3},?${idx+4},?${idx+5},?${idx+6})`);
vals.push(sid, p.ts, p.lat, p.lon, p.altitude ?? null, p.speed ?? null, p.seq ?? null);
idx += 7;
}
await execute(
`INSERT INTO gps_points (session_id, ts, lat, lon, altitude_gps_m, speed_ms, seq) VALUES ${placeholders.join(",")}`,
vals
);
}
return sid;
}
// Expose as globals — no ES module system, loaded via <script> tags.
// main.js accesses these as window.DB.xxx
window.DB = {};
window.DB.getProfileValue = getProfileValue;
window.DB.setProfileValue = setProfileValue;
window.DB.getFullProfile = getFullProfile;
window.DB.addWeight = addWeight;
window.DB.deleteWeight = deleteWeight;
window.DB.getWeightsForDate = getWeightsForDate;
window.DB.getLatestWeight = getLatestWeight;
window.DB.getRecentWeights = getRecentWeights;
window.DB.getSleepForDate = getSleepForDate;
window.DB.saveSleep = saveSleep;
window.DB.searchProducts = searchProducts;
window.DB.getProduct = getProduct;
window.DB.addProduct = addProduct;
window.DB.getFoodForDate = getFoodForDate;
window.DB.addFoodManual = addFoodManual;
window.DB.addFoodEntry = addFoodEntry;
window.DB.updateFoodEntry = updateFoodEntry;
window.DB.deleteFoodEntry = deleteFoodEntry;
window.DB.getActivitiesForDate = getActivitiesForDate;
window.DB.addActivity = addActivity;
window.DB.deleteActivity = deleteActivity;
window.DB.getSupplements = getSupplements;
window.DB.getSupplement = getSupplement;
window.DB.addSupplement = addSupplement;
window.DB.deleteSupplement = deleteSupplement;
window.DB.logSupplement = logSupplement;
window.DB.getSupplementLogForDate = getSupplementLogForDate;
window.DB.deleteSupplementLog = deleteSupplementLog;
window.DB.getSupplementTotalsForDate = getSupplementTotalsForDate;
window.DB.getFoodTotalsForDate = getFoodTotalsForDate;
window.DB.getActivityTotalsForDate = getActivityTotalsForDate;
window.DB.createGpsSession = createGpsSession;
window.DB.addGpsPoint = addGpsPoint;
window.DB.finishGpsSession = finishGpsSession;
window.DB.getGpsSessions = getGpsSessions;
window.DB.getGpsSession = getGpsSession;
window.DB.getGpsPoints = getGpsPoints;
window.DB.deleteGpsSession = deleteGpsSession;
window.DB.importGpxSession = importGpxSession;
console.log("[db.js] window.DB ready, keys:", Object.keys(window.DB).length);

416
frontend/js/gps.js Normal file
View file

@ -0,0 +1,416 @@
// GPS tracking module — recording, calculations, GPX import
// Exposes window.GPS
const GPS = {};
// ---------------------------------------------------------------------------
// Haversine distance (meters)
// ---------------------------------------------------------------------------
GPS.distance = function(lat1, lon1, lat2, lon2) {
const R = 6371000;
const toRad = d => d * Math.PI / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
// ---------------------------------------------------------------------------
// Kcal formula (FitoTrack-style MET based)
// kcal = minutes * (MET * 3.5 * weight_kg) / 200
// MET = speed_kmh * 0.97 (capped at minimum 1)
// + elevation_gain_m (1 kcal per meter ascent — rough estimate)
// ---------------------------------------------------------------------------
GPS.calcKcal = function(minutes, avgSpeedKmh, weightKg, elevGainM) {
const met = Math.max(avgSpeedKmh * 0.97, 1);
const base = minutes * (met * 3.5 * weightKg) / 200;
return Math.round(base + (elevGainM ?? 0));
};
// ---------------------------------------------------------------------------
// Compute session stats from array of {lat, lon, altitude, ts} points
// ---------------------------------------------------------------------------
GPS.computeStats = function(points, weightKg) {
if (points.length < 2) return { distance: 0, elevGain: 0, elevLoss: 0, avgSpeed: 0, maxSpeed: 0, kcal: 0, duration: 0 };
let distance = 0, elevGain = 0, elevLoss = 0;
const speeds = []; // m/s per segment
for (let i = 1; i < points.length; i++) {
const p = points[i], prev = points[i - 1];
const d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon);
distance += d;
const dt = (new Date(p.ts) - new Date(prev.ts)) / 1000;
speeds.push(dt > 0 ? d / dt : 0);
const alt = p.altitude ?? p.altitude_gps_m;
const prevAlt = prev.altitude ?? prev.altitude_gps_m;
if (alt != null && prevAlt != null) {
const diff = alt - prevAlt;
if (diff > 0) elevGain += diff;
else elevLoss += Math.abs(diff);
}
}
// 9-point smoothed speeds for maxSpeed (kills spikes from GPS noise)
let maxSpeed = 0;
for (let i = 0; i < speeds.length; i++) {
let sum = 0, cnt = 0;
for (let j = Math.max(0, i - 4); j <= Math.min(speeds.length - 1, i + 4); j++) {
sum += speeds[j]; cnt++;
}
const smooth = sum / cnt;
if (smooth > maxSpeed) maxSpeed = smooth;
}
const first = new Date(points[0].ts);
const last = new Date(points[points.length - 1].ts);
const duration = (last - first) / 1000;
const minutes = duration / 60;
const avgSpeed = duration > 0 ? (distance / duration) : 0;
const avgSpeedKmh = avgSpeed * 3.6;
const maxSpeedKmh = maxSpeed * 3.6;
const kcal = GPS.calcKcal(minutes, avgSpeedKmh, weightKg ?? 80, elevGain);
return {
distance: Math.round(distance),
elevGain: Math.round(elevGain),
elevLoss: Math.round(elevLoss),
avgSpeed: Math.round(avgSpeedKmh * 10) / 10,
maxSpeed: Math.round(maxSpeedKmh * 10) / 10,
kcal,
duration: Math.round(duration),
};
};
// ---------------------------------------------------------------------------
// Speed smoothing — 3-point moving average
// ---------------------------------------------------------------------------
GPS.smoothSpeeds = function(points) {
const speeds = [];
for (let i = 0; i < points.length; i++) {
if (i === 0 || i === points.length - 1) {
speeds.push(points[i].speed_ms ?? 0);
} else {
const a = points[i-1].speed_ms ?? 0;
const b = points[i].speed_ms ?? 0;
const c = points[i+1].speed_ms ?? 0;
speeds.push((a + b + c) / 3);
}
}
return speeds;
};
// ---------------------------------------------------------------------------
// Live recording state
// ---------------------------------------------------------------------------
GPS.recording = {
active: false,
sessionId: null,
watchId: null,
points: [],
startTime: null,
activityType: "bike",
};
GPS.startRecording = async function(activityType, weightKg) {
if (GPS.recording.active) return;
if (!navigator.geolocation) throw new Error("Geolocation not available");
GPS.recording.active = true;
GPS.recording.activityType = activityType || "bike";
GPS.recording.points = [];
GPS.recording.startTime = new Date();
GPS.recording.weightKg = weightKg ?? 80;
const now = GPS.recording.startTime;
GPS.recording.sessionId = await DB.createGpsSession(
now.toISOString().slice(0, 10),
now.toTimeString().slice(0, 5),
GPS.recording.activityType
);
GPS.recording.watchId = navigator.geolocation.watchPosition(
async (pos) => {
const p = {
lat: pos.coords.latitude,
lon: pos.coords.longitude,
altitude: pos.coords.altitude,
speed_ms: pos.coords.speed,
ts: new Date(pos.timestamp).toISOString(),
};
GPS.recording.points.push(p);
await DB.addGpsPoint(
GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms
);
if (GPS.recording.onPoint) GPS.recording.onPoint(p);
},
(err) => {
console.error("GPS error:", err.message);
if (GPS.recording.onError) GPS.recording.onError(err);
},
{ enableHighAccuracy: true, maximumAge: 2000, timeout: 10000 }
);
};
GPS.stopRecording = async function() {
if (!GPS.recording.active) return null;
GPS.recording.active = false;
if (GPS.recording.watchId != null) {
navigator.geolocation.clearWatch(GPS.recording.watchId);
GPS.recording.watchId = null;
}
const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg);
const endTime = new Date().toTimeString().slice(0, 5);
await DB.finishGpsSession(
GPS.recording.sessionId, endTime,
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
stats.elevGain, stats.elevLoss
);
const sid = GPS.recording.sessionId;
GPS.recording.sessionId = null;
GPS.recording.points = [];
return { sessionId: sid, stats };
};
// ---------------------------------------------------------------------------
// GPX import
// ---------------------------------------------------------------------------
GPS.parseGPX = function(xmlString) {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlString, "application/xml");
const trkpts = doc.querySelectorAll("trkpt");
const points = [];
for (const pt of trkpts) {
const lat = parseFloat(pt.getAttribute("lat"));
const lon = parseFloat(pt.getAttribute("lon"));
const eleEl = pt.querySelector("ele");
const timeEl = pt.querySelector("time");
const spdEl = pt.querySelector("speed");
points.push({
lat, lon,
altitude: eleEl ? parseFloat(eleEl.textContent) : null,
ts: timeEl ? timeEl.textContent : null,
speed: spdEl ? parseFloat(spdEl.textContent) : null,
});
}
// Activity type from GPX <type> tag
const typeEl = doc.querySelector("type");
const actType = typeEl ? typeEl.textContent.toLowerCase() : "other";
return { points, activityType: actType };
};
GPS.importGPX = async function(xmlString, weightKg) {
const { points, activityType } = GPS.parseGPX(xmlString);
if (points.length < 2) throw new Error("GPX has fewer than 2 points");
const stats = GPS.computeStats(points, weightKg ?? 80);
const first = points[0].ts ? new Date(points[0].ts) : new Date();
const last = points[points.length - 1].ts ? new Date(points[points.length - 1].ts) : new Date();
const session = {
date: first.toISOString().slice(0, 10),
start_time: first.toTimeString().slice(0, 5),
end_time: last.toTimeString().slice(0, 5),
activity_type: actType(activityType),
total_distance_m: stats.distance,
total_kcal: stats.kcal,
avg_speed_kmh: stats.avgSpeed,
max_speed_kmh: stats.maxSpeed,
elevation_gain_m: stats.elevGain,
elevation_loss_m: stats.elevLoss,
source: "gpx_import",
};
const dbPoints = points.map(p => ({
ts: p.ts, lat: p.lat, lon: p.lon, altitude: p.altitude, speed: p.speed,
}));
return DB.importGpxSession(session, dbPoints);
};
// ---------------------------------------------------------------------------
// FitoTrack .ftb import (zip containing data.xml)
// ---------------------------------------------------------------------------
GPS.importFTB = async function(arrayBuffer, weightKg) {
const zip = await JSZip.loadAsync(arrayBuffer);
const xmlFile = zip.file("data.xml");
if (!xmlFile) throw new Error("data.xml not found in .ftb");
const xmlStr = await xmlFile.async("string");
const parser = new DOMParser();
const doc = parser.parseFromString(xmlStr, "application/xml");
// Parse workouts
const workoutEls = doc.querySelectorAll("workouts > workouts");
const sampleEls = doc.querySelectorAll("samples > samples");
// Group samples by workoutId
const samplesByWorkout = {};
for (const s of sampleEls) {
const wid = s.querySelector("workoutId")?.textContent;
if (!wid) continue;
if (!samplesByWorkout[wid]) samplesByWorkout[wid] = [];
samplesByWorkout[wid].push({
lat: parseFloat(s.querySelector("lat")?.textContent),
lon: parseFloat(s.querySelector("lon")?.textContent),
altitude: parseFloat(s.querySelector("elevationMSL")?.textContent) || parseFloat(s.querySelector("elevation")?.textContent) || null,
speed: parseFloat(s.querySelector("speed")?.textContent) || null,
ts: new Date(parseInt(s.querySelector("absoluteTime")?.textContent)).toISOString(),
});
}
let imported = 0;
for (const w of workoutEls) {
const el = tag => w.querySelector(tag)?.textContent;
const wid = el("id");
const samples = samplesByWorkout[wid] || [];
if (samples.length < 2) continue;
const startMs = parseInt(el("start"));
const endMs = parseInt(el("end"));
const startDate = new Date(startMs);
const endDate = new Date(endMs);
const session = {
date: startDate.toISOString().slice(0, 10),
start_time: startDate.toTimeString().slice(0, 5),
end_time: endDate.toTimeString().slice(0, 5),
activity_type: ftbType(el("workoutType")),
total_distance_m: parseFloat(el("length")) || null,
total_kcal: parseFloat(el("calorie")) || null,
avg_speed_kmh: parseFloat(el("avgSpeed")) ? Math.round(parseFloat(el("avgSpeed")) * 3.6 * 10) / 10 : null,
max_speed_kmh: parseFloat(el("topSpeed")) ? Math.round(parseFloat(el("topSpeed")) * 3.6 * 10) / 10 : null,
elevation_gain_m: parseFloat(el("ascent")) || null,
elevation_loss_m: parseFloat(el("descent")) || null,
source: "fitotrack_import",
};
const dbPoints = samples.map((p, idx) => ({
ts: p.ts, lat: p.lat, lon: p.lon, altitude: p.altitude, speed: p.speed, seq: idx,
}));
await DB.importGpxSession(session, dbPoints);
imported++;
}
return imported;
};
function ftbType(raw) {
const r = (raw || "").toLowerCase();
if (r.includes("cycl") || r.includes("bik")) return "bike";
if (r.includes("run")) return "run";
if (r.includes("walk") || r.includes("hik")) return "walk";
return "other";
}
// ---------------------------------------------------------------------------
// Interpolate points where GPS gap > threshold (e.g. tunnel, signal loss)
// Adds intermediate points with linear lat/lon/altitude interpolation
// ---------------------------------------------------------------------------
GPS.interpolate = function(points, maxGapSec = 30) {
if (points.length < 2) return points;
const result = [points[0]];
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const cur = points[i];
const prevT = new Date(prev.ts).getTime();
const curT = new Date(cur.ts).getTime();
const gapSec = (curT - prevT) / 1000;
if (gapSec > maxGapSec) {
// Insert interpolated points every ~3 seconds
const steps = Math.min(Math.ceil(gapSec / 3), 100);
for (let s = 1; s < steps; s++) {
const frac = s / steps;
const t = prevT + (curT - prevT) * frac;
result.push({
lat: prev.lat + (cur.lat - prev.lat) * frac,
lon: prev.lon + (cur.lon - prev.lon) * frac,
altitude: (prev.altitude != null && cur.altitude != null)
? prev.altitude + (cur.altitude - prev.altitude) * frac : null,
altitude_gps_m: (prev.altitude_gps_m != null && cur.altitude_gps_m != null)
? prev.altitude_gps_m + (cur.altitude_gps_m - prev.altitude_gps_m) * frac : null,
speed_ms: (prev.speed_ms != null && cur.speed_ms != null)
? prev.speed_ms + (cur.speed_ms - prev.speed_ms) * frac : null,
ts: new Date(t).toISOString(),
interpolated: true,
});
}
}
result.push(cur);
}
return result;
};
// ---------------------------------------------------------------------------
// Filter GPS noise — remove points with unrealistic speed (>150 km/h)
// and break track into segments at large time gaps
// ---------------------------------------------------------------------------
// FitoTrack-style filter:
// 1. Skip points too close in time (<500ms)
// 2. Skip points with unrealistic speed (>maxSpeedKmh)
// 3. Skip points too close in distance (<minDistM) — configurable per activity
// Nearest-neighbor reorder: from current point, always jump to the closest
// unused point. No points removed. Untangles AGPS alternation.
// Identical to the debug version that worked.
// Reorder points: normal points go chronologically, AGPS points (with wrong
// timestamps) get inserted at their nearest spatial neighbor.
// Detection: a point is "AGPS-displaced" if it's far (>50m) from BOTH its
// chronological predecessor and successor — meaning its timestamp is wrong.
// Just return points as-is. Order comes from DB: seq (FTB XML order), then ts.
// This matches FitoTrack v15.6 which draws by insertion order without sorting.
GPS.filterNoise = function(points, activityType) {
return points;
};
// Build polyline segments — break at gaps > maxGapSec
// Returns array of arrays of [lat,lon] for separate polylines
GPS.buildSegments = function(points, maxGapSec = 120) {
if (points.length === 0) return [];
const segments = [[[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 > maxGapSec) {
segments.push([]);
}
segments[segments.length - 1].push([points[i].lat, points[i].lon]);
}
return segments.filter(s => s.length > 1);
};
function actType(raw) {
const r = (raw || "").toLowerCase();
if (r.includes("bik") || r.includes("cycl")) return "bike";
if (r.includes("run")) return "run";
if (r.includes("walk") || r.includes("hik")) return "walk";
return "other";
}
// ---------------------------------------------------------------------------
// Format helpers
// ---------------------------------------------------------------------------
GPS.formatDuration = function(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return h > 0 ? `${h}:${String(m).padStart(2,"0")}:${String(s).padStart(2,"0")}`
: `${m}:${String(s).padStart(2,"0")}`;
};
GPS.formatDistance = function(meters) {
return meters >= 1000 ? (meters / 1000).toFixed(2) + " км" : meters + " м";
};
window.GPS = GPS;
console.log("[gps.js] window.GPS ready");

1602
frontend/js/main.js Normal file

File diff suppressed because it is too large Load diff

161
frontend/js/tilecache.js Normal file
View file

@ -0,0 +1,161 @@
// Offline tile cache — download OSM tiles to IndexedDB, serve locally
// Exposes window.TileCache
const TileCache = {};
const DB_NAME = "baldav_tiles";
const STORE = "tiles";
let _db = null;
function openDB() {
if (_db) return Promise.resolve(_db);
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => { _db = req.result; resolve(_db); };
req.onerror = () => reject(req.error);
});
}
function putTile(key, blob) {
return openDB().then(db => new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).put(blob, key);
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
}));
}
function getTile(key) {
return openDB().then(db => new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readonly");
const req = tx.objectStore(STORE).get(key);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
}));
}
// Count cached tiles
TileCache.count = async function() {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readonly");
const req = tx.objectStore(STORE).count();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
};
// Clear all cached tiles
TileCache.clear = async function() {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).clear();
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
};
// Calculate tile coordinates for a bounding box at a given zoom
function getTileRange(bounds, zoom) {
const n = Math.pow(2, zoom);
const toTile = (lat, lon) => ({
x: Math.floor((lon + 180) / 360 * n),
y: Math.floor((1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * n),
});
const sw = toTile(bounds.getSouth(), bounds.getWest());
const ne = toTile(bounds.getNorth(), bounds.getEast());
return {
xMin: Math.min(sw.x, ne.x), xMax: Math.max(sw.x, ne.x),
yMin: Math.min(sw.y, ne.y), yMax: Math.max(sw.y, ne.y),
};
}
// Count tiles needed for area + zoom range
TileCache.countTilesForArea = function(bounds, zoomMin, zoomMax) {
let total = 0;
for (let z = zoomMin; z <= zoomMax; z++) {
const r = getTileRange(bounds, z);
total += (r.xMax - r.xMin + 1) * (r.yMax - r.yMin + 1);
}
return total;
};
// Download tiles for current map bounds
// onProgress(downloaded, total) callback
TileCache.downloadArea = async function(bounds, zoomMin, zoomMax, onProgress) {
const tiles = [];
for (let z = zoomMin; z <= zoomMax; z++) {
const r = getTileRange(bounds, z);
for (let x = r.xMin; x <= r.xMax; x++) {
for (let y = r.yMin; y <= r.yMax; y++) {
tiles.push({ z, x, y });
}
}
}
let done = 0;
const PARALLEL = 4;
async function downloadOne(tile) {
const key = `${tile.z}/${tile.x}/${tile.y}`;
// Skip if already cached
const existing = await getTile(key);
if (existing) { done++; if (onProgress) onProgress(done, tiles.length); return; }
const url = `https://tile.openstreetmap.org/${key}.png`;
try {
const resp = await fetch(url);
if (resp.ok) {
const blob = await resp.blob();
await putTile(key, blob);
}
} catch { /* skip failed tiles */ }
done++;
if (onProgress) onProgress(done, tiles.length);
}
// Process in parallel batches
for (let i = 0; i < tiles.length; i += PARALLEL) {
const batch = tiles.slice(i, i + PARALLEL);
await Promise.all(batch.map(downloadOne));
}
return done;
};
// Custom Leaflet TileLayer that checks IndexedDB first
TileCache.createLayer = function() {
return L.TileLayer.extend({
createTile: function(coords, done) {
const tile = document.createElement("img");
const key = `${coords.z}/${coords.x}/${coords.y}`;
getTile(key).then(blob => {
if (blob) {
tile.src = URL.createObjectURL(blob);
done(null, tile);
} else {
// Fallback to network
tile.crossOrigin = "";
tile.src = `https://tile.openstreetmap.org/${key}.png`;
// Cache after download
tile.onload = () => {
fetch(tile.src).then(r => r.blob()).then(b => putTile(key, b)).catch(() => {});
done(null, tile);
};
tile.onerror = () => done(new Error("tile failed"), tile);
}
}).catch(() => {
tile.src = `https://tile.openstreetmap.org/${key}.png`;
tile.onload = () => done(null, tile);
tile.onerror = () => done(new Error("tile failed"), tile);
});
return tile;
}
});
};
window.TileCache = TileCache;
console.log("[tilecache.js] window.TileCache ready");

20
frontend/lib/chart.min.js vendored Normal file

File diff suppressed because one or more lines are too long

13
frontend/lib/jszip.min.js vendored Normal file

File diff suppressed because one or more lines are too long

661
frontend/lib/leaflet.css Normal file
View file

@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

6
frontend/lib/leaflet.js Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

17
package.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "baldav",
"private": true,
"version": "0.1.0",
"scripts": {
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/cli": "^2"
},
"dependencies": {
"puppeteer": "^24.43.1",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2"
}
}

17
src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "baldav"
version = "0.1.0"
edition = "2021"
[lib]
name = "baldav_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

3
src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

196
src-tauri/src/lib.rs Normal file
View file

@ -0,0 +1,196 @@
use tauri_plugin_sql::{Migration, MigrationKind};
pub fn run() {
let migrations = vec![
Migration {
version: 1,
description: "full schema v1",
sql: r#"
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS profile (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
brand TEXT,
category TEXT,
weight_g REAL,
kcal_per_100g REAL,
protein_per_100g REAL,
fat_per_100g REAL,
carbs_per_100g REAL,
fiber_per_100g REAL,
vit_a_mcg REAL,
vit_c_mg REAL,
vit_d_mcg REAL,
vit_e_mg REAL,
vit_k_mcg REAL,
vit_b1_mg REAL,
vit_b2_mg REAL,
vit_b3_mg REAL,
vit_b6_mg REAL,
vit_b12_mcg REAL,
folate_mcg REAL,
calcium_mg REAL,
iron_mg REAL,
magnesium_mg REAL,
zinc_mg REAL,
copper_mg REAL,
selenium_mcg REAL,
potassium_mg REAL,
sodium_mg REAL,
source TEXT,
store_id TEXT,
image_path TEXT,
created_at TEXT DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS supplements (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
daily_dose_g REAL,
vit_a_mcg REAL,
vit_c_mg REAL,
vit_d_mcg REAL,
vit_e_mg REAL,
vit_k_mcg REAL,
vit_b1_mg REAL,
vit_b2_mg REAL,
vit_b3_mg REAL,
vit_b6_mg REAL,
vit_b12_mcg REAL,
folate_mcg REAL,
calcium_mg REAL,
iron_mg REAL,
magnesium_mg REAL,
zinc_mg REAL,
copper_mg REAL,
selenium_mcg REAL,
potassium_mg REAL,
sodium_mg REAL
);
CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
cooking_method TEXT,
cooking_temp_c INTEGER,
servings INTEGER DEFAULT 1,
notes TEXT
);
CREATE TABLE IF NOT EXISTS recipe_ingredients (
id INTEGER PRIMARY KEY,
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL REFERENCES products(id),
amount_g REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS weight_log (
id INTEGER PRIMARY KEY,
date TEXT NOT NULL,
time TEXT,
weight_kg REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS sleep_log (
id INTEGER PRIMARY KEY,
date TEXT NOT NULL,
bed_time TEXT,
asleep_time TEXT,
wake_time TEXT,
nap_minutes INTEGER DEFAULT 0,
notes TEXT
);
CREATE TABLE IF NOT EXISTS food_log (
id INTEGER PRIMARY KEY,
date TEXT NOT NULL,
time TEXT,
product_id INTEGER REFERENCES products(id),
recipe_id INTEGER REFERENCES recipes(id),
amount_g REAL NOT NULL,
kcal REAL,
protein_g REAL,
fat_g REAL,
carbs_g REAL,
fiber_g REAL,
note TEXT
);
CREATE TABLE IF NOT EXISTS supplement_log (
id INTEGER PRIMARY KEY,
date TEXT NOT NULL,
time TEXT,
supplement_id INTEGER NOT NULL REFERENCES supplements(id),
dose_g REAL
);
CREATE TABLE IF NOT EXISTS gps_sessions (
id INTEGER PRIMARY KEY,
date TEXT NOT NULL,
start_time TEXT,
end_time TEXT,
activity_type TEXT,
total_distance_m REAL,
total_kcal REAL,
avg_speed_kmh REAL,
max_speed_kmh REAL,
elevation_gain_m REAL,
elevation_loss_m REAL,
source TEXT
);
CREATE TABLE IF NOT EXISTS gps_points (
id INTEGER PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES gps_sessions(id) ON DELETE CASCADE,
ts TEXT NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
altitude_gps_m REAL,
altitude_srtm_m REAL,
speed_ms REAL,
heart_rate INTEGER
);
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY,
date TEXT NOT NULL,
time TEXT,
type TEXT,
desc TEXT,
minutes INTEGER,
kcal REAL,
gps_session_id INTEGER REFERENCES gps_sessions(id)
);
CREATE INDEX IF NOT EXISTS idx_food_log_date ON food_log(date);
CREATE INDEX IF NOT EXISTS idx_activity_log_date ON activity_log(date);
CREATE INDEX IF NOT EXISTS idx_weight_log_date ON weight_log(date);
CREATE INDEX IF NOT EXISTS idx_sleep_log_date ON sleep_log(date);
CREATE INDEX IF NOT EXISTS idx_gps_points_sess ON gps_points(session_id);
CREATE INDEX IF NOT EXISTS idx_products_name ON products(name COLLATE NOCASE);
"#,
kind: MigrationKind::Up,
},
Migration {
version: 2,
description: "add composition column to products",
sql: "ALTER TABLE products ADD COLUMN composition TEXT;",
kind: MigrationKind::Up,
},
];
tauri::Builder::default()
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations("sqlite:baldav.db", migrations)
.build(),
)
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

5
src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
baldav_lib::run()
}

32
src-tauri/tauri.conf.json Normal file
View file

@ -0,0 +1,32 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
"productName": "BalDav",
"version": "0.1.0",
"identifier": "dev.baldav.app",
"build": {
"frontendDist": "../frontend"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "BalDav",
"width": 400,
"height": 700,
"resizable": true
}
]
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/icon.png"
]
},
"plugins": {
"sql": {
"preload": ["sqlite:baldav.db"]
}
}
}