baldav/frontend/js/db.js
4aiman 5d6025ea9c Desktop: synchronous track-detail render + text session ids
showSessionDetail rendered its map/charts/buttons inside setTimeout blocks
guarded by renderToken. On WebKitGTK the deferred render was voided, so the
track detail showed no map, charts, or fullscreen button (while the
synchronously-rendered roads view worked).

- Remove all setTimeout/requestAnimationFrame/IIFE from showSessionDetail;
  render map, charts and wire buttons synchronously right after the modal
  markup exists (like showRoadsMap). Early `return`s become `if` guards;
  goals/food-cost keep real `await`. Force layout (offsetHeight) + call
  invalidateSize so Leaflet reads the real container size in the modal.

- Fix large i64 session-id corruption from tauri-plugin-sql 2.4.0 (ids come
  back off by 1 as JS numbers). Read/pass session ids as text via
  CAST(id AS TEXT) AS id_str everywhere they are reused: Trek list, Today
  click, compare modal/view, goal-marker prev sessions, map default-center,
  and importGpxSession (whose points were orphaned by the corrupted id).
  SQLite coerces the string to the INTEGER column. Point ids (max ~135k)
  are small and unaffected.
2026-07-03 22:19:58 +03:00

1633 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, nutrients_guessed, source, water_pct,
vit_a_mcg, vit_c_mg, vit_b1_mg, vit_b2_mg, vit_b3_mg, vit_e_mg,
calcium_mg, iron_mg, magnesium_mg, potassium_mg, sodium_mg, phosphorus_mg,
carotene_mcg, selenium_mcg, zinc_mg, copper_mg, folate_mcg,
vit_d_mcg, vit_k_mcg, vit_b6_mg, vit_b12_mcg
FROM products
WHERE name_lower LIKE ?1
GROUP BY COALESCE(store_id, id)
ORDER BY
CASE WHEN name_lower LIKE ?2 THEN 0 ELSE 1 END,
length(name),
name COLLATE NOCASE
LIMIT ?3`,
[`%${q}%`, `${q}%`, limit ?? 200]
);
}
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) {
await execute(
`INSERT INTO products
(name, name_lower, 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, phosphorus_mg, carotene_mcg, source, water_pct)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23,?24,?25,?26,?27,?28,?29,?30,?31,?32,?33)`,
[
p.name, p.name?.toLowerCase() ?? null,
p.brand ?? null, p.category ?? null, p.weight_g ?? null,
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.phosphorus_mg ?? null, p.carotene_mcg ?? null,
p.source ?? "manual", p.water_pct ?? null,
]
);
const rows = await select("SELECT id FROM products ORDER BY id DESC LIMIT 1");
return rows[0]?.id;
}
async function getProductRefs(id) {
const food = await select("SELECT COUNT(*) as cnt FROM food_log WHERE product_id = ?1", [id]);
const recipes = await select("SELECT COUNT(*) as cnt FROM recipe_ingredients WHERE product_id = ?1", [id]);
return { food: food[0]?.cnt || 0, recipes: recipes[0]?.cnt || 0 };
}
async function deleteProduct(id) {
await execute("DELETE FROM products WHERE id = ?1", [id]);
}
// ---------------------------------------------------------------------------
// Cooking methods & Recipes
// ---------------------------------------------------------------------------
async function getCookingMethods() {
return select("SELECT * FROM cooking_methods ORDER BY id");
}
async function getCookingMethod(id) {
const rows = await select("SELECT * FROM cooking_methods WHERE id = ?1", [id]);
return rows.length ? rows[0] : null;
}
async function getRecipes() {
return select(
`SELECT r.*, cm.name as cooking_method_name
FROM recipes r
LEFT JOIN cooking_methods cm ON r.cooking_method_id = cm.id
ORDER BY r.created_at DESC`
);
}
async function getRecipe(id) {
const rows = await select(
`SELECT r.*, cm.name as cooking_method_name
FROM recipes r
LEFT JOIN cooking_methods cm ON r.cooking_method_id = cm.id
WHERE r.id = ?1`,
[id]
);
return rows.length ? rows[0] : null;
}
async function getRecipeIngredients(recipeId) {
return select(
`SELECT ri.*, p.name as product_name, p.kcal_per_100g,
p.protein_per_100g, p.fat_per_100g, p.carbs_per_100g,
p.fiber_per_100g,
p.vit_a_mcg, p.vit_c_mg, p.vit_d_mcg, p.vit_e_mg, p.vit_k_mcg,
p.vit_b1_mg, p.vit_b2_mg, p.vit_b3_mg, p.vit_b6_mg,
p.vit_b12_mcg, p.folate_mcg,
p.calcium_mg, p.iron_mg, p.magnesium_mg, p.zinc_mg, p.copper_mg,
p.selenium_mcg, p.potassium_mg, p.sodium_mg,
p.water_pct, cm.name as cooking_method_name
FROM recipe_ingredients ri
LEFT JOIN products p ON ri.product_id = p.id AND ri.product_id > 0
LEFT JOIN cooking_methods cm ON ri.cooking_method_id = cm.id
WHERE ri.recipe_id = ?1
ORDER BY ri.id`,
[recipeId]
);
}
async function saveRecipe(name, cookingMethodId, servings, notes) {
await execute(
`INSERT INTO recipes (name, cooking_method_id, servings, notes, created_at)
VALUES (?1, ?2, ?3, ?4, datetime('now'))`,
[name, cookingMethodId ?? null, servings ?? 1, notes ?? null]
);
// lastInsertId from tauri-plugin-sql is unreliable; query the actual id
const rows = await select("SELECT id FROM recipes ORDER BY id DESC LIMIT 1");
return rows[0]?.id;
}
async function updateRecipe(id, name, cookingMethodId, servings, notes) {
await execute(
`UPDATE recipes SET name=?1, cooking_method_id=?2, servings=?3, notes=?4
WHERE id=?5`,
[name, cookingMethodId ?? null, servings ?? 1, notes ?? null, id]
);
}
async function deleteRecipe(id) {
await execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?1", [id]);
await execute("DELETE FROM recipes WHERE id = ?1", [id]);
}
async function addRecipeIngredient(recipeId, productId, customName, weightG, cookingMethodId) {
const r = await execute(
`INSERT INTO recipe_ingredients (recipe_id, product_id, custom_name, amount_g, cooking_method_id)
VALUES (?1, ?2, ?3, ?4, ?5)`,
[recipeId, productId ?? 0, customName ?? null, weightG, cookingMethodId ?? null]
);
return r.lastInsertId;
}
async function deleteRecipeIngredient(id) {
await execute("DELETE FROM recipe_ingredients WHERE id = ?1", [id]);
}
async function clearRecipeIngredients(recipeId) {
await execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?1", [recipeId]);
}
// Calculate nutrition for a recipe per serving.
// For each ingredient: (product nutrients / 100) * weight_g, then apply cooking loss.
// Cooking loss is taken from ingredient's own cooking_method_id, or recipe's default.
async function calculateRecipeNutrition(recipeId) {
const recipe = await getRecipe(recipeId);
if (!recipe) return null;
const ingredients = await getRecipeIngredients(recipeId);
const servings = recipe.servings || 1;
// Cache cooking methods to avoid repeated queries
const cmCache = {};
async function getCM(cmId) {
if (!cmId) return null;
if (cmCache[cmId]) return cmCache[cmId];
cmCache[cmId] = await getCookingMethod(cmId);
return cmCache[cmId];
}
// All micronutrient keys tracked in products table
const microKeys = [
"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",
];
const totals = { kcal: 0, protein: 0, fat: 0, carbs: 0, fiber: 0, water_ml: 0 };
for (const mk of microKeys) totals[mk] = 0;
for (const ing of ingredients) {
if (!ing.product_id || ing.product_id === 0) continue; // skip custom ingredients without product data
const factor = (ing.amount_g || 0) / 100;
// Raw nutrient values for this ingredient
const raw = {
kcal: (ing.kcal_per_100g ?? 0) * factor,
protein: (ing.protein_per_100g ?? 0) * factor,
fat: (ing.fat_per_100g ?? 0) * factor,
carbs: (ing.carbs_per_100g ?? 0) * factor,
fiber: (ing.fiber_per_100g ?? 0) * factor,
};
for (const mk of microKeys) raw[mk] = (ing[mk] ?? 0) * factor;
raw.water_ml = (ing.water_pct ?? 0) * (ing.amount_g || 0) / 100;
// Apply cooking loss: use ingredient's method, or recipe's default
const cmId = ing.cooking_method_id || recipe.cooking_method_id;
const cm = await getCM(cmId);
if (cm) {
// loss_pct is percentage lost (positive = loss, negative = gain like frying fat)
raw.kcal *= (1 - (cm.kcal_loss_pct ?? 0) / 100);
raw.protein *= (1 - (cm.protein_loss_pct ?? 0) / 100);
raw.fat *= (1 - (cm.fat_loss_pct ?? 0) / 100);
raw.carbs *= (1 - (cm.carbs_loss_pct ?? 0) / 100);
raw.fiber *= (1 - (cm.fiber_loss_pct ?? 0) / 100);
// Vitamin losses: cooking_methods table only has columns for a, c, b1, b2
raw.vit_a_mcg *= (1 - (cm.vit_a_loss_pct ?? 0) / 100);
raw.vit_c_mg *= (1 - (cm.vit_c_loss_pct ?? 0) / 100);
raw.vit_b1_mg *= (1 - (cm.vit_b1_loss_pct ?? 0) / 100);
raw.vit_b2_mg *= (1 - (cm.vit_b2_loss_pct ?? 0) / 100);
}
for (const k of Object.keys(totals)) {
totals[k] += raw[k];
}
}
// Per serving
const result = {
kcal: Math.round(totals.kcal / servings),
protein: Math.round(totals.protein / servings * 10) / 10,
fat: Math.round(totals.fat / servings * 10) / 10,
carbs: Math.round(totals.carbs / servings * 10) / 10,
fiber: Math.round(totals.fiber / servings * 10) / 10,
servings: servings,
totalWeight: ingredients.reduce((s, i) => s + (i.amount_g || 0), 0),
};
// Add all micronutrients per serving (rounded)
for (const mk of microKeys) {
result[mk] = Math.round(totals[mk] / servings * 100) / 100;
}
result.water_ml = Math.round(totals.water_ml / servings);
return result;
}
// Log a recipe as a food entry — stores calculated nutrition per serving
async function addFoodFromRecipe(date, time, recipeId, customAmountG) {
const nutr = await calculateRecipeNutrition(recipeId);
if (!nutr) return;
const servingG = Math.round(nutr.totalWeight / nutr.servings);
const amountG = customAmountG ?? servingG;
const scale = servingG > 0 ? amountG / servingG : 1;
await execute(
`INSERT INTO food_log
(date, time, recipe_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, recipeId, amountG,
Math.round(nutr.kcal * scale),
Math.round(nutr.protein * scale * 10) / 10,
Math.round(nutr.fat * scale * 10) / 10,
Math.round(nutr.carbs * scale * 10) / 10,
Math.round(nutr.fiber * scale * 10) / 10, null]
);
}
// ---------------------------------------------------------------------------
// Food log
// ---------------------------------------------------------------------------
async function getFoodForDate(date) {
return select(
`SELECT fl.*, p.name as product_name, p.brand as product_brand,
COALESCE(p.nutrients_guessed, 0) as nutrients_guessed, p.source as product_source,
p.vit_a_mcg, p.vit_c_mg, p.vit_d_mcg, p.vit_e_mg, p.vit_k_mcg,
p.vit_b1_mg, p.vit_b2_mg, p.vit_b3_mg, p.vit_b6_mg,
p.vit_b12_mcg, p.folate_mcg,
p.calcium_mg, p.iron_mg, p.magnesium_mg, p.zinc_mg, p.copper_mg,
p.selenium_mcg, p.potassium_mg, p.sodium_mg,
p.phosphorus_mg, p.carotene_mcg, p.water_pct,
r.name as recipe_name, r.cached_nutrients,
su.name as unit_name, su.name_short as unit_short
FROM food_log fl
LEFT JOIN products p ON fl.product_id = p.id
LEFT JOIN recipes r ON fl.recipe_id = r.id
LEFT JOIN serving_units su ON fl.unit_id = su.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, unitId, unitAmount) {
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, unit_id, unit_amount)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)`,
[date, time, productId ?? null, amountG,
kcal, protein, fat, carbs, fiber, note ?? null,
unitId ?? null, unitAmount ?? null]
);
}
async function updateFoodEntry(id, time, amountG, note, kcalOverride, unitId, unitAmount) {
// Recalculate nutrition from product or recipe
const rows = await select(
"SELECT product_id, recipe_id, amount_g, kcal, protein_g, fat_g, carbs_g, fiber_g FROM food_log WHERE id = ?1", [id]);
if (!rows.length) return;
const row = rows[0];
let kcal = kcalOverride, protein = null, fat = null, carbs = null, fiber = null;
if (row.product_id && kcalOverride == null) {
// Product: recalculate from kcal_per_100g
const p = await getProduct(row.product_id);
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;
}
} else if (row.recipe_id && kcalOverride == null && row.amount_g > 0) {
// Recipe: scale proportionally to amount change
const scale = amountG / row.amount_g;
kcal = Math.round((row.kcal ?? 0) * scale);
protein = Math.round((row.protein_g ?? 0) * scale * 10) / 10;
fat = Math.round((row.fat_g ?? 0) * scale * 10) / 10;
carbs = Math.round((row.carbs_g ?? 0) * scale * 10) / 10;
fiber = Math.round((row.fiber_g ?? 0) * scale * 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,
unit_id=?9, unit_amount=?10
WHERE id=?11`,
[time, amountG, note ?? null, kcal, protein, fat, carbs, fiber,
unitId ?? null, unitAmount ?? null, 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, opts) {
// Build column list dynamically — new columns may not exist yet on first run
const cols = ["date", "time", "type", "desc", "minutes", "kcal"];
const vals = [date, time, type ?? "manual", desc, minutes ?? null, kcal ?? null];
if (opts) {
const actCols = (await select("PRAGMA table_info(activity_log)")).map(c => c.name);
if (actCols.includes("activity_type")) { cols.push("activity_type"); vals.push(opts.activity_type ?? "manual"); }
if (actCols.includes("sets") && opts.sets) { cols.push("sets"); vals.push(opts.sets); }
if (actCols.includes("reps") && opts.reps) { cols.push("reps"); vals.push(opts.reps); }
if (actCols.includes("distance_m") && opts.distance_m) { cols.push("distance_m"); vals.push(opts.distance_m); }
}
const placeholders = cols.map((_, i) => `?${i + 1}`).join(",");
await execute(`INSERT INTO activity_log (${cols.join(",")}) VALUES (${placeholders})`, vals);
}
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 cols = [
"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_b5_mg", "vit_b6_mg",
"vit_b7_mcg", "vit_b12_mcg", "folate_mcg",
"calcium_mg", "iron_mg", "magnesium_mg", "zinc_mg", "copper_mg",
"selenium_mcg", "potassium_mg", "sodium_mg", "phosphorus_mg",
"iodine_mg", "chromium_mg", "manganese_mg", "molybdenum_mcg",
"silicon_mg", "vanadium_mg", "lutein_mg", "lycopene_mg", "carotene_mcg",
"fish_oil_mg", "omega3_mg", "epa_mg", "dha_mg", "omega6_mg", "omega9_mg",
"ginseng_mg", "resveratrol_mg", "coq10_mg",
];
const placeholders = cols.map((_, i) => `?${i + 1}`).join(",");
const vals = cols.map(c => s[c] ?? null);
const r = await execute(
`INSERT INTO supplements (${cols.join(",")}) VALUES (${placeholders})`,
vals
);
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_b5_mg) as vit_b5_mg,
SUM(COALESCE(sl.dose_g,1) * s.vit_b6_mg) as vit_b6_mg,
SUM(COALESCE(sl.dose_g,1) * s.vit_b7_mcg) as vit_b7_mcg,
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,
SUM(COALESCE(sl.dose_g,1) * s.chromium_mg) as chromium_mg,
SUM(COALESCE(sl.dose_g,1) * s.manganese_mg) as manganese_mg,
SUM(COALESCE(sl.dose_g,1) * s.molybdenum_mcg) as molybdenum_mcg,
SUM(COALESCE(sl.dose_g,1) * s.iodine_mg) as iodine_mg,
SUM(COALESCE(sl.dose_g,1) * s.phosphorus_mg) as phosphorus_mg,
SUM(COALESCE(sl.dose_g,1) * s.carotene_mcg) as carotene_mcg,
SUM(COALESCE(sl.dose_g,1) * s.omega3_mg) as omega3_mg,
SUM(COALESCE(sl.dose_g,1) * s.epa_mg) as epa_mg,
SUM(COALESCE(sl.dose_g,1) * s.dha_mg) as dha_mg,
SUM(COALESCE(sl.dose_g,1) * s.omega6_mg) as omega6_mg,
SUM(COALESCE(sl.dose_g,1) * s.omega9_mg) as omega9_mg
FROM supplement_log sl
JOIN supplements s ON sl.supplement_id = s.id
WHERE sl.date = ?1`,
[date]
);
return rows[0] || {};
}
// ---------------------------------------------------------------------------
// Serving units
// ---------------------------------------------------------------------------
async function getServingUnits() {
return select("SELECT * FROM serving_units ORDER BY id");
}
async function getUnitsForProduct(productId) {
// Returns product-specific units (with their grams_per_unit) + universal units
return select(
`SELECT su.*, COALESCE(pu.grams_per_unit, su.grams_equivalent) as grams_per_unit
FROM serving_units su
LEFT JOIN product_units pu ON pu.unit_id = su.id AND pu.product_id = ?1
WHERE su.category = 'universal' OR pu.id IS NOT NULL
ORDER BY CASE WHEN pu.id IS NOT NULL THEN 0 ELSE 1 END, su.id`,
[productId]
);
}
// ---------------------------------------------------------------------------
// 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 manual = await select(
"SELECT SUM(kcal) as kcal, SUM(minutes) as minutes FROM activity_log WHERE date = ?1",
[date]
);
const gps = await select(
"SELECT SUM(total_kcal) as kcal FROM gps_sessions WHERE date = ?1 AND total_kcal IS NOT NULL",
[date]
);
const manualKcal = manual[0]?.kcal ?? 0;
const gpsKcal = gps[0]?.kcal ?? 0;
return { kcal: manualKcal + gpsKcal, minutes: manual[0]?.minutes ?? 0 };
}
async function getDailyFoodTotals(days) {
return select(
`SELECT date, SUM(kcal) as kcal, SUM(protein_g) as protein_g,
SUM(fat_g) as fat_g, SUM(carbs_g) as carbs_g
FROM food_log WHERE date >= date('now', ?1)
GROUP BY date ORDER BY date ASC`,
[`-${days ?? 30} days`]
);
}
async function getDailyActivityTotals(days) {
const offset = `-${days ?? 30} days`;
return select(
`SELECT date, SUM(kcal) as kcal FROM (
SELECT date, kcal FROM activity_log WHERE date >= date('now', ?1)
UNION ALL
SELECT date, total_kcal as kcal FROM gps_sessions WHERE date >= date('now', ?2) AND total_kcal IS NOT NULL
) GROUP BY date ORDER BY date ASC`,
[offset, offset]
);
}
// ---------------------------------------------------------------------------
// GPS sessions & points
// ---------------------------------------------------------------------------
async function createGpsSession(date, startTime, activityType) {
const id = Date.now();
await execute(
`INSERT INTO gps_sessions (id, date, start_time, activity_type, source)
VALUES (?1, ?2, ?3, ?4, 'recorded')`,
[id, date, startTime, activityType]
);
return id;
}
// Column list cached after first call (avoids PRAGMA per insert)
let _gpsPointCols = null;
async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs, seq, bleData, sensorData) {
// Build INSERT dynamically based on existing columns (avoids crash if migration hasn't run)
if (!_gpsPointCols) {
try {
const info = await select("PRAGMA table_info(gps_points)");
_gpsPointCols = new Set(info.map(r => r.name));
} catch { _gpsPointCols = new Set(["session_id","ts","lat","lon","altitude_gps_m","speed_ms","seq"]); }
}
const cols = ["session_id","ts","lat","lon","altitude_gps_m","speed_ms","seq"];
const vals = [sessionId, ts, lat, lon, altGps ?? null, speedMs ?? null, seq ?? null];
const extra = [
["heart_rate", bleData?.hr], ["spo2", bleData?.spo2],
["bp_sys", bleData?.bp_sys], ["bp_dia", bleData?.bp_dia],
["accel_rms", sensorData?.accel_rms], ["accel_max", sensorData?.accel_max],
["gyro_pitch", sensorData?.gyro_pitch], ["gyro_yaw", sensorData?.gyro_yaw],
];
for (const [col, val] of extra) {
if (_gpsPointCols.has(col)) { cols.push(col); vals.push(val ?? null); }
}
const ph = cols.map((_, i) => `?${i + 1}`).join(",");
await execute(`INSERT INTO gps_points (${cols.join(",")}) VALUES (${ph})`, vals);
}
async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss) {
// Guard against NaN — SQLite may reject or corrupt NaN values
const safe = v => (v != null && !isNaN(v)) ? v : null;
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, safe(distM), safe(kcal), safe(avgSpd), safe(maxSpd), safe(elevGain), safe(elevLoss), id]
);
}
// NOTE: tauri-plugin-sql corrupts large i64 ids when returned as JS numbers
// (off by ±1). Always read/pass session ids via the text `id_str` column.
async function getGpsSessions(limit) {
return select(
"SELECT *, CAST(id AS TEXT) AS id_str FROM gps_sessions ORDER BY date DESC, start_time DESC, id DESC LIMIT ?1",
[limit ?? 50]
);
}
async function getGpsSessionsForDate(date) {
return select(
"SELECT *, CAST(id AS TEXT) AS id_str FROM gps_sessions WHERE date = ?1 ORDER BY start_time ASC",
[date]
);
}
async function getGpsSession(id) {
const rows = await select("SELECT *, CAST(id AS TEXT) AS id_str 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"]
);
// Get id of just-inserted session by matching unique fields.
// CAST to TEXT — numeric i64 id is corrupted by the SQL plugin; use string
// sid for point inserts (SQLite coerces it to the INTEGER session_id column).
const rows = await select(
"SELECT CAST(id AS TEXT) AS id FROM gps_sessions WHERE date=?1 AND start_time=?2 AND source=?3 ORDER BY id DESC LIMIT 1",
[session.date, session.start_time, session.source ?? "gpx_import"]
);
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;
}
// ---------------------------------------------------------------------------
// Bikes
// ---------------------------------------------------------------------------
async function getBikes() {
return select("SELECT * FROM bikes ORDER BY is_active DESC, name");
}
async function getBike(id) {
const rows = await select("SELECT * FROM bikes WHERE id = ?1", [id]);
return rows.length ? rows[0] : null;
}
async function getActiveBike() {
const rows = await select("SELECT * FROM bikes WHERE is_active = 1 LIMIT 1");
return rows.length ? rows[0] : null;
}
async function saveBike(data) {
if (data.is_active) {
await execute("UPDATE bikes SET is_active = 0 WHERE is_active = 1");
}
const r = await execute(
`INSERT INTO bikes (name, weight_kg, wheel_diameter_inch, chainring_teeth,
cog_teeth, gear_count, surface_type, notes, is_active, tire_pressure_bar)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)`,
[data.name, data.weight_kg ?? null, data.wheel_diameter_inch ?? 26,
data.chainring_teeth ?? null, data.cog_teeth ?? null,
data.gear_count ?? null, data.surface_type ?? "mixed",
data.notes ?? null, data.is_active ? 1 : 0,
data.tire_pressure_bar ?? null]
);
return r.lastInsertId;
}
async function updateBike(id, data) {
if (data.is_active) {
await execute("UPDATE bikes SET is_active = 0 WHERE is_active = 1");
}
await execute(
`UPDATE bikes SET name=?1, weight_kg=?2, wheel_diameter_inch=?3,
chainring_teeth=?4, cog_teeth=?5, gear_count=?6, surface_type=?7,
notes=?8, is_active=?9, tire_pressure_bar=?10 WHERE id=?11`,
[data.name, data.weight_kg ?? null, data.wheel_diameter_inch ?? 26,
data.chainring_teeth ?? null, data.cog_teeth ?? null,
data.gear_count ?? null, data.surface_type ?? "mixed",
data.notes ?? null, data.is_active ? 1 : 0,
data.tire_pressure_bar ?? null, id]
);
}
async function deleteBike(id) {
await execute("DELETE FROM bikes WHERE id = ?1", [id]);
}
async function updateSessionBike(sessionId, bikeId) {
await execute("UPDATE gps_sessions SET bike_id = ?1 WHERE id = ?2", [bikeId, sessionId]);
}
// ---------------------------------------------------------------------------
// Bike maintenance log
// ---------------------------------------------------------------------------
async function getBikeMaintenanceLog(bikeId) {
return select(
"SELECT * FROM bike_maintenance WHERE bike_id = ?1 ORDER BY date DESC",
[bikeId]
);
}
async function addBikeMaintenance(bikeId, date, description, mileageKm, nextServiceKm, notes) {
const r = await execute(
`INSERT INTO bike_maintenance (bike_id, date, description, mileage_km, next_service_km, notes)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
[bikeId, date, description, mileageKm ?? null, nextServiceKm ?? null, notes ?? null]
);
return r.lastInsertId;
}
async function deleteBikeMaintenance(id) {
await execute("DELETE FROM bike_maintenance WHERE id = ?1", [id]);
}
async function getBikeTotalMileage(bikeId) {
const rows = await select(
"SELECT SUM(total_distance_m) as total FROM gps_sessions WHERE bike_id = ?1 AND total_distance_m IS NOT NULL",
[bikeId]
);
const totalM = rows[0]?.total ?? 0;
return Math.round(totalM / 100) / 10; // km, 1 decimal
}
async function getBikeMaintenanceDue(bikeId) {
const mileage = await getBikeTotalMileage(bikeId);
const all = await getBikeMaintenanceLog(bikeId);
return all.filter(e => e.next_service_km != null && mileage >= e.next_service_km);
}
// ---------------------------------------------------------------------------
// Goal markers
// ---------------------------------------------------------------------------
async function getGoalMarkers() {
return select("SELECT * FROM goal_markers ORDER BY created_at DESC");
}
async function addGoalMarker(name, lat, lon, notes) {
const r = await execute(
`INSERT INTO goal_markers (name, lat, lon, notes) VALUES (?1, ?2, ?3, ?4)`,
[name, lat, lon, notes ?? null]
);
return r.lastInsertId;
}
async function deleteGoalMarker(id) {
await execute("DELETE FROM goal_markers WHERE id = ?1", [id]);
}
// ---------------------------------------------------------------------------
// Food cost helper — most frequently eaten foods in last 7 days
// ---------------------------------------------------------------------------
async function getFrequentFoods(days) {
return select(
`SELECT product_name, kcal, amount_g,
CASE WHEN amount_g > 0 THEN ROUND(kcal * 100.0 / amount_g) ELSE kcal END as kcal_per_100g
FROM (
SELECT p.name as product_name, fl.kcal, fl.amount_g,
COUNT(*) as cnt
FROM food_log fl
JOIN products p ON fl.product_id = p.id
WHERE fl.date >= date('now', ?1)
AND fl.kcal > 0 AND fl.amount_g > 0
GROUP BY p.name
ORDER BY cnt DESC
LIMIT 5
)`,
[`-${days ?? 7} days`]
);
}
// ---------------------------------------------------------------------------
// Closest approach of a session to a goal marker
// Returns {minDist, closestIdx} — computed in JS since SQLite can't do haversine
// ---------------------------------------------------------------------------
async function getGoalProgress(markerId, sessionId) {
// Fetch all sessions that got close to this marker, ordered by date
// We actually compute this in JS, but store the result concept here
// This function just returns all sessions with their closest approach to a marker
const sessions = await select(
`SELECT gs.id, gs.date, gs.start_time, gs.total_distance_m
FROM gps_sessions gs
WHERE gs.id != ?1
ORDER BY gs.date DESC, gs.start_time DESC
LIMIT 20`,
[sessionId]
);
return sessions;
}
// 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;
async function getManualProducts() {
return select("SELECT * FROM products WHERE source = 'manual' ORDER BY name");
}
window.DB.getManualProducts = getManualProducts;
window.DB.addProduct = addProduct;
window.DB.deleteProduct = deleteProduct;
window.DB.getProductRefs = getProductRefs;
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.getServingUnits = getServingUnits;
window.DB.getUnitsForProduct = getUnitsForProduct;
window.DB.getDailyFoodTotals = getDailyFoodTotals;
window.DB.getDailyActivityTotals = getDailyActivityTotals;
window.DB.getCookingMethods = getCookingMethods;
window.DB.getCookingMethod = getCookingMethod;
window.DB.getRecipes = getRecipes;
window.DB.getRecipe = getRecipe;
window.DB.getRecipeIngredients = getRecipeIngredients;
window.DB.saveRecipe = saveRecipe;
window.DB.updateRecipe = updateRecipe;
window.DB.deleteRecipe = deleteRecipe;
window.DB.addRecipeIngredient = addRecipeIngredient;
window.DB.deleteRecipeIngredient = deleteRecipeIngredient;
window.DB.clearRecipeIngredients = clearRecipeIngredients;
window.DB.calculateRecipeNutrition = calculateRecipeNutrition;
async function cacheRecipeNutrients(recipeId) {
const nutr = await calculateRecipeNutrition(recipeId);
if (!nutr) return;
await execute("UPDATE recipes SET cached_nutrients = ?1 WHERE id = ?2",
[JSON.stringify(nutr), recipeId]);
}
window.DB.cacheRecipeNutrients = cacheRecipeNutrients;
window.DB.addFoodFromRecipe = addFoodFromRecipe;
window.DB.createGpsSession = createGpsSession;
window.DB.addGpsPoint = addGpsPoint;
window.DB.finishGpsSession = finishGpsSession;
window.DB.getGpsSessions = getGpsSessions;
window.DB.getGpsSessionsForDate = getGpsSessionsForDate;
window.DB.getGpsSession = getGpsSession;
window.DB.getGpsPoints = getGpsPoints;
window.DB.deleteGpsSession = deleteGpsSession;
window.DB.importGpxSession = importGpxSession;
async function findNearestAltitude(lat, lon, radiusM) {
const dLat = radiusM / 111000;
const dLon = radiusM / (111000 * Math.cos(lat * Math.PI / 180));
const rows = await select(`
SELECT altitude_gps_m FROM gps_points
WHERE altitude_gps_m IS NOT NULL
AND lat BETWEEN ?1 AND ?2 AND lon BETWEEN ?3 AND ?4
ORDER BY (lat - ?5)*(lat - ?5) + (lon - ?6)*(lon - ?6)
LIMIT 1`, [lat - dLat, lat + dLat, lon - dLon, lon + dLon, lat, lon]);
return rows[0]?.altitude_gps_m ?? null;
}
window.DB.findNearestAltitude = findNearestAltitude;
window.DB.getBikes = getBikes;
window.DB.getBike = getBike;
window.DB.getActiveBike = getActiveBike;
window.DB.saveBike = saveBike;
window.DB.updateBike = updateBike;
window.DB.deleteBike = deleteBike;
window.DB.updateSessionBike = updateSessionBike;
window.DB.getBikeMaintenanceLog = getBikeMaintenanceLog;
window.DB.addBikeMaintenance = addBikeMaintenance;
window.DB.deleteBikeMaintenance = deleteBikeMaintenance;
window.DB.getBikeTotalMileage = getBikeTotalMileage;
window.DB.getBikeMaintenanceDue = getBikeMaintenanceDue;
window.DB.getGoalMarkers = getGoalMarkers;
window.DB.addGoalMarker = addGoalMarker;
window.DB.deleteGoalMarker = deleteGoalMarker;
window.DB.getFrequentFoods = getFrequentFoods;
window.DB.getGoalProgress = getGoalProgress;
// ---------------------------------------------------------------------------
// Nutrient reserves (accumulated body stores)
// ---------------------------------------------------------------------------
async function getReserves(date) {
const rows = await select("SELECT reserves FROM nutrient_reserves WHERE date = ?1", [date]);
if (!rows.length || !rows[0].reserves) return {};
try { return JSON.parse(rows[0].reserves); } catch { return {}; }
}
async function saveReserves(date, reserves) {
await execute(
`INSERT INTO nutrient_reserves (date, reserves) VALUES (?1, ?2)
ON CONFLICT(date) DO UPDATE SET reserves = ?2`,
[date, JSON.stringify(reserves)]
);
}
async function getAllReservesDates() {
return select("SELECT date FROM nutrient_reserves ORDER BY date");
}
async function getDaysWithFood() {
return select(
`SELECT DISTINCT date FROM (
SELECT date FROM food_log
UNION SELECT date FROM supplement_log
) ORDER BY date`
);
}
async function getDailyWaterMl(date) {
// From products with water_pct
const prodRows = await select(
`SELECT COALESCE(SUM(fl.amount_g * p.water_pct / 100.0), 0) as ml
FROM food_log fl JOIN products p ON fl.product_id = p.id
WHERE fl.date = ?1 AND p.water_pct IS NOT NULL AND p.water_pct > 0`,
[date]
);
let total = prodRows[0]?.ml || 0;
// From recipes — calculate water from ingredients on-the-fly (cache may be stale)
const recipeRows = await select(
`SELECT fl.amount_g, fl.recipe_id, r.servings,
(SELECT SUM(ri.amount_g) FROM recipe_ingredients ri WHERE ri.recipe_id = r.id) as recipe_weight
FROM food_log fl JOIN recipes r ON fl.recipe_id = r.id
WHERE fl.date = ?1`,
[date]
);
for (const rr of recipeRows) {
try {
// Sum water from recipe ingredients
const ings = await select(
`SELECT ri.amount_g, COALESCE(p.water_pct, 0) as water_pct
FROM recipe_ingredients ri LEFT JOIN products p ON ri.product_id = p.id
WHERE ri.recipe_id = ?1`,
[rr.recipe_id]
);
let recipeWaterMl = 0;
for (const ing of ings) {
recipeWaterMl += (ing.amount_g || 0) * (ing.water_pct || 0) / 100;
}
// Scale by portion: how much of the recipe was eaten
const servingG = Math.round((rr.recipe_weight || 0) / (rr.servings || 1));
const scale = servingG > 0 ? rr.amount_g / servingG : 1;
total += recipeWaterMl / (rr.servings || 1) * scale;
} catch {}
}
return Math.round(total);
}
window.DB.getReserves = getReserves;
window.DB.saveReserves = saveReserves;
window.DB.getAllReservesDates = getAllReservesDates;
window.DB.getDaysWithFood = getDaysWithFood;
window.DB.getDailyWaterMl = getDailyWaterMl;
// All GPS tracks as arrays of [lat, lon] grouped by session_id.
// minSpeedMs filters out stopped/slow points. minPoints filters out tiny sessions.
// Fetches per-session to avoid Tauri SQL plugin row limit.
// Road points — all GPS points merged into one flat table.
// Rebuild: INSERT...SELECT runs entirely in SQLite (no JS iteration, no ID issues).
// Reading: paginated to avoid Tauri SQL plugin row limit (~28K).
async function rebuildRoads() {
const invoke = window.__TAURI__.core.invoke;
const raw = await invoke("rebuild_roads", { minSpeedMs: 0.5 });
const resp = JSON.parse(raw);
console.log("[rebuildRoads]", resp);
return resp.road_points;
}
// Read road points via Rust command (Tauri SQL plugin has ~28K row limit)
async function getRoadPoints() {
const invoke = window.__TAURI__.core.invoke;
const BATCH = 10000;
let offset = 0;
const all = [];
while (true) {
const raw = await invoke("get_road_points", { offset, batchSize: BATCH });
const resp = JSON.parse(raw);
for (const p of resp.points) all.push({ lat: p[0], lon: p[1], atype: p[2] || "b" });
console.log(`[getRoadPoints] batch offset=${offset} got=${resp.points.length} total_db=${resp.total}`);
if (!resp.has_more) break;
offset += BATCH;
}
console.log(`[getRoadPoints] total=${all.length}`);
return all;
}
window.DB.rebuildRoads = rebuildRoads;
window.DB.getRoadPoints = getRoadPoints;
// JS-side migrations — safe for any DB state (checks before altering)
async function runMigrations() {
// Disable WAL — all writes go directly to main DB file (no -wal/-shm files)
await execute("PRAGMA journal_mode=DELETE");
// Sync columns: sync_id (UUID), updated_at (ISO timestamp), sync_dirty (0/1)
const syncTables = ["food_log","weight_log","supplement_log","sleep_log","activity_log",
"gps_sessions","recipes","recipe_ingredients","supplements"];
for (const tbl of syncTables) {
const cols = (await select(`PRAGMA table_info(${tbl})`)).map(r => r.name);
if (!cols.includes("sync_id")) {
await execute(`ALTER TABLE ${tbl} ADD COLUMN sync_id TEXT`);
await execute(`ALTER TABLE ${tbl} ADD COLUMN updated_at TEXT`);
await execute(`ALTER TABLE ${tbl} ADD COLUMN sync_dirty INTEGER DEFAULT 0`);
// Backfill sync_id for existing rows
await execute(`UPDATE ${tbl} SET sync_id = lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-' || hex(randomblob(2)) || '-' || hex(randomblob(2)) || '-' || hex(randomblob(6))) WHERE sync_id IS NULL`);
await execute(`UPDATE ${tbl} SET updated_at = datetime('now') WHERE updated_at IS NULL`);
}
// Triggers: auto-set sync_id, updated_at, sync_dirty on INSERT/UPDATE
await execute(`CREATE TRIGGER IF NOT EXISTS ${tbl}_sync_insert
AFTER INSERT ON ${tbl} WHEN NEW.sync_id IS NULL
BEGIN UPDATE ${tbl} SET
sync_id = lower(hex(randomblob(4))||'-'||hex(randomblob(2))||'-'||hex(randomblob(2))||'-'||hex(randomblob(2))||'-'||hex(randomblob(6))),
updated_at = datetime('now'), sync_dirty = 1
WHERE rowid = NEW.rowid; END`);
await execute(`CREATE TRIGGER IF NOT EXISTS ${tbl}_sync_update
AFTER UPDATE ON ${tbl} WHEN NEW.sync_dirty != 0 OR OLD.sync_dirty = 0
BEGIN UPDATE ${tbl} SET
updated_at = datetime('now'), sync_dirty = 1
WHERE rowid = NEW.rowid AND sync_dirty = 0; END`);
// First sync: mark all existing rows as dirty so they get pushed
const dirtyCount = (await select(`SELECT COUNT(*) as c FROM ${tbl} WHERE sync_dirty = 1`))[0].c;
if (dirtyCount === 0) {
const total = (await select(`SELECT COUNT(*) as c FROM ${tbl}`))[0].c;
if (total > 0) {
await execute(`UPDATE ${tbl} SET sync_dirty = 1`);
console.log(`[Sync] Marked ${total} rows in ${tbl} as dirty for first sync`);
}
}
}
// Batch column checks — one PRAGMA per table instead of N separate IPC calls
const productCols = (await select("PRAGMA table_info(products)")).map(r => r.name);
const addProdCol = async (col, type) => { if (!productCols.includes(col)) await execute(`ALTER TABLE products ADD COLUMN ${col} ${type}`); };
await addProdCol("composition", "TEXT");
await addProdCol("name_lower", "TEXT");
await addProdCol("nutrients_guessed", "INTEGER DEFAULT 0");
await addProdCol("user_edited", "INTEGER DEFAULT 0");
await addProdCol("ginseng_mg", "REAL");
await addProdCol("resveratrol_mg", "REAL");
await addProdCol("coq10_mg", "REAL");
// New supplement columns (ginseng, resveratrol, coq10) — added after existing suppCols migration below
const gpsCols = (await select("PRAGMA table_info(gps_points)")).map(r => r.name);
if (!gpsCols.includes("seq")) await execute("ALTER TABLE gps_points ADD COLUMN seq INTEGER");
if (!gpsCols.includes("spo2")) await execute("ALTER TABLE gps_points ADD COLUMN spo2 INTEGER");
if (!gpsCols.includes("bp_sys")) await execute("ALTER TABLE gps_points ADD COLUMN bp_sys INTEGER");
if (!gpsCols.includes("bp_dia")) await execute("ALTER TABLE gps_points ADD COLUMN bp_dia INTEGER");
if (!gpsCols.includes("accel_rms")) await execute("ALTER TABLE gps_points ADD COLUMN accel_rms REAL");
if (!gpsCols.includes("accel_max")) await execute("ALTER TABLE gps_points ADD COLUMN accel_max REAL");
if (!gpsCols.includes("gyro_pitch")) await execute("ALTER TABLE gps_points ADD COLUMN gyro_pitch REAL");
if (!gpsCols.includes("gyro_yaw")) await execute("ALTER TABLE gps_points ADD COLUMN gyro_yaw REAL");
// --- Cooking methods table ---
await execute(`CREATE TABLE IF NOT EXISTS cooking_methods (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
protein_loss_pct REAL DEFAULT 0,
fat_loss_pct REAL DEFAULT 0,
carbs_loss_pct REAL DEFAULT 0,
fiber_loss_pct REAL DEFAULT 0,
vit_a_loss_pct REAL DEFAULT 0,
vit_c_loss_pct REAL DEFAULT 0,
vit_b1_loss_pct REAL DEFAULT 0,
vit_b2_loss_pct REAL DEFAULT 0,
kcal_loss_pct REAL DEFAULT 0
)`);
// Seed cooking methods if empty
const cmCount = await select("SELECT COUNT(*) as cnt FROM cooking_methods");
if (cmCount[0].cnt === 0) {
const methods = [
[1, "Без обработки", 0, 0, 0, 0, 0, 0, 0, 0, 0],
[2, "Варка", 2, 5, 3, 0, 0, 50, 30, 10, 0],
[3, "Жарка", 5, -10, 3, 0, 0, 60, 35, 0, 0],
[4, "Тушение", 2, 10, 2, 0, 0, 40, 25, 0, 0],
[5, "Запекание", 3, 8, 2, 0, 0, 45, 20, 0, 0],
[6, "На пару", 1, 2, 1, 0, 0, 25, 15, 0, 0],
];
for (const m of methods) {
await execute(
`INSERT INTO cooking_methods (id, name, protein_loss_pct, fat_loss_pct,
carbs_loss_pct, fiber_loss_pct, vit_a_loss_pct, vit_c_loss_pct,
vit_b1_loss_pct, vit_b2_loss_pct, kcal_loss_pct)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)`,
m
);
}
console.log("[db.js] Seeded 6 cooking methods");
}
// --- Recipes + ingredients: batch column checks ---
const recipeCols = (await select("PRAGMA table_info(recipes)")).map(r => r.name);
if (!recipeCols.includes("cooking_method_id")) await execute("ALTER TABLE recipes ADD COLUMN cooking_method_id INTEGER");
if (!recipeCols.includes("created_at")) await execute("ALTER TABLE recipes ADD COLUMN created_at TEXT DEFAULT (datetime('now'))");
const riCols = (await select("PRAGMA table_info(recipe_ingredients)")).map(r => r.name);
if (!riCols.includes("custom_name")) await execute("ALTER TABLE recipe_ingredients ADD COLUMN custom_name TEXT");
if (!riCols.includes("cooking_method_id")) await execute("ALTER TABLE recipe_ingredients ADD COLUMN cooking_method_id INTEGER");
// --- Serving units table ---
await execute(`CREATE TABLE IF NOT EXISTS serving_units (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
name_short TEXT,
grams_equivalent REAL,
category TEXT DEFAULT 'universal'
)`);
// --- Product units junction table ---
await execute(`CREATE TABLE IF NOT EXISTS product_units (
id INTEGER PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id),
unit_id INTEGER NOT NULL REFERENCES serving_units(id),
grams_per_unit REAL NOT NULL,
UNIQUE(product_id, unit_id)
)`);
// --- Add unit columns to food_log ---
const flCols = (await select("PRAGMA table_info(food_log)")).map(r => r.name);
if (!flCols.includes("unit_id")) await execute("ALTER TABLE food_log ADD COLUMN unit_id INTEGER");
if (!flCols.includes("unit_amount")) await execute("ALTER TABLE food_log ADD COLUMN unit_amount REAL");
// Seed serving_units if empty
const suCnt = await select("SELECT COUNT(*) as cnt FROM serving_units");
if (suCnt[0].cnt === 0) {
const units = [
[1, "шт", "шт", null, "product_specific"],
[2, "ст.л.", "ст.л.", 15, "universal"],
[3, "ч.л.", "ч.л.", 5, "universal"],
[4, "стакан", "стак.", 250, "universal"],
[5, "мл", "мл", 1, "universal"],
];
for (const u of units) {
await execute(
`INSERT INTO serving_units (id, name, name_short, grams_equivalent, category)
VALUES (?1, ?2, ?3, ?4, ?5)`, u
);
}
console.log("[db.js] Seeded 5 serving units");
}
// product_units: user adds per-product unit mappings manually via UI.
// No auto-seeding — product names too varied for reliable matching.
// --- Bikes table ---
await execute(`CREATE TABLE IF NOT EXISTS bikes (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
weight_kg REAL,
wheel_diameter_inch REAL,
chainring_teeth INTEGER,
cog_teeth INTEGER,
gear_count INTEGER,
surface_type TEXT DEFAULT 'mixed',
notes TEXT,
is_active INTEGER DEFAULT 0
)`);
// Add bike_id column to gps_sessions
const gsCols = (await select("PRAGMA table_info(gps_sessions)")).map(r => r.name);
if (!gsCols.includes("bike_id")) await execute("ALTER TABLE gps_sessions ADD COLUMN bike_id INTEGER");
// Add tire_pressure_bar to bikes (for Crr correction by tire pressure)
const bikeCols = (await select("PRAGMA table_info(bikes)")).map(r => r.name);
if (!bikeCols.includes("tire_pressure_bar")) await execute("ALTER TABLE bikes ADD COLUMN tire_pressure_bar REAL");
// --- Bike maintenance log table ---
await execute(`CREATE TABLE IF NOT EXISTS bike_maintenance (
id INTEGER PRIMARY KEY,
bike_id INTEGER NOT NULL REFERENCES bikes(id) ON DELETE CASCADE,
date TEXT NOT NULL,
description TEXT NOT NULL,
mileage_km REAL,
next_service_km REAL,
notes TEXT
)`);
// Seed default bike if table is empty
const bikeCnt = await select("SELECT COUNT(*) as cnt FROM bikes");
if (bikeCnt[0].cnt === 0) {
await execute(
`INSERT INTO bikes (name, weight_kg, wheel_diameter_inch, chainring_teeth,
cog_teeth, surface_type, is_active)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
["Мой велосипед", 12, 26, 42, 18, "mixed", 1]
);
console.log("[db.js] Seeded default bike");
}
// --- Goal markers table ---
await execute(`CREATE TABLE IF NOT EXISTS goal_markers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
notes TEXT,
created_at TEXT DEFAULT (datetime('now'))
)`);
// --- Supplement columns migration: add missing micronutrient columns ---
const suppCols = (await select("PRAGMA table_info(supplements)")).map(r => r.name);
const addSuppCol = async (col, type) => { if (!suppCols.includes(col)) await execute(`ALTER TABLE supplements ADD COLUMN ${col} ${type}`); };
await addSuppCol("vit_b5_mg", "REAL");
await addSuppCol("vit_b7_mcg", "REAL"); // biotin
await addSuppCol("chromium_mg", "REAL");
await addSuppCol("manganese_mg", "REAL");
await addSuppCol("molybdenum_mcg", "REAL");
await addSuppCol("iodine_mg", "REAL");
await addSuppCol("phosphorus_mg", "REAL");
await addSuppCol("silicon_mg", "REAL"); // кремний — no RDI
await addSuppCol("vanadium_mg", "REAL"); // ванадий — no RDI
await addSuppCol("lutein_mg", "REAL"); // лютеин — no RDI
await addSuppCol("lycopene_mg", "REAL"); // ликопин — no RDI
await addSuppCol("carotene_mcg", "REAL"); // бета-каротин
await addSuppCol("fish_oil_mg", "REAL"); // рыбий жир (общий объём)
await addSuppCol("omega3_mg", "REAL"); // омега-3 общая
await addSuppCol("epa_mg", "REAL"); // EPA (эйкозапентаеновая)
await addSuppCol("dha_mg", "REAL"); // DHA (докозагексаеновая)
await addSuppCol("omega6_mg", "REAL"); // омега-6
await addSuppCol("omega9_mg", "REAL"); // омега-9
await addSuppCol("ginseng_mg", "REAL"); // женьшень
await addSuppCol("resveratrol_mg", "REAL"); // ресвератрол
await addSuppCol("coq10_mg", "REAL"); // коэнзим Q10
// Product dedup handled in Rust import_seed_products (UNIQUE INDEX on name_lower+source+store_id)
// RDI standards tables
await execute(`CREATE TABLE IF NOT EXISTS rdi_standards (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL
)`);
await execute(`CREATE TABLE IF NOT EXISTS rdi_values (
id INTEGER PRIMARY KEY,
standard_id INTEGER NOT NULL REFERENCES rdi_standards(id),
nutrient_key TEXT NOT NULL,
value REAL NOT NULL,
UNIQUE(standard_id, nutrient_key)
)`);
// Seed RDI standards if empty
const rdiCount = await select("SELECT COUNT(*) as cnt FROM rdi_standards");
if (rdiCount[0].cnt === 0) {
await execute("INSERT INTO rdi_standards (id, code, name) VALUES (1, 'RU', 'РФ (ТР ТС 022/2011)')");
await execute("INSERT INTO rdi_standards (id, code, name) VALUES (2, 'EU', 'ЕС (EU RDA)')");
await execute("INSERT INTO rdi_standards (id, code, name) VALUES (3, 'US', 'США (US DV)')");
const standards = {
// nutrient_key: [RU, EU, US]
vit_a_mcg: [800, 800, 900],
vit_c_mg: [60, 80, 90],
vit_e_mg: [10, 12, 15],
vit_b1_mg: [1.4, 1.1, 1.2],
vit_b2_mg: [1.6, 1.4, 1.3],
vit_b3_mg: [18, 16, 16],
vit_b5_mg: [6, 6, 5],
vit_b6_mg: [2, 1.4, 1.7],
vit_b7_mcg: [50, 50, 30],
folate_mcg: [200, 200, 400],
vit_b12_mcg: [1, 2.5, 2.4],
vit_d_mcg: [5, 15, 15],
vit_k_mcg: [120, 75, 120],
calcium_mg: [1000, 800, 1000],
magnesium_mg: [400, 375, 420],
zinc_mg: [15, 10, 11],
iron_mg: [14, 14, 18],
iodine_mg: [0.15, 0.15, 0.15],
selenium_mcg: [70, 55, 55],
molybdenum_mcg: [70, 50, 45],
manganese_mg: [2, 2, 2.3],
copper_mg: [1, 1, 0.9],
chromium_mg: [0.05, 0.04, 0.035],
phosphorus_mg: [800, 700, 700],
potassium_mg: [3500, 2000, 2600],
sodium_mg: [2400, 2400, 2300],
};
for (const [key, vals] of Object.entries(standards)) {
for (let i = 0; i < 3; i++) {
await execute("INSERT INTO rdi_values (standard_id, nutrient_key, value) VALUES (?1, ?2, ?3)", [i + 1, key, vals[i]]);
}
}
console.log("[db.js] Seeded 3 RDI standards (RU/EU/US)");
}
// Add norm_type to rdi_values (rdi vs aup)
const rdiValCols = (await select("PRAGMA table_info(rdi_values)")).map(r => r.name);
if (!rdiValCols.includes("norm_type")) {
await execute("ALTER TABLE rdi_values ADD COLUMN norm_type TEXT DEFAULT 'rdi'");
}
// Seed АУП values (one-time, check if omega3 already exists)
const hasOmega3 = await select("SELECT COUNT(*) as cnt FROM rdi_values WHERE nutrient_key = 'omega3_mg'");
if (hasOmega3[0].cnt === 0) {
const aupData = {
// key: [RU, EU, US] — all marked as 'aup'
omega3_mg: [1000, 250, 1600],
epa_mg: [250, 250, 500],
dha_mg: [250, 250, 500],
lutein_mg: [5, 10, 6],
lycopene_mg: [5, 5, 5],
silicon_mg: [30, 30, 30],
vanadium_mg: [0.04, 0.04, 0.04],
};
for (const [key, vals] of Object.entries(aupData)) {
for (let i = 0; i < 3; i++) {
await execute("INSERT OR IGNORE INTO rdi_values (standard_id, nutrient_key, value, norm_type) VALUES (?1, ?2, ?3, 'aup')", [i + 1, key, vals[i]]);
}
}
console.log("[db.js] Seeded АУП values for omega/lutein/lycopene/silicon/vanadium");
}
// Nutrient half-life table (whole-body, in days)
await execute(`CREATE TABLE IF NOT EXISTS nutrient_halflife (
nutrient_key TEXT PRIMARY KEY,
halflife_min_days REAL,
halflife_max_days REAL,
halflife_avg_days REAL,
source TEXT
)`);
const hlCount = await select("SELECT COUNT(*) as cnt FROM nutrient_halflife");
if (hlCount[0].cnt === 0) {
const hl = [
["vit_a_mcg", 50, 180, 115, "fat-soluble, liver stores"],
["vit_c_mg", 10, 20, 15, "Performance Lab review"],
["vit_d_mcg", 30, 90, 60, "fat tissue, PMC4207933"],
["vit_e_mg", 2, 5, 3.5, "ZIO review"],
["vit_k_mcg", 1, 3, 1.5, "K1 pool turnover"],
["vit_b1_mg", 9, 18, 13.5, "Ariaey-Nejad 1970"],
["vit_b2_mg", 2, 7, 4, "limited storage"],
["vit_b3_mg", 0.5, 2, 1, "rapid excretion"],
["vit_b5_mg", 0.5, 2, 1, "rapid excretion"],
["vit_b6_mg", 15, 20, 17.5, "muscle stores"],
["vit_b7_mcg", 1, 3, 2, "tissue stores > plasma"],
["folate_mcg", 60, 120, 100, "RBC folate ~8 weeks"],
["vit_b12_mcg", 200, 770, 400, "liver, PubMed 14010994"],
["calcium_mg", 1, 10, 5, "fast exchangeable pool"],
["magnesium_mg", 30, 60, 42, "~1000 hours"],
["iron_mg", 2000, 5000, 3650, "~10 years, no excretion"],
["zinc_mg", 12, 30, 20, "fast pool"],
["copper_mg", 13, 33, 23, "NCBI/PMC review"],
["selenium_mcg", 50, 150, 100, "Griffith 1976"],
["iodine_mg", 40, 80, 66, "ICRP/measured"],
["manganese_mg", 20, 50, 37, "clinical data"],
["molybdenum_mcg", 1, 3, 2, "plasma ~24-51h"],
["chromium_mg", 300, 900, 616, "ICRP"],
["phosphorus_mg", 14, 60, 30, "soft tissue pool"],
["potassium_mg", 10, 28, 16, "K-42 tracer study"],
["sodium_mg", 7, 15, 11, "ICRP"],
["omega3_mg", 30, 90, 60, "fat-soluble stored"],
["epa_mg", 30, 90, 60, "same pool as omega3"],
["dha_mg", 30, 90, 60, "same pool as omega3"],
["carotene_mcg", 5, 30, 15, "fat tissue"],
["lutein_mg", 10, 30, 20, "macular pigment"],
["lycopene_mg", 14, 28, 21, "fat tissue"],
];
for (const [key, min, max, avg, src] of hl) {
await execute("INSERT INTO nutrient_halflife VALUES (?1,?2,?3,?4,?5)", [key, min, max, avg, src]);
}
console.log("[db.js] Seeded nutrient half-life data (32 nutrients)");
}
// Nutrient reserves table — accumulated body stores per day
await execute(`CREATE TABLE IF NOT EXISTS nutrient_reserves (
date TEXT PRIMARY KEY,
reserves TEXT
)`);
// Road points — deduplicated points from all GPS tracks
await execute(`CREATE TABLE IF NOT EXISTS road_points (
id INTEGER PRIMARY KEY,
lat REAL NOT NULL,
lon REAL NOT NULL
)`);
// Spatial index for fast 2m proximity checks
await execute(`CREATE INDEX IF NOT EXISTS idx_road_lat_lon ON road_points(lat, lon)`);
// Activity types: extend activity_log with structured fields
const actCols = (await select("PRAGMA table_info(activity_log)")).map(c => c.name);
if (!actCols.includes("activity_type")) {
await execute("ALTER TABLE activity_log ADD COLUMN activity_type TEXT");
await execute("UPDATE activity_log SET activity_type = 'manual' WHERE activity_type IS NULL");
}
if (!actCols.includes("sets")) await execute("ALTER TABLE activity_log ADD COLUMN sets INTEGER");
if (!actCols.includes("reps")) await execute("ALTER TABLE activity_log ADD COLUMN reps INTEGER");
if (!actCols.includes("distance_m")) await execute("ALTER TABLE activity_log ADD COLUMN distance_m REAL");
// Water content column for hydration tracking
if (!productCols.includes("water_pct")) {
await execute("ALTER TABLE products ADD COLUMN water_pct REAL");
// Seed water_pct for known liquid products by name pattern
const waterPatterns = [
[100, ["% вода %", "% вода", "вода %", "вода питьевая%", "вода минеральная%", "вода газированная%"]],
[99, ["%чай %", "%чай", "чай %"]],
[99, ["%кофе %", "%кофе", "кофе %"]],
[87, ["%молоко %", "%молоко", "молоко %", "%кефир%", "%ряженка%", "%простокваша%", "%ацидофилин%"]],
[88, ["%сок %", "%сок", "сок %", "%нектар%"]],
[85, ["%компот%", "%морс%", "%кисель%", "%какао%напит%"]],
[90, ["%суп %", "%суп", "суп %", "%бульон%", "%борщ%", "%щи %", "%солянка%", "%рассольник%", "%уха %", "%окрошка%"]],
[89, ["%газировка%", "%кола %", "%кола", "%фанта%", "%спрайт%", "%лимонад%", "%тархун%", "%байкал%", "%напиток газ%"]],
[92, ["%пиво %", "%пиво", "пиво %", "%пивн%"]],
[93, ["%квас%"]],
[80, ["%смузи%"]],
];
for (const [pct, patterns] of waterPatterns) {
for (const p of patterns) {
await execute(`UPDATE products SET water_pct = ?1 WHERE water_pct IS NULL AND name_lower LIKE ?2`, [pct, p]);
}
}
const waterCount = (await select("SELECT COUNT(*) as cnt FROM products WHERE water_pct IS NOT NULL"))[0].cnt;
console.log(`[db.js] Seeded water_pct for ${waterCount} products`);
}
// Recipe cached nutrients columns
const recipeCols2 = (await select("PRAGMA table_info(recipes)")).map(r => r.name);
if (!recipeCols2.includes("cached_nutrients")) {
await execute("ALTER TABLE recipes ADD COLUMN cached_nutrients TEXT");
}
console.log("[db.js] recipe migrations done, checking seq repair...");
// Repair old tracks: assign seq to points where it's NULL, ordered by timestamp.
// Only runs if there are NULL-seq points (should be one-time after migration).
try {
const nullSeqSessions = await select(
"SELECT DISTINCT session_id FROM gps_points WHERE seq IS NULL"
);
if (nullSeqSessions.length > 0) {
console.log(`[db.js] Repairing seq for ${nullSeqSessions.length} sessions...`);
for (const row of nullSeqSessions) {
const pts = await select(
"SELECT rowid, ts FROM gps_points WHERE session_id = ?1 ORDER BY ts ASC, rowid ASC",
[row.session_id]
);
for (let i = 0; i < pts.length; i++) {
await execute("UPDATE gps_points SET seq = ?1 WHERE rowid = ?2", [i + 1, pts[i].rowid]);
}
console.log(`[db.js] Repaired seq for session ${row.session_id}: ${pts.length} points`);
}
}
} catch (e) { console.error("[db.js] seq repair error:", e); }
}
window.DB._migrationsReady = runMigrations().then(() => {
console.log("[db.js] migrations done");
}).catch(e => {
console.error("[db.js] migration error:", e);
});
console.log("[db.js] window.DB ready, keys:", Object.keys(window.DB).length);