// 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().trim(); // Order-independent: every whitespace-separated word must appear in the name // (in any order). Fixes "надо вводить слова в порядке названия". const tokens = q.split(/\s+/).filter(Boolean); if (tokens.length === 0) return []; const cols = `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`; const where = tokens.map((_, i) => `name_lower LIKE ?${i + 1}`).join(" AND "); const vals = tokens.map(t => `%${t}%`); const nFull = vals.length + 1, nPrefix = vals.length + 2, nLimit = vals.length + 3; vals.push(`%${q}%`); // full contiguous string present → rank first vals.push(`${q}%`); // name starts with the query → rank next vals.push(limit ?? 200); return select( `SELECT ${cols} FROM products WHERE ${where} GROUP BY COALESCE(store_id, id) ORDER BY CASE WHEN name_lower LIKE ?${nFull} THEN 0 ELSE 1 END, CASE WHEN name_lower LIKE ?${nPrefix} THEN 0 ELSE 1 END, length(name), name COLLATE NOCASE LIMIT ?${nLimit}`, vals ); } 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]); } // Catalog cleanup: sources that carry a "плашка" (badge) and are worth keeping even // if never eaten. Everything else (health-diet / ashan / fast food / null source) is // removed ONLY when unused — i.e. not referenced by food_log or recipe_ingredients, // so eaten products and recipe ingredients always survive. const PRODUCT_KEEP_SOURCES = "'magnit','5ka','ion','kdv','manual','recipe_import','gridnev'"; const UNLABELED_UNUSED_WHERE = `(source IS NULL OR source NOT IN (${PRODUCT_KEEP_SOURCES})) AND id NOT IN (SELECT product_id FROM food_log WHERE product_id IS NOT NULL) AND id NOT IN (SELECT product_id FROM recipe_ingredients WHERE product_id > 0)`; async function countUnlabeledUnusedProducts() { const rows = await select(`SELECT COUNT(*) AS cnt FROM products WHERE ${UNLABELED_UNUSED_WHERE}`); return rows[0]?.cnt || 0; } async function cleanupUnlabeledProducts() { const n = await countUnlabeledUnusedProducts(); await execute(`DELETE FROM products WHERE ${UNLABELED_UNUSED_WHERE}`); return n; } // --------------------------------------------------------------------------- // 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], ["bearing_deg", sensorData?.bearing], ["accuracy_m", sensorData?.accuracy], ]; 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); } // Bulk insert native points (recovery). Multi-row INSERT in chunks — MUCH faster // than per-point addGpsPoint (one IPC per ~75 rows instead of per row). `points` // are native point objects {ts, lat, lon, altitude, speed_ms, bearing, accel_*, gyro_*}. // Returns the number of rows inserted. seq continues from startSeq. async function addGpsPointsBulk(sessionId, points, startSeq) { if (!points || !points.length) return 0; 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 base = ["session_id","ts","lat","lon","altitude_gps_m","speed_ms","seq"]; const extra = ["accel_rms","accel_max","gyro_pitch","gyro_yaw","bearing_deg","accuracy_m"].filter(c => _gpsPointCols.has(c)); const allCols = base.concat(extra); const perRow = allCols.length; const maxRows = Math.max(1, Math.floor(900 / perRow)); // stay under SQLite bind-var limit let seq = startSeq || 0; let inserted = 0; for (let i = 0; i < points.length; i += maxRows) { const chunk = points.slice(i, i + maxRows); const rowSql = []; const vals = []; let ph = 1; for (const p of chunk) { seq++; const row = [sessionId, p.ts, p.lat, p.lon, p.altitude ?? null, p.speed_ms ?? null, seq]; for (const c of extra) row.push(c === "bearing_deg" ? (p.bearing ?? null) : c === "accuracy_m" ? (p.accuracy ?? null) : (p[c] ?? null)); rowSql.push("(" + row.map(() => `?${ph++}`).join(",") + ")"); vals.push(...row); } await execute(`INSERT INTO gps_points (${allCols.join(",")}) VALUES ${rowSql.join(",")}`, vals); inserted += chunk.length; } return inserted; } // Gap-fill (BRouter): persist the routed dropout points as estimated=1, reversibly. // `points` is the full fillGaps result array (real + estimated interleaved, ordered). // Each estimated point gets seq = the preceding real point's seq, so the existing // ORDER BY COALESCE(seq,999999), ts places it inside the gap; ts is already interpolated. // Original points are untouched → revertGapFill removes only estimated=1. async function saveGapFill(sessionId, points) { let lastSeq = 0, toInsert = []; for (let p of points) { if (p.estimated) toInsert.push({ ts: p.ts, lat: p.lat, lon: p.lon, seq: lastSeq }); else if (p.seq != null) lastSeq = p.seq; } if (!toInsert.length) return 0; let BATCH = 120; // 5 params/row → under SQLite bind-var limit for (let i = 0; i < toInsert.length; i += BATCH) { let chunk = toInsert.slice(i, i + BATCH); let rowSql = [], vals = [], ph = 1; for (let p of chunk) { rowSql.push(`(?${ph},?${ph+1},?${ph+2},?${ph+3},?${ph+4},1)`); vals.push(sessionId, p.ts, p.lat, p.lon, p.seq); ph += 5; } await execute(`INSERT INTO gps_points (session_id, ts, lat, lon, seq, estimated) VALUES ${rowSql.join(",")}`, vals); } await execute("UPDATE gps_sessions SET gap_filled=1 WHERE id=?1", [sessionId]); return toInsert.length; } async function revertGapFill(sessionId) { await execute("DELETE FROM gps_points WHERE session_id=?1 AND estimated=1", [sessionId]); await execute("UPDATE gps_sessions SET gap_filled=0 WHERE id=?1", [sessionId]); } async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss, pauses) { // Guard against NaN — SQLite may reject or corrupt NaN values const safe = v => (v != null && !isNaN(v)) ? v : null; // pauses: array of manual-pause intervals; store as JSON. undefined = leave column as-is. const hasPauses = pauses !== undefined; const pausesJson = hasPauses ? JSON.stringify(pauses || []) : 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${hasPauses ? ", pauses=?9" : ""} WHERE id=?8`, hasPauses ? [endTime, safe(distM), safe(kcal), safe(avgSpd), safe(maxSpd), safe(elevGain), safe(elevLoss), id, pausesJson] : [endTime, safe(distM), safe(kcal), safe(avgSpd), safe(maxSpd), safe(elevGain), safe(elevLoss), id] ); } // Persist a de-jittered (smoothed) variant: write lat_smoothed/lon_smoothed per // point (original lat/lon untouched) and flag the session. Reversible via // setSessionSmoothed(id, false). `smoothedPoints` carry each point's `id` + the // smoothed `lat`/`lon`. async function saveSmoothedTrack(sessionId, smoothedPoints, window, iters, onProgress) { // One UPDATE per point over IPC is thousands of round-trips (slow, ~1 pt/ms). // Batch into a single CASE-per-chunk UPDATE — ~15 statements instead of 4000+. // Chunk keeps bound params under SQLite's 999 limit (250 pts × 3 params = 750). let pts = smoothedPoints.filter(p => p.id != null); let CHUNK = 250; for (let start = 0; start < pts.length; start += CHUNK) { let chunk = pts.slice(start, start + CHUNK); let latWhen = [], lonWhen = [], ids = [], params = []; for (let p of chunk) { params.push(p.id); let idi = params.length; params.push(p.lat); let lati = params.length; params.push(p.lon); let loni = params.length; latWhen.push(`WHEN ?${idi} THEN ?${lati}`); lonWhen.push(`WHEN ?${idi} THEN ?${loni}`); ids.push(`?${idi}`); } await execute( `UPDATE gps_points SET lat_smoothed=CASE id ${latWhen.join(" ")} END, ` + `lon_smoothed=CASE id ${lonWhen.join(" ")} END WHERE id IN (${ids.join(",")})`, params ); if (onProgress) { try { onProgress(Math.min(start + CHUNK, pts.length), pts.length); } catch {} } } await execute("UPDATE gps_sessions SET smoothed=1, smooth_window=?1, smooth_iters=?2 WHERE id=?3", [window, iters, sessionId]); } async function setSessionSmoothed(sessionId, on) { await execute("UPDATE gps_sessions SET smoothed=?1 WHERE id=?2", [on ? 1 : 0, sessionId]); } // 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, offset) { return select( "SELECT *, CAST(id AS TEXT) AS id_str FROM gps_sessions ORDER BY date DESC, start_time DESC, id DESC LIMIT ?1 OFFSET ?2", [limit ?? 50, offset ?? 0] ); } 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]); if (!rows.length) return null; const s = rows[0]; if (typeof s.pauses === "string") { try { s.pauses = JSON.parse(s.pauses); } catch { s.pauses = []; } } if (typeof s.wind_marks === "string") { try { s.wind_marks = JSON.parse(s.wind_marks); } catch { s.wind_marks = []; } } return s; } // Save headwind labels for a session (JSON). Kept separate from finishGpsSession so // recompute paths preserve marks (they never call this). Marks are ground truth for // the wind estimate — they do not affect distance/kcal/power. async function setSessionWindMarks(id, marks) { await execute("UPDATE gps_sessions SET wind_marks=?1 WHERE id=?2", [JSON.stringify(marks || []), id]); } 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]); } // Bulk-set terrain altitude (altitude_srtm_m) for points by id. rows: [{id, srtm}]. // Batched CASE update — values are numbers (ids small, no i64 corruption), safe to inline. async function updatePointsSrtm(rows) { const BATCH = 200; for (let i = 0; i < rows.length; i += BATCH) { const batch = rows.slice(i, i + BATCH).filter(r => r.id != null && r.srtm != null && isFinite(r.srtm)); if (!batch.length) continue; const cases = batch.map(r => `WHEN ${parseInt(r.id, 10)} THEN ${Number(r.srtm)}`).join(" "); const ids = batch.map(r => parseInt(r.id, 10)).join(","); await execute(`UPDATE gps_points SET altitude_srtm_m = CASE id ${cases} END WHERE id IN (${ids})`, []); } } // --------------------------------------------------------------------------- // 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]); } // --------------------------------------------------------------------------- // Saved routes (hand-drawn reference paths overlaid on the map) // --------------------------------------------------------------------------- async function getRoutes() { // Routes are few and small — load the blob to report a point count, drop it. const rows = await select("SELECT id, name, color, created_at, points_json FROM routes ORDER BY created_at DESC"); return rows.map(r => { let n = 0, hasEle = false; try { let pts = JSON.parse(r.points_json) || []; n = pts.length; hasEle = pts.some(p => p.ele != null); } catch {} return { id: r.id, name: r.name, color: r.color, created_at: r.created_at, pts: n, hasEle }; }); } async function getRoute(id) { const rows = await select("SELECT * FROM routes WHERE id = ?1", [id]); if (!rows.length) return null; const r = rows[0]; let points = []; try { points = JSON.parse(r.points_json) || []; } catch {} return { id: r.id, name: r.name, color: r.color, created_at: r.created_at, points }; } async function addRoute(name, color, points) { const r = await execute( "INSERT INTO routes (name, color, points_json) VALUES (?1, ?2, ?3)", [name, color || "#e53935", JSON.stringify(points || [])] ); return r.lastInsertId; } async function renameRoute(id, name) { await execute("UPDATE routes SET name = ?1 WHERE id = ?2", [name, id]); } async function setRouteColor(id, color) { await execute("UPDATE routes SET color = ?1 WHERE id = ?2", [color, id]); } async function deleteRoute(id) { await execute("DELETE FROM routes WHERE id = ?1", [id]); } // Overwrite a route's points (used to persist fetched elevations back into // points_json — points are {lat, lon, ele?}). async function updateRoutePoints(id, points) { await execute("UPDATE routes SET points_json = ?1 WHERE id = ?2", [JSON.stringify(points || []), 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