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:
parent
0032c309b7
commit
3e5333cec6
5 changed files with 1134 additions and 91 deletions
|
|
@ -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.
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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}">✕</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}">✕</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}">✕</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">Нет рецептов. Создайте в Продукты → Рецепты.</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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue