Recipes, nutrients_guessed, seed import fixes, search improvements

Recipes:
- Cooking methods table + 6 presets (boiling, frying, steaming etc.)
- Recipe editor with live KBJU calculation including cooking loss
- Recipe detail view, log-to-diary, ingredient search
- Split food log into "Products" and "Dishes" sections
- Products screen sub-tabs: Продукты / Рецепты

Nutrient enrichment:
- 1620 store products enriched with ION micronutrients (guessed flag)
- Purple tint on guessed food entries, * on daily kcal total
- Seed DB v4 with nutrients_guessed column

Seed import fixes:
- Rust: always refresh seed DB from bundled assets on launch
- Rust: check app_config_dir for main DB (plugin-sql stores there on desktop)
- Rust: ATTACH-based UPDATE to sync nutrients_guessed for existing products
- Rust: read all 27 columns including micronutrients
- Rust: emit seed_progress events + indeterminate progress bar animation
- JS: boot screen with status labels during migrations/import
- JS: nutrients_guessed column created in JS migration as safety net

Search:
- Results sorted by starts-with-query first, then by name length
- Default limit raised to 200
- Modal search results scroll without max-height cap

Bug fixes:
- product_id=0 for custom recipe ingredients (NOT NULL constraint)
- getRecipe JOIN for cooking_method_name
- calcTotals applies cooking loss from selected method
- Ingredient modal targets last overlay (not parent)
- select elements dark theme (color-scheme: dark)
- renderToday wrapped in try/catch
This commit is contained in:
4aiman 2026-05-21 00:06:50 +03:00
commit 3e5333cec6
5 changed files with 1134 additions and 91 deletions

View file

@ -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;

Binary file not shown.

View file

@ -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(() => {

View file

@ -29,25 +29,59 @@ async function init() {
return;
}
// Show loading immediately so the user sees something
app.innerHTML = `<div class="seed-progress">
<div class="seed-label" id="boot-label">Загрузка...</div>
<div class="seed-bar"><div class="seed-bar-fill" id="seed-fill"></div></div>
<div class="seed-count" id="seed-count"></div>
</div>`;
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 = `<div class="loading">Проверка продуктов...</div>`;
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 = `<div class="loading">Загрузка...</div>`;
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 = `<div class="loading" style="color:#cf6679">Ошибка загрузки: ${esc(String(e))}</div>`;
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 = `
<div class="day-header">
@ -174,7 +217,7 @@ async function renderToday(container) {
<div class="summary-row">
<div class="summary-card">
<span class="summary-val">${intake}</span>
<span class="summary-val">${intake}${hasGuessed ? "*" : ""}</span>
<span class="summary-lbl">ккал приход</span>
</div>
<div class="summary-card">
@ -204,13 +247,31 @@ async function renderToday(container) {
</section>
<section class="log-section">
<div class="section-header"><h3>Еда</h3>
<button class="add-btn" id="add-food-btn">+</button></div>
<div class="section-header"><h3>Еда: Продукты</h3>
<button class="add-btn" id="add-food-btn">+</button>
</div>
<ul class="log-list">
${food.map(f =>
`<li class="log-item food-edit-item" data-food-id="${f.id}">
${food.filter(f => !f.recipe_id).map(f =>
`<li class="log-item food-edit-item${f.nutrients_guessed ? " guessed" : ""}" data-food-id="${f.id}">
<span class="log-time">${f.time ?? ""}</span>
<span class="log-desc">${esc(f.product_name ?? f.recipe_name ?? f.note ?? "—")}
<span class="log-desc">${esc(f.product_name ?? f.note ?? "—")}
${f.amount_g ? `<small>${f.amount_g}г</small>` : ""}</span>
<span class="log-kcal">${f.kcal != null ? Math.round(f.kcal) + " кк" : ""}</span>
<button class="del-btn" data-table="food" data-id="${f.id}">&#x2715;</button>
</li>`
).join("") || `<li class="log-empty">нет данных</li>`}
</ul>
</section>
<section class="log-section">
<div class="section-header"><h3>Еда: Блюда</h3>
<button class="add-btn" id="add-recipe-food-btn" title="Из рецепта">+</button>
</div>
<ul class="log-list">
${food.filter(f => f.recipe_id).map(f =>
`<li class="log-item food-edit-item${f.nutrients_guessed ? " guessed" : ""}" data-food-id="${f.id}">
<span class="log-time">${f.time ?? ""}</span>
<span class="log-desc">${esc(f.recipe_name ?? f.note ?? "—")}
${f.amount_g ? `<small>${f.amount_g}г</small>` : ""}</span>
<span class="log-kcal">${f.kcal != null ? Math.round(f.kcal) + " кк" : ""}</span>
<button class="del-btn" data-table="food" data-id="${f.id}">&#x2715;</button>
@ -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 =>
`<div class="modal-prod-item" data-pid="${p.id}">
<span>${esc(p.name)}</span>
@ -534,7 +596,7 @@ function showEditFoodModal(entry) {
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 =>
`<div class="modal-prod-item" data-pid="${p.id}">
<span>${esc(p.name)}</span>
@ -633,13 +695,31 @@ async function renderProducts(container) {
container.innerHTML = `
<div class="products-screen">
<h2>Продукты</h2>
<input id="prod-search" class="search-input" type="search"
placeholder="Название, бренд или ингредиент..." />
<div id="prod-count" class="prod-count"></div>
<div id="prod-results" class="prod-list"></div>
<div class="sub-tabs">
<button class="sub-tab active" data-tab="products">Продукты</button>
<button class="sub-tab" data-tab="recipes">Рецепты</button>
</div>
<div id="products-tab">
<input id="prod-search" class="search-input" type="search"
placeholder="Название, бренд или ингредиент..." />
<div id="prod-count" class="prod-count"></div>
<div id="prod-results" class="prod-list"></div>
</div>
<div id="recipes-tab" style="display:none"></div>
</div>`;
// Sub-tab switching
container.querySelectorAll(".sub-tab").forEach(btn => {
btn.onclick = () => {
container.querySelectorAll(".sub-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
const tab = btn.dataset.tab;
container.querySelector("#products-tab").style.display = tab === "products" ? "" : "none";
container.querySelector("#recipes-tab").style.display = tab === "recipes" ? "" : "none";
if (tab === "recipes") renderRecipes(container.querySelector("#recipes-tab"));
};
});
const input = container.querySelector("#prod-search");
const countEl = container.querySelector("#prod-count");
const listEl = container.querySelector("#prod-results");
@ -751,6 +831,489 @@ async function renderProducts(container) {
input.focus();
}
// ---------------------------------------------------------------------------
// RECIPES screen
// ---------------------------------------------------------------------------
async function renderRecipes(container) {
const recipes = await DB.getRecipes();
// Pre-calculate nutrition for each recipe to show kcal in list
const nutritionMap = {};
for (const r of recipes) {
try {
nutritionMap[r.id] = await DB.calculateRecipeNutrition(r.id);
} catch (e) {
nutritionMap[r.id] = null;
}
}
container.innerHTML = `
<div class="products-screen">
<div class="section-header">
<h2>Рецепты</h2>
<button class="add-btn" id="add-recipe-btn">+</button>
</div>
<div id="recipe-list" class="prod-list">
${recipes.length ? recipes.map(r => {
const n = nutritionMap[r.id];
const kcalStr = n ? n.kcal + " кк" : "";
const macroStr = n ? `Б${n.protein} Ж${n.fat} У${n.carbs}` : "";
return `
<div class="prod-card recipe-card" data-rid="${r.id}">
<div class="prod-card-header">
<div class="prod-card-name">
${esc(r.name)}
${r.cooking_method_name ? `<span class="prod-brand">${esc(r.cooking_method_name)}</span>` : ""}
${r.servings > 1 ? `<span class="prod-weight">${r.servings} порц.</span>` : ""}
</div>
<div class="prod-card-kcal">${kcalStr}</div>
</div>
${macroStr ? `<div class="recipe-macros-hint">${macroStr}</div>` : ""}
</div>`;
}).join("") : `<div class="prod-hint">Нет рецептов. Нажмите + чтобы создать.</div>`}
</div>
</div>`;
container.querySelector("#add-recipe-btn").onclick = () => showRecipeEditorModal(null);
container.querySelectorAll(".recipe-card").forEach(card => {
card.onclick = () => {
const rid = parseInt(card.dataset.rid);
showRecipeDetailModal(rid);
};
});
}
// ---------------------------------------------------------------------------
// Recipe detail modal — view recipe, log to food diary
// ---------------------------------------------------------------------------
async function showRecipeDetailModal(recipeId) {
const recipe = await DB.getRecipe(recipeId);
if (!recipe) return;
const ingredients = await DB.getRecipeIngredients(recipeId);
const nutr = await DB.calculateRecipeNutrition(recipeId);
const ingList = ingredients.map(ing => {
const name = ing.product_name ?? ing.custom_name ?? "?";
const cm = ing.cooking_method_name ? ` (${ing.cooking_method_name})` : "";
return `<li class="log-item">
<span class="log-desc">${esc(name)}${cm}</span>
<span class="log-kcal">${ing.amount_g} г</span>
</li>`;
}).join("");
showModal(`
<h3>${esc(recipe.name)}</h3>
${recipe.cooking_method_name ? `<p style="color:var(--muted);font-size:13px;margin-bottom:8px">Способ: ${esc(recipe.cooking_method_name)}</p>` : ""}
${recipe.servings > 1 ? `<p style="color:var(--muted);font-size:13px;margin-bottom:8px">Порций: ${recipe.servings}</p>` : ""}
${recipe.notes ? `<p style="color:var(--muted);font-size:13px;margin-bottom:12px">${esc(recipe.notes)}</p>` : ""}
<div class="sd-section">
<div class="sd-title">Ингредиенты</div>
<ul class="log-list">${ingList || `<li class="log-empty">нет ингредиентов</li>`}</ul>
</div>
${nutr ? `
<div class="sd-section" style="margin-top:12px">
<div class="sd-title">Пищевая ценность (на порцию)</div>
<div class="prod-macros">
<div class="macro"><span class="macro-val">${nutr.kcal}</span><span class="macro-lbl">ккал</span></div>
<div class="macro"><span class="macro-val">${nutr.protein}</span><span class="macro-lbl">Б</span></div>
<div class="macro"><span class="macro-val">${nutr.fat}</span><span class="macro-lbl">Ж</span></div>
<div class="macro"><span class="macro-val">${nutr.carbs}</span><span class="macro-lbl">У</span></div>
<div class="macro"><span class="macro-val">${nutr.fiber}</span><span class="macro-lbl">Кл</span></div>
</div>
</div>` : ""}
<button id="rd-log" class="btn-primary full-width" style="margin-top:16px">Записать в дневник</button>
<button id="rd-edit" class="btn-secondary full-width" style="margin-top:8px">Редактировать</button>
<button id="rd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить рецепт</button>
`, () => {});
// 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 =>
`<option value="${cm.id}">${esc(cm.name)}</option>`
).join("");
function renderIngredientRows() {
if (ingredients.length === 0) return `<div class="log-empty">Добавьте ингредиенты</div>`;
return ingredients.map((ing, idx) => {
const name = ing.product_name ?? ing.custom_name ?? "?";
return `<div class="recipe-ing-row" data-idx="${idx}">
<div class="recipe-ing-info">
<span class="recipe-ing-name">${esc(name)}</span>
<span class="recipe-ing-weight">${ing.weight_g} г</span>
${ing.cooking_method_name ? `<span class="recipe-ing-cm">${esc(ing.cooking_method_name)}</span>` : ""}
</div>
<button class="del-btn recipe-ing-del" data-idx="${idx}">&#x2715;</button>
</div>`;
}).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(`
<h3>${isNew ? "Новый рецепт" : "Редактирование рецепта"}</h3>
<div class="form-group">
<label>Название</label>
<input id="re-name" type="text" value="${esc(recipe.name)}" placeholder="Борщ, Оливье..." />
</div>
<div class="form-group">
<label>Способ приготовления</label>
<select id="re-method">
<option value="">-- нет --</option>
${cmOptions}
</select>
</div>
<div class="form-group">
<label>Порций</label>
<input id="re-servings" type="number" inputmode="decimal" step="0.5" min="0.5"
value="${recipe.servings ?? 1}" />
</div>
<div class="form-group">
<label>Заметки</label>
<input id="re-notes" type="text" value="${esc(recipe.notes ?? "")}" placeholder="Бабушкин рецепт..." />
</div>
<div class="sd-section" style="margin-top:12px">
<div class="section-header" style="padding-left:0">
<div class="sd-title">Ингредиенты</div>
<button class="add-btn" id="re-add-ing">+</button>
</div>
<div id="re-ing-list">${renderIngredientRows()}</div>
</div>
<div id="re-totals" class="recipe-totals"></div>
`, 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 =>
`<option value="${cm.id}">${esc(cm.name)}</option>`
).join("");
showModal(`
<h3>Добавить ингредиент</h3>
<div class="form-group">
<label>Поиск продукта</label>
<input id="ing-search" type="search" placeholder="Название продукта..." />
<div id="ing-results" class="modal-search-results"></div>
</div>
<div id="ing-selected" class="modal-selected" style="display:none"></div>
<div id="ing-custom-block">
<div class="form-group">
<label>Или вручную: название</label>
<input id="ing-custom" type="text" placeholder="масло оливковое" />
</div>
</div>
<div class="form-group">
<label>Вес (г)</label>
<input id="ing-weight" type="number" inputmode="decimal" value="100" placeholder="100" />
</div>
<div class="form-group">
<label>Обработка (для этого ингредиента)</label>
<select id="ing-method">
<option value="">-- как рецепт --</option>
${cmOptions}
</select>
</div>
`, 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 =>
`<div class="modal-prod-item" data-pid="${p.id}">
<span>${esc(p.name)}</span>
<span class="prod-card-kcal">${p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) + " кк" : "?"}</span>
</div>`
).join("") || `<div class="prod-hint">Не найдено</div>`;
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 = `<span>${esc(selectedProduct.name)}</span>`;
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(`
<h3>Записать рецепт</h3>
<div id="rfr-list" class="prod-list"><div class="loading">Загрузка...</div></div>
<div class="form-group" style="margin-top:12px">
<label>Время</label>
<input id="rfr-time" type="time" value="${nowTime()}" />
</div>
`, 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 = `<div class="prod-hint">Нет рецептов. Создайте в Продукты &rarr; Рецепты.</div>`;
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 =>
`<div class="supp-pick-item recipe-pick-item" data-rid="${r.id}">
${esc(r.name)}
${r.nutr ? `<small style="color:var(--muted);margin-left:6px">${r.nutr.kcal} кк</small>` : ""}
</div>`
).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

View file

@ -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<String, String> {
// 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<String, String> {
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<String>>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<f64>>(2)?,
row.get::<_, Option<f64>>(3)?,
row.get::<_, Option<f64>>(4)?,
row.get::<_, Option<f64>>(5)?,
row.get::<_, Option<f64>>(6)?,
row.get::<_, Option<String>>(7)?,
row.get::<_, Option<String>>(8)?,
row.get::<_, Option<String>>(9)?,
row.get::<_, Option<String>>(10)?,
row.get::<_, Option<f64>>(11)?,
row.get::<_, Option<String>>(0)?, // name
row.get::<_, Option<String>>(1)?, // brand
row.get::<_, Option<f64>>(2)?, // kcal
row.get::<_, Option<f64>>(3)?, // protein
row.get::<_, Option<f64>>(4)?, // fat
row.get::<_, Option<f64>>(5)?, // carbs
row.get::<_, Option<f64>>(6)?, // fiber
row.get::<_, Option<String>>(7)?, // composition
row.get::<_, Option<String>>(8)?, // source
row.get::<_, Option<String>>(9)?, // store_id
row.get::<_, Option<String>>(10)?, // name_lower
row.get::<_, Option<f64>>(11)?, // weight_g
row.get::<_, Option<String>>(12)?, // category
row.get::<_, Option<f64>>(13)?, // sodium_mg
row.get::<_, Option<f64>>(14)?, // potassium_mg
row.get::<_, Option<f64>>(15)?, // calcium_mg
row.get::<_, Option<f64>>(16)?, // magnesium_mg
row.get::<_, Option<f64>>(17)?, // phosphorus_mg
row.get::<_, Option<f64>>(18)?, // iron_mg
row.get::<_, Option<f64>>(19)?, // vit_a_mcg
row.get::<_, Option<f64>>(20)?, // vit_b1_mg
row.get::<_, Option<f64>>(21)?, // vit_b2_mg
row.get::<_, Option<f64>>(22)?, // vit_b3_mg
row.get::<_, Option<f64>>(23)?, // vit_c_mg
row.get::<_, Option<f64>>(24)?, // vit_e_mg
row.get::<_, Option<f64>>(25)?, // carotene_mcg
row.get::<_, Option<i64>>(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<String, String> {
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))
}