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 = `
+
Загрузка...
+
+
+
`; + + const bootLabel = document.getElementById("boot-label"); + const bootCount = document.getElementById("seed-count"); + function bootLog(msg) { if (bootLabel) bootLabel.textContent = msg; } + // Wait for DB migrations - if (DB._migrationsReady) await DB._migrationsReady; + try { + bootLog("Миграция базы данных..."); + if (DB._migrationsReady) await DB._migrationsReady; + bootLog("Миграция завершена"); + } catch (e) { bootLog("Ошибка миграции: " + e); } // Recover any orphaned GPS recording session - await GPS.recoverSession(); - - // Import seed products if needed try { - app.innerHTML = `
Проверка продуктов...
`; + bootLog("Проверка GPS..."); + await GPS.recoverSession(); + } catch (e) { /* ignore */ } + + // Import seed products if needed — with progress bar + try { + bootLog("Проверка продуктов..."); + + let unlisten = null; + try { + unlisten = await window.__TAURI__.event.listen("seed_progress", (ev) => { + const { current, total } = ev.payload; + const pct = total > 0 ? Math.round(current / total * 100) : 0; + const fill = document.getElementById("seed-fill"); + if (fill) { + fill.classList.add("determinate"); + fill.style.width = pct + "%"; + } + if (bootCount) bootCount.textContent = current + " / " + total; + bootLog("Обновление продуктов (" + pct + "%)"); + }); + } catch {} + const result = await window.__TAURI__.core.invoke("import_seed_products"); - console.log("[SEED]", result); - if (result.startsWith("imported:")) { - const [_, count, total] = result.split(":"); - console.log(`[SEED] Imported ${count}/${total} products`); - } + bootLog("Продукты: " + result); + + if (unlisten) unlisten(); } catch (e) { - console.error("[SEED] error:", e); + bootLog("Ошибка продуктов: " + e); } + bootLog("Готово, загрузка экрана..."); + // Apply safe area insets (Android status bar / nav bar) try { const insets = await window.__TAURI__.core.invoke("get_safe_insets"); @@ -87,6 +121,7 @@ function renderScreen(name, fromPopstate) { const screens = { today: renderToday, products: renderProducts, + recipes: renderRecipes, gps: renderGpsScreen, recording: renderRecordingInline, supplements: renderSupplements, @@ -148,22 +183,30 @@ function buildNav(active) { async function renderToday(container) { container.innerHTML = `
Загрузка...
`; - const [weights, food, activities, sleep, foodTotals, actTotals, suppLog, suppTotals] = await Promise.all([ - DB.getWeightsForDate(state.date), - DB.getFoodForDate(state.date), - DB.getActivitiesForDate(state.date), - DB.getSleepForDate(state.date), - DB.getFoodTotalsForDate(state.date), - DB.getActivityTotalsForDate(state.date), - DB.getSupplementLogForDate(state.date), - DB.getSupplementTotalsForDate(state.date), - ]); + let weights = [], food = [], activities = [], sleep = [], foodTotals = {}, actTotals = {}, suppLog = [], suppTotals = {}; + try { + [weights, food, activities, sleep, foodTotals, actTotals, suppLog, suppTotals] = await Promise.all([ + DB.getWeightsForDate(state.date), + DB.getFoodForDate(state.date), + DB.getActivitiesForDate(state.date), + DB.getSleepForDate(state.date), + DB.getFoodTotalsForDate(state.date), + DB.getActivityTotalsForDate(state.date), + DB.getSupplementLogForDate(state.date), + DB.getSupplementTotalsForDate(state.date), + ]); + } catch (e) { + console.error("[renderToday] DB error:", e); + container.innerHTML = `
Ошибка загрузки: ${esc(String(e))}
`; + return; + } const bmr = calcBmr(weights); const intake = Math.round(foodTotals.kcal ?? 0); const actKcal = Math.round(actTotals.kcal ?? 0); const expend = bmr != null ? bmr + actKcal : null; const balance = expend != null ? intake - expend : null; + const hasGuessed = food.some(f => f.nutrients_guessed); container.innerHTML = `
@@ -174,7 +217,7 @@ async function renderToday(container) {
- ${intake} + ${intake}${hasGuessed ? "*" : ""} ккал приход
@@ -204,13 +247,31 @@ async function renderToday(container) {
-

Еда

-
+

Еда: Продукты

+ +
    - ${food.map(f => - `
  • + ${food.filter(f => !f.recipe_id).map(f => + `
  • ${f.time ?? ""} - ${esc(f.product_name ?? f.recipe_name ?? f.note ?? "—")} + ${esc(f.product_name ?? f.note ?? "—")} + ${f.amount_g ? `${f.amount_g}г` : ""} + ${f.kcal != null ? Math.round(f.kcal) + " кк" : ""} + +
  • ` + ).join("") || `
  • нет данных
  • `} +
+
+ +
+

Еда: Блюда

+ +
+
    + ${food.filter(f => f.recipe_id).map(f => + `
  • + ${f.time ?? ""} + ${esc(f.recipe_name ?? f.note ?? "—")} ${f.amount_g ? `${f.amount_g}г` : ""} ${f.kcal != null ? Math.round(f.kcal) + " кк" : ""} @@ -292,6 +353,7 @@ async function renderToday(container) { container.querySelector("#add-weight-btn").onclick = () => showWeightModal(); container.querySelector("#add-food-btn").onclick = () => showFoodModal(); + container.querySelector("#add-recipe-food-btn").onclick = () => showFoodFromRecipeModal(); container.querySelector("#add-act-btn").onclick = () => showActivityModal(); container.querySelector("#add-supp-btn").onclick = () => showLogSupplementModal(); container.querySelector("#add-sleep-btn").onclick = () => showSleepModal(sleep); @@ -436,7 +498,7 @@ function showFoodModal() { debounce = setTimeout(async () => { const q = searchInput.value.trim(); if (q.length < 2) { resultsEl.innerHTML = ""; return; } - const results = await DB.searchProducts(q, 10); + const results = await DB.searchProducts(q); resultsEl.innerHTML = results.map(p => `
  • + ${esc(name)}${cm} + ${ing.amount_g} г +
  • `; + }).join(""); + + showModal(` +

    ${esc(recipe.name)}

    + ${recipe.cooking_method_name ? `

    Способ: ${esc(recipe.cooking_method_name)}

    ` : ""} + ${recipe.servings > 1 ? `

    Порций: ${recipe.servings}

    ` : ""} + ${recipe.notes ? `

    ${esc(recipe.notes)}

    ` : ""} + +
    +
    Ингредиенты
    +
      ${ingList || `
    • нет ингредиентов
    • `}
    +
    + + ${nutr ? ` +
    +
    Пищевая ценность (на порцию)
    +
    +
    ${nutr.kcal}ккал
    +
    ${nutr.protein}Б
    +
    ${nutr.fat}Ж
    +
    ${nutr.carbs}У
    +
    ${nutr.fiber}Кл
    +
    +
    ` : ""} + + + + + `, () => {}); + + // Wire up buttons after DOM is ready + const overlay = document.querySelector(".modal-overlay"); + const logBtn = overlay.querySelector("#rd-log"); + const editBtn = overlay.querySelector("#rd-edit"); + const delBtn = overlay.querySelector("#rd-delete"); + + logBtn.onclick = async () => { + await DB.addFoodFromRecipe(state.date, nowTime(), recipeId); + overlay.remove(); + history.back(); + renderScreen(state.screen); + }; + + editBtn.onclick = () => { + overlay.remove(); + history.back(); + showRecipeEditorModal(recipeId); + }; + + delBtn.onclick = async () => { + await DB.deleteRecipe(recipeId); + overlay.remove(); + history.back(); + renderScreen("recipes"); + }; +} + +// --------------------------------------------------------------------------- +// Recipe editor modal — create / edit recipe with ingredient list +// --------------------------------------------------------------------------- + +async function showRecipeEditorModal(recipeId) { + const isNew = !recipeId; + let recipe = isNew ? { name: "", cooking_method_id: null, servings: 1, notes: "" } : await DB.getRecipe(recipeId); + if (!recipe) return; + + const cookingMethods = await DB.getCookingMethods(); + const existingIngredients = isNew ? [] : await DB.getRecipeIngredients(recipeId); + + // Local in-memory ingredient list for editing + // Each item: { product_id, product_name, custom_name, weight_g, cooking_method_id, cooking_method_name } + const ingredients = existingIngredients.map(ing => ({ + product_id: ing.product_id, + product_name: ing.product_name, + custom_name: ing.custom_name, + weight_g: ing.amount_g, + cooking_method_id: ing.cooking_method_id, + cooking_method_name: ing.cooking_method_name, + })); + + const cmOptions = cookingMethods.map(cm => + `` + ).join(""); + + function renderIngredientRows() { + if (ingredients.length === 0) return `
    Добавьте ингредиенты
    `; + return ingredients.map((ing, idx) => { + const name = ing.product_name ?? ing.custom_name ?? "?"; + return `
    +
    + ${esc(name)} + ${ing.weight_g} г + ${ing.cooking_method_name ? `${esc(ing.cooking_method_name)}` : ""} +
    + +
    `; + }).join(""); + } + + // Calculate totals from current in-memory ingredients, with cooking loss + function calcTotals() { + let kcal = 0, protein = 0, fat = 0, carbs = 0; + const defaultMethodId = parseInt(document.querySelector("#re-method")?.value) || 0; + for (const ing of ingredients) { + if (!ing._product) continue; + const f = (ing.weight_g || 0) / 100; + let ik = (ing._product.kcal_per_100g ?? 0) * f; + let ip = (ing._product.protein_per_100g ?? 0) * f; + let ifat = (ing._product.fat_per_100g ?? 0) * f; + let ic = (ing._product.carbs_per_100g ?? 0) * f; + // Apply cooking loss + const cmId = ing.cooking_method_id || defaultMethodId; + const cm = cmId ? cookingMethods.find(m => m.id === cmId) : null; + if (cm) { + ik *= (1 - (cm.kcal_loss_pct ?? 0) / 100); + ip *= (1 - (cm.protein_loss_pct ?? 0) / 100); + ifat *= (1 - (cm.fat_loss_pct ?? 0) / 100); + ic *= (1 - (cm.carbs_loss_pct ?? 0) / 100); + } + kcal += ik; protein += ip; fat += ifat; carbs += ic; + } + const servings = parseFloat(document.querySelector("#re-servings")?.value) || 1; + return { + kcal: Math.round(kcal / servings), + protein: Math.round(protein / servings * 10) / 10, + fat: Math.round(fat / servings * 10) / 10, + carbs: Math.round(carbs / servings * 10) / 10, + }; + } + + function updateTotalsDisplay() { + const el = document.querySelector("#re-totals"); + if (!el) return; + const t = calcTotals(); + el.innerHTML = `${t.kcal} кк | Б${t.protein} Ж${t.fat} У${t.carbs}`; + } + + showModal(` +

    ${isNew ? "Новый рецепт" : "Редактирование рецепта"}

    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    Ингредиенты
    + +
    +
    ${renderIngredientRows()}
    +
    + +
    + `, async overlay => { + const name = overlay.querySelector("#re-name").value.trim(); + if (!name) return false; + if (ingredients.length === 0) return false; + + const methodId = parseInt(overlay.querySelector("#re-method").value) || null; + const servings = parseFloat(overlay.querySelector("#re-servings").value) || 1; + const notes = overlay.querySelector("#re-notes").value.trim() || null; + + let rid; + if (isNew) { + rid = await DB.saveRecipe(name, methodId, servings, notes); + } else { + rid = recipeId; + await DB.updateRecipe(rid, name, methodId, servings, notes); + await DB.clearRecipeIngredients(rid); + } + + for (const ing of ingredients) { + await DB.addRecipeIngredient( + rid, ing.product_id, ing.custom_name, ing.weight_g, ing.cooking_method_id + ); + } + renderScreen("recipes"); + }); + + // Set initial method value + const overlay = document.querySelector(".modal-overlay"); + if (recipe.cooking_method_id) { + overlay.querySelector("#re-method").value = recipe.cooking_method_id; + } + + // Servings or method change recalculates totals + overlay.querySelector("#re-servings").oninput = updateTotalsDisplay; + overlay.querySelector("#re-method").onchange = updateTotalsDisplay; + + // Bind delete buttons and add ingredient button + function rebindIngredients() { + const listEl = overlay.querySelector("#re-ing-list"); + listEl.innerHTML = renderIngredientRows(); + listEl.querySelectorAll(".recipe-ing-del").forEach(btn => { + btn.onclick = (e) => { + e.stopPropagation(); + const idx = parseInt(btn.dataset.idx); + ingredients.splice(idx, 1); + rebindIngredients(); + updateTotalsDisplay(); + }; + }); + } + + rebindIngredients(); + + // Add ingredient button — shows inline search + overlay.querySelector("#re-add-ing").onclick = () => { + showAddIngredientModal(cookingMethods, (newIng) => { + ingredients.push(newIng); + rebindIngredients(); + updateTotalsDisplay(); + }); + }; + + // Load product data for existing ingredients (for nutrition calc) + for (let i = 0; i < ingredients.length; i++) { + if (ingredients[i].product_id) { + ingredients[i]._product = await DB.getProduct(ingredients[i].product_id); + } + } + updateTotalsDisplay(); +} + +// --------------------------------------------------------------------------- +// Add ingredient sub-modal — search product, set weight, optional cooking method +// --------------------------------------------------------------------------- + +function showAddIngredientModal(cookingMethods, onAdd) { + let selectedProduct = null; + + const cmOptions = cookingMethods.map(cm => + `` + ).join(""); + + showModal(` +

    Добавить ингредиент

    +
    + + + +
    + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    + `, async overlay => { + const weight = parseFloat(overlay.querySelector("#ing-weight").value); + if (isNaN(weight) || weight <= 0) return false; + + const methodId = parseInt(overlay.querySelector("#ing-method").value) || null; + const methodName = methodId + ? cookingMethods.find(cm => cm.id === methodId)?.name ?? null + : null; + + if (selectedProduct) { + onAdd({ + product_id: selectedProduct.id, + product_name: selectedProduct.name, + custom_name: null, + weight_g: weight, + cooking_method_id: methodId, + cooking_method_name: methodName, + _product: selectedProduct, + }); + } else { + const custom = overlay.querySelector("#ing-custom").value.trim(); + if (!custom) return false; + onAdd({ + product_id: null, + product_name: null, + custom_name: custom, + weight_g: weight, + cooking_method_id: methodId, + cooking_method_name: methodName, + _product: null, + }); + } + }); + + // Wire up product search — use the LAST modal overlay (this one, not the parent recipe editor) + const overlays = document.querySelectorAll(".modal-overlay"); + const overlay = overlays[overlays.length - 1]; + const searchInput = overlay.querySelector("#ing-search"); + const resultsEl = overlay.querySelector("#ing-results"); + const selectedEl = overlay.querySelector("#ing-selected"); + const customBlock = overlay.querySelector("#ing-custom-block"); + let debounce = null; + + searchInput.oninput = () => { + clearTimeout(debounce); + debounce = setTimeout(async () => { + const q = searchInput.value.trim(); + if (q.length < 2) { resultsEl.innerHTML = ""; customBlock.style.display = ""; return; } + customBlock.style.display = "none"; + const results = await DB.searchProducts(q, 15); + resultsEl.innerHTML = results.map(p => + `` + ).join("") || `
    Не найдено
    `; + + resultsEl.querySelectorAll(".modal-prod-item").forEach(item => { + item.onclick = async () => { + const pid = parseInt(item.dataset.pid); + selectedProduct = await DB.getProduct(pid); + resultsEl.innerHTML = ""; + searchInput.value = ""; + selectedEl.style.display = "block"; + selectedEl.innerHTML = `${esc(selectedProduct.name)}`; + customBlock.style.display = "none"; + + selectedEl.onclick = () => { + selectedProduct = null; + selectedEl.style.display = "none"; + customBlock.style.display = ""; + searchInput.focus(); + }; + }; + }); + }, 300); + }; + + searchInput.focus(); +} + +// --------------------------------------------------------------------------- +// Food modal: add recipe option +// --------------------------------------------------------------------------- + +function showFoodFromRecipeModal() { + // Show a list of saved recipes to quickly log one + showModal(` +

    Записать рецепт

    +
    Загрузка...
    +
    + + +
    + `, async overlay => { + const selected = overlay.querySelector(".recipe-pick-item.selected"); + if (!selected) return false; + const rid = parseInt(selected.dataset.rid); + const time = overlay.querySelector("#rfr-time").value; + await DB.addFoodFromRecipe(state.date, time, rid); + }); + + // Load recipes into the list + const overlay = document.querySelector(".modal-overlay"); + const listEl = overlay.querySelector("#rfr-list"); + + (async () => { + const recipes = await DB.getRecipes(); + if (!recipes.length) { + listEl.innerHTML = `
    Нет рецептов. Создайте в Продукты → Рецепты.
    `; + return; + } + + // Calculate nutrition for display + const items = []; + for (const r of recipes) { + const n = await DB.calculateRecipeNutrition(r.id); + items.push({ ...r, nutr: n }); + } + + listEl.innerHTML = items.map(r => + `
    + ${esc(r.name)} + ${r.nutr ? `${r.nutr.kcal} кк` : ""} +
    ` + ).join(""); + + listEl.querySelectorAll(".recipe-pick-item").forEach(item => { + item.onclick = () => { + listEl.querySelectorAll(".recipe-pick-item").forEach(i => i.classList.remove("selected")); + item.classList.add("selected"); + }; + }); + })(); +} + // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // GPS screen — map, recording, sessions list, GPX import diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a9af8e7..2290399 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ use tauri_plugin_sql::{Migration, MigrationKind}; #[allow(unused_imports)] use tauri::Manager; +use tauri::Emitter; use serde::Serialize; #[derive(Serialize)] @@ -83,40 +84,45 @@ fn import_seed_products(app: tauri::AppHandle) -> Result { // Find seed DB — try multiple locations let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let seed_in_data = data_dir.join("products_seed.db"); - let seed_in_files = data_dir.join("files").join("products_seed.db"); + // Always try to refresh seed DB from bundled assets (resource_dir). + // This ensures app updates deliver new seed data even if an old copy exists. + let resource_dir = app.path().resource_dir().unwrap_or_default(); + let seed_resource = resource_dir.join("data").join("products_seed.db"); + if seed_resource.exists() { + // Overwrite the local copy with the bundled one (may be newer) + let _ = std::fs::copy(&seed_resource, &seed_in_data); + } + let seed_path = if seed_in_data.exists() { seed_in_data } else if seed_in_files.exists() { seed_in_files } else { - let resource_dir = app.path().resource_dir().unwrap_or_default(); - let seed_resource = resource_dir.join("data").join("products_seed.db"); - if seed_resource.exists() { - std::fs::copy(&seed_resource, &seed_in_data.clone()).map_err(|e| e.to_string())?; - seed_in_data - } else { - return Err(format!("Seed DB not found. Checked: {:?}, {:?}", seed_in_data, seed_in_files)); - } + return Err(format!("Seed DB not found. Checked: {:?}, {:?}", seed_in_data, seed_in_files)); }; let seed_db = rusqlite::Connection::open(&seed_path).map_err(|e| e.to_string())?; - // Read seed version let seed_version: i64 = seed_db.query_row( "SELECT version FROM seed_meta LIMIT 1", [], |r| r.get(0) ).map_err(|e| e.to_string())?; - // Open main DB — check multiple locations (Tauri may put it in different dirs) + // tauri-plugin-sql may store DB in app_config_dir (desktop: ~/.config/...) + // or app_data_dir (desktop: ~/.local/share/...). Check both. + let config_dir = app.path().app_config_dir().unwrap_or_default(); let main_path = { - let p1 = data_dir.join("baldav.db"); - let p2 = data_dir.join("..").join("baldav.db"); - if p1.exists() { p1 } else if p2.exists() { p2 } else { p1 } + let p_config = config_dir.join("baldav.db"); + let p_data = data_dir.join("baldav.db"); + let p_parent = data_dir.join("..").join("baldav.db"); + if p_config.exists() { p_config } + else if p_data.exists() { p_data } + else if p_parent.exists() { p_parent } + else { p_data } }; let main_db = rusqlite::Connection::open(&main_path).map_err(|e| e.to_string())?; - // Check current seed version in main DB let current_version: i64 = main_db.query_row( "SELECT COALESCE((SELECT value FROM profile WHERE key='seed_version'), '0')", [], |r| { @@ -129,48 +135,99 @@ fn import_seed_products(app: tauri::AppHandle) -> Result { return Ok(format!("up-to-date:v{}", current_version)); } - // Count products in seed let total: usize = seed_db.query_row("SELECT COUNT(*) FROM products", [], |r| r.get(0)) .map_err(|e| e.to_string())?; - // Import products in batches + // Emit initial progress + let _ = app.emit("seed_progress", serde_json::json!({"current": 0, "total": total})); + + // Ensure all columns exist in main DB (including micronutrients from ION import) + let extra_cols = [ + "composition TEXT", "name_lower TEXT", "category TEXT", + "sodium_mg REAL", "potassium_mg REAL", "calcium_mg REAL", + "magnesium_mg REAL", "phosphorus_mg REAL", "iron_mg REAL", + "vit_a_mcg REAL", "vit_b1_mg REAL", "vit_b2_mg REAL", + "vit_b3_mg REAL", "vit_c_mg REAL", "vit_e_mg REAL", "carotene_mcg REAL", + "nutrients_guessed INTEGER DEFAULT 0", + ]; + for col in &extra_cols { + let _ = main_db.execute(&format!("ALTER TABLE products ADD COLUMN {}", col), []); + } + + // Read ALL columns from seed DB including micronutrients let mut stmt = seed_db.prepare( - "SELECT name, brand, kcal_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, composition, source, store_id, name_lower, weight_g FROM products" + "SELECT name, brand, kcal_per_100g, protein_per_100g, fat_per_100g, + carbs_per_100g, fiber_per_100g, composition, source, store_id, + name_lower, weight_g, category, + sodium_mg, potassium_mg, calcium_mg, magnesium_mg, phosphorus_mg, + iron_mg, vit_a_mcg, vit_b1_mg, vit_b2_mg, vit_b3_mg, vit_c_mg, + vit_e_mg, carotene_mcg, COALESCE(nutrients_guessed, 0) + FROM products" ).map_err(|e| e.to_string())?; let mut inserted = 0usize; + let mut processed = 0usize; + let rows = stmt.query_map([], |row| { + // 26 columns: 0-12 strings/basic, 13-25 micronutrients Ok(( - row.get::<_, Option>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, Option>(4)?, - row.get::<_, Option>(5)?, - row.get::<_, Option>(6)?, - row.get::<_, Option>(7)?, - row.get::<_, Option>(8)?, - row.get::<_, Option>(9)?, - row.get::<_, Option>(10)?, - row.get::<_, Option>(11)?, + row.get::<_, Option>(0)?, // name + row.get::<_, Option>(1)?, // brand + row.get::<_, Option>(2)?, // kcal + row.get::<_, Option>(3)?, // protein + row.get::<_, Option>(4)?, // fat + row.get::<_, Option>(5)?, // carbs + row.get::<_, Option>(6)?, // fiber + row.get::<_, Option>(7)?, // composition + row.get::<_, Option>(8)?, // source + row.get::<_, Option>(9)?, // store_id + row.get::<_, Option>(10)?, // name_lower + row.get::<_, Option>(11)?, // weight_g + row.get::<_, Option>(12)?, // category + row.get::<_, Option>(13)?, // sodium_mg + row.get::<_, Option>(14)?, // potassium_mg + row.get::<_, Option>(15)?, // calcium_mg + row.get::<_, Option>(16)?, // magnesium_mg + row.get::<_, Option>(17)?, // phosphorus_mg + row.get::<_, Option>(18)?, // iron_mg + row.get::<_, Option>(19)?, // vit_a_mcg + row.get::<_, Option>(20)?, // vit_b1_mg + row.get::<_, Option>(21)?, // vit_b2_mg + row.get::<_, Option>(22)?, // vit_b3_mg + row.get::<_, Option>(23)?, // vit_c_mg + row.get::<_, Option>(24)?, // vit_e_mg + row.get::<_, Option>(25)?, // carotene_mcg + row.get::<_, Option>(26)?, // nutrients_guessed )) }).map_err(|e| e.to_string())?; - // Ensure columns exist - let _ = main_db.execute("ALTER TABLE products ADD COLUMN composition TEXT", []); - let _ = main_db.execute("ALTER TABLE products ADD COLUMN name_lower TEXT", []); - main_db.execute("BEGIN", []).map_err(|e| e.to_string())?; for row in rows { let r = row.map_err(|e| e.to_string())?; main_db.execute( - "INSERT OR IGNORE INTO products (name, brand, kcal_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, composition, source, store_id, name_lower, weight_g) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)", - rusqlite::params![r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9, r.10, r.11], + "INSERT OR IGNORE INTO products ( + name, brand, kcal_per_100g, protein_per_100g, fat_per_100g, + carbs_per_100g, fiber_per_100g, composition, source, store_id, + name_lower, weight_g, category, + sodium_mg, potassium_mg, calcium_mg, magnesium_mg, phosphorus_mg, + iron_mg, vit_a_mcg, vit_b1_mg, vit_b2_mg, vit_b3_mg, vit_c_mg, + vit_e_mg, carotene_mcg, nutrients_guessed + ) 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)", + rusqlite::params![ + r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9, + r.10, r.11, r.12, r.13, r.14, r.15, r.16, r.17, r.18, r.19, + r.20, r.21, r.22, r.23, r.24, r.25, r.26 + ], ).map_err(|e| e.to_string())?; inserted += 1; + processed += 1; + + // Emit progress every 200 rows + if processed % 200 == 0 { + let _ = app.emit("seed_progress", serde_json::json!({"current": processed, "total": total})); + } } - // Update seed version main_db.execute( "INSERT OR REPLACE INTO profile (key, value) VALUES ('seed_version', ?1)", [seed_version.to_string()], @@ -178,6 +235,41 @@ fn import_seed_products(app: tauri::AppHandle) -> Result { main_db.execute("COMMIT", []).map_err(|e| e.to_string())?; + // Sync enriched data for EXISTING products (INSERT OR IGNORE skips them). + // ATTACH seed DB and run bulk UPDATE to copy nutrients_guessed flag and micronutrients. + let seed_str = seed_path.to_str().unwrap_or_default().to_string(); + if let Ok(_) = main_db.execute("ATTACH DATABASE ?1 AS seed", rusqlite::params![seed_str]) { + // Sync nutrients_guessed flag + let _ = main_db.execute_batch( + "UPDATE main.products SET nutrients_guessed = 1 + WHERE nutrients_guessed IS NULL OR nutrients_guessed = 0 + AND name_lower IN (SELECT s.name_lower FROM seed.products s WHERE s.nutrients_guessed = 1)" + ); + // Sync micronutrients for products that have them in seed but not in main + let _ = main_db.execute_batch( + "UPDATE main.products SET + sodium_mg = COALESCE(sodium_mg, (SELECT s.sodium_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1)), + potassium_mg = COALESCE(potassium_mg, (SELECT s.potassium_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1)), + calcium_mg = COALESCE(calcium_mg, (SELECT s.calcium_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1)), + magnesium_mg = COALESCE(magnesium_mg, (SELECT s.magnesium_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1)), + phosphorus_mg= COALESCE(phosphorus_mg,(SELECT s.phosphorus_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1)), + iron_mg = COALESCE(iron_mg, (SELECT s.iron_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1)), + vit_a_mcg = COALESCE(vit_a_mcg, (SELECT s.vit_a_mcg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1)), + vit_b1_mg = COALESCE(vit_b1_mg, (SELECT s.vit_b1_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1)), + vit_b2_mg = COALESCE(vit_b2_mg, (SELECT s.vit_b2_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1)), + vit_b3_mg = COALESCE(vit_b3_mg, (SELECT s.vit_b3_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1)), + vit_c_mg = COALESCE(vit_c_mg, (SELECT s.vit_c_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1)), + vit_e_mg = COALESCE(vit_e_mg, (SELECT s.vit_e_mg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1)), + carotene_mcg = COALESCE(carotene_mcg, (SELECT s.carotene_mcg FROM seed.products s WHERE s.name_lower = main.products.name_lower LIMIT 1)) + WHERE main.products.sodium_mg IS NULL + AND main.products.name_lower IN (SELECT s2.name_lower FROM seed.products s2 WHERE s2.sodium_mg IS NOT NULL)" + ); + let _ = main_db.execute("DETACH DATABASE seed", []); + } + + // Final progress + let _ = app.emit("seed_progress", serde_json::json!({"current": total, "total": total})); + Ok(format!("imported:{}:{}", inserted, total)) }