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:
commit
1affadb05d
22 changed files with 4850 additions and 0 deletions
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal 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
1157
frontend/css/styles.css
Normal file
File diff suppressed because it is too large
Load diff
24
frontend/index.html
Normal file
24
frontend/index.html
Normal 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
14
frontend/js/bmr.js
Normal 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
492
frontend/js/db.js
Normal 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
416
frontend/js/gps.js
Normal 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
1602
frontend/js/main.js
Normal file
File diff suppressed because it is too large
Load diff
161
frontend/js/tilecache.js
Normal file
161
frontend/js/tilecache.js
Normal 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
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
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
661
frontend/lib/leaflet.css
Normal 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
6
frontend/lib/leaflet.js
Normal file
File diff suppressed because one or more lines are too long
BIN
frontend/lib/marker-icon-2x.png
Normal file
BIN
frontend/lib/marker-icon-2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
frontend/lib/marker-icon.png
Normal file
BIN
frontend/lib/marker-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/lib/marker-shadow.png
Normal file
BIN
frontend/lib/marker-shadow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
17
package.json
Normal file
17
package.json
Normal 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
17
src-tauri/Cargo.toml
Normal 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
3
src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
BIN
src-tauri/icons/icon.png
Normal file
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
196
src-tauri/src/lib.rs
Normal 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
5
src-tauri/src/main.rs
Normal 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
32
src-tauri/tauri.conf.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue