diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 9584c6b..89833be 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -251,6 +251,15 @@ body { color: var(--on-surface); font-size: 16px; font-family: inherit; + color-scheme: dark; + -webkit-appearance: none; + appearance: none; +} + +select { + background: var(--bg); + color: var(--on-surface); + color-scheme: dark; } .form-group input:focus, @@ -868,8 +877,6 @@ body { /* Modal product search */ .modal-search-results { - max-height: 180px; - overflow-y: auto; margin-top: 4px; } @@ -1369,6 +1376,126 @@ body { font-size: 14px; } +/* Recipe components */ +/* Guessed nutrients indicator — slight tint on food items with estimated micronutrients */ +.log-item.guessed { + background: rgba(187, 134, 252, 0.2); +} + +.seed-progress { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 60vh; + gap: 12px; + padding: 0 32px; +} +.seed-label { + color: var(--on-bg); + font-size: 16px; +} +.seed-bar { + width: 100%; + max-width: 300px; + height: 8px; + background: var(--surface); + border-radius: 4px; + overflow: hidden; +} +.seed-bar-fill { + height: 100%; + width: 30%; + background: var(--primary); + border-radius: 4px; + animation: seed-slide 1.2s ease-in-out infinite; +} +@keyframes seed-slide { + 0% { margin-left: 0%; } + 50% { margin-left: 70%; } + 100% { margin-left: 0%; } +} +/* When progress is known, switch to determinate mode */ +.seed-bar-fill.determinate { + animation: none; + margin-left: 0; + transition: width 0.3s; +} +.seed-count { + color: var(--muted); + font-size: 13px; +} + +.sub-tabs { + display: flex; + gap: 0; + margin-bottom: 12px; + border-bottom: 1px solid var(--border); +} +.sub-tab { + flex: 1; + background: none; + border: none; + color: var(--muted); + font-size: 14px; + padding: 8px 0; + cursor: pointer; + border-bottom: 2px solid transparent; +} +.sub-tab.active { + color: var(--primary); + border-bottom-color: var(--primary); +} + +.recipe-macros-hint { + padding: 2px 12px 8px; + font-size: 12px; + color: var(--muted); +} + +.recipe-ing-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid rgba(255,255,255,0.04); +} + +.recipe-ing-info { + flex: 1; + display: flex; + align-items: baseline; + gap: 8px; + overflow: hidden; +} + +.recipe-ing-name { + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.recipe-ing-weight { + font-size: 12px; + color: var(--muted); + flex-shrink: 0; +} + +.recipe-ing-cm { + font-size: 11px; + color: var(--primary); + flex-shrink: 0; +} + +.recipe-totals { + padding: 10px 0; + font-size: 15px; + font-weight: 600; + color: var(--primary); + text-align: center; +} + /* Loading */ .loading { text-align: center; diff --git a/frontend/data/products_seed.db b/frontend/data/products_seed.db index da7e4e9..977d999 100644 Binary files a/frontend/data/products_seed.db and b/frontend/data/products_seed.db differ diff --git a/frontend/js/db.js b/frontend/js/db.js index 69023b6..e67fa15 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -115,10 +115,11 @@ async function searchProducts(query, limit) { WHERE name_lower LIKE ?1 GROUP BY COALESCE(store_id, id) ORDER BY - CASE WHEN name_lower LIKE ?1 THEN 0 ELSE 1 END, + CASE WHEN name_lower LIKE ?2 THEN 0 ELSE 1 END, + length(name), name COLLATE NOCASE - LIMIT ?2`, - [`%${q}%`, limit ?? 30] + LIMIT ?3`, + [`%${q}%`, `${q}%`, limit ?? 200] ); } @@ -154,6 +155,184 @@ async function addProduct(p) { return r.lastInsertId; } +// --------------------------------------------------------------------------- +// 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_b1_mg, + p.vit_b2_mg, + 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) { + const r = 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] + ); + return r.lastInsertId; +} + +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]; + } + + const totals = { + kcal: 0, protein: 0, fat: 0, carbs: 0, fiber: 0, + vit_a_mcg: 0, vit_c_mg: 0, vit_b1_mg: 0, vit_b2_mg: 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, + vit_a_mcg: (ing.vit_a_mcg ?? 0) * factor, + vit_c_mg: (ing.vit_c_mg ?? 0) * factor, + vit_b1_mg: (ing.vit_b1_mg ?? 0) * factor, + vit_b2_mg: (ing.vit_b2_mg ?? 0) * factor, + }; + + // 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); + 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 + return { + 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, + vit_a_mcg: Math.round(totals.vit_a_mcg / servings * 10) / 10, + vit_c_mg: Math.round(totals.vit_c_mg / servings * 10) / 10, + vit_b1_mg: Math.round(totals.vit_b1_mg / servings * 1000) / 1000, + vit_b2_mg: Math.round(totals.vit_b2_mg / servings * 1000) / 1000, + servings: servings, + totalWeight: ingredients.reduce((s, i) => s + (i.amount_g || 0), 0), + }; +} + +// Log a recipe as a food entry — stores calculated nutrition per serving +async function addFoodFromRecipe(date, time, recipeId) { + const nutr = await calculateRecipeNutrition(recipeId); + if (!nutr) return; + 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, Math.round(nutr.totalWeight / nutr.servings), + nutr.kcal, nutr.protein, nutr.fat, nutr.carbs, nutr.fiber, null] + ); +} + // --------------------------------------------------------------------------- // Food log // --------------------------------------------------------------------------- @@ -161,7 +340,7 @@ async function addProduct(p) { async function getFoodForDate(date) { return select( `SELECT fl.*, p.name as product_name, p.brand as product_brand, - r.name as recipe_name + COALESCE(p.nutrients_guessed, 0) as nutrients_guessed, r.name as recipe_name FROM food_log fl LEFT JOIN products p ON fl.product_id = p.id LEFT JOIN recipes r ON fl.recipe_id = r.id @@ -484,6 +663,19 @@ window.DB.deleteSupplementLog = deleteSupplementLog; window.DB.getSupplementTotalsForDate = getSupplementTotalsForDate; window.DB.getFoodTotalsForDate = getFoodTotalsForDate; window.DB.getActivityTotalsForDate = getActivityTotalsForDate; +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; +window.DB.addFoodFromRecipe = addFoodFromRecipe; window.DB.createGpsSession = createGpsSession; window.DB.addGpsPoint = addGpsPoint; window.DB.finishGpsSession = finishGpsSession; @@ -509,21 +701,90 @@ async function runMigrations() { await execute("ALTER TABLE gps_points ADD COLUMN seq INTEGER"); } - // Repair old tracks: assign seq to points where it's NULL, ordered by timestamp. - // Uses rowid to break ties when timestamps are identical. - const nullSeqSessions = await select( - "SELECT DISTINCT session_id FROM gps_points WHERE seq IS NULL" - ); - 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`); + if (!(await hasColumn("products", "nutrients_guessed"))) { + await execute("ALTER TABLE products ADD COLUMN nutrients_guessed INTEGER DEFAULT 0"); } + + console.log("[db.js] base migrations done, starting cooking_methods..."); + + // --- 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 table: add missing columns --- + if (!(await hasColumn("recipes", "cooking_method_id"))) { + await execute("ALTER TABLE recipes ADD COLUMN cooking_method_id INTEGER REFERENCES cooking_methods(id)"); + } + if (!(await hasColumn("recipes", "created_at"))) { + await execute("ALTER TABLE recipes ADD COLUMN created_at TEXT DEFAULT (datetime('now'))"); + } + // Ensure servings column exists (Rust creates it as INTEGER, we need REAL for half-portions) + // Column already exists from Rust, so we leave it as is. + + // --- Recipe ingredients: add missing columns --- + if (!(await hasColumn("recipe_ingredients", "custom_name"))) { + await execute("ALTER TABLE recipe_ingredients ADD COLUMN custom_name TEXT"); + } + if (!(await hasColumn("recipe_ingredients", "cooking_method_id"))) { + await execute("ALTER TABLE recipe_ingredients ADD COLUMN cooking_method_id INTEGER REFERENCES cooking_methods(id)"); + } + + 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(() => { diff --git a/frontend/js/main.js b/frontend/js/main.js index 78c47ff..b0a2884 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -29,25 +29,59 @@ async function init() { return; } + // Show loading immediately so the user sees something + app.innerHTML = `
Способ: ${esc(recipe.cooking_method_name)}
` : ""} + ${recipe.servings > 1 ? `Порций: ${recipe.servings}
` : ""} + ${recipe.notes ? `${esc(recipe.notes)}
` : ""} + +