From 705a10bb3aa8d6982e11f1fca46fcd0076a89d1a Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Thu, 2 Apr 2026 19:46:01 +0200 Subject: [PATCH] recipes: overhaul nutrition editor UI and defer saves to form submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nutrition mappings and global overwrites are now local-only until the recipe is saved, preventing premature DB writes on generate/edit - Generate endpoint supports ?preview=true for non-persisting previews - Show existing nutrition data immediately instead of requiring generate - Replace raw checkboxes with Toggle component for global overwrites, initialized from existing NutritionOverwrite records - Fix search dropdown readability (solid backgrounds, proper theming) - Use fuzzy search (fzf-style) for manual nutrition ingredient lookup - Swap ingredient display: German primary, English in brackets - Allow editing g/u on manually mapped ingredients - Make translation optional: separate save (FAB) and translate buttons - "Vollständig neu übersetzen" now triggers actual full retranslation - Show existing translation inline instead of behind a button - Replace nord0 dark backgrounds with semantic theme variables --- src/lib/components/Toggle.svelte | 4 +- .../recipes/TranslationApproval.svelte | 15 +- .../edit/[name]/+page.server.ts | 40 ++ .../edit/[name]/+page.svelte | 675 ++++++++++-------- .../nutrition/generate/[name]/+server.ts | 12 +- src/routes/api/nutrition/search/+server.ts | 29 +- 6 files changed, 458 insertions(+), 317 deletions(-) diff --git a/src/lib/components/Toggle.svelte b/src/lib/components/Toggle.svelte index 6a8e80b..fbb00f6 100644 --- a/src/lib/components/Toggle.svelte +++ b/src/lib/components/Toggle.svelte @@ -1,5 +1,5 @@ @@ -915,33 +1006,33 @@ - -
- - {#if nutritionResult} -
+ + + + + {#if nutritionMappings.length > 0} +
+
+

Nährwerte

+ +
+

- {nutritionResult.mappings.filter((m) => m.matchMethod !== 'none').length}/{nutritionResult.count} Zutaten zugeordnet + {nutritionMappings.filter((m) => m.matchMethod !== 'none').length}/{nutritionMappings.length} Zutaten zugeordnet

- {#each nutritionResult.mappings as m, i} + {#each nutritionMappings as m, i (mappingKey(m))} {@const key = mappingKey(m)} - +
#ZutatQuelleTreffer / SucheKonf.g/u
{i + 1} - {m.ingredientName} - {#if m.ingredientNameDe && m.ingredientNameDe !== m.ingredientName} - ({m.ingredientNameDe}) + {m.ingredientNameDe || m.ingredientName} + {#if m.ingredientName && m.ingredientName !== m.ingredientNameDe} + ({m.ingredientName}) {/if} @@ -955,7 +1046,7 @@ {/if} -
+
{#if m.excluded} Übersprungen {:else if m.matchMethod !== 'none' && !searchQueries[key]} @@ -963,48 +1054,49 @@ {/if} handleSearchInput(key, e.currentTarget.value)} /> {#if searchResults[key]?.length > 0} -
    - {#each searchResults[key] as result} +
      + {#each searchResults[key] as result (result.id)}
    • {/each}
    {/if}
    - + { globalToggle[key] = !globalToggle[key]; }} /> {#if m.manuallyEdited || m.excluded} - + {/if}
{m.matchConfidence ? (m.matchConfidence * 100).toFixed(0) + '%' : '—'}{m.gramsPerUnit || '—'} + {#if m.manuallyEdited} + + {:else} + {m.gramsPerUnit || '—'} + {/if} +
- {/if} -
- - {#if !showTranslationWorkflow} -
- - {#if translationData} -
+ {:else} +
+

Nährwerte

+
+ - {/if} +
+
+ {/if} + + {#if !translationData && !showTranslationWorkflow} +
+

Übersetzung

+
+ +
{/if} -{#if showTranslationWorkflow} +{#if translationData || showTranslationWorkflow}
{/if} + + + diff --git a/src/routes/api/[recipeLang=recipeLang]/nutrition/generate/[name]/+server.ts b/src/routes/api/[recipeLang=recipeLang]/nutrition/generate/[name]/+server.ts index dd26d03..92b600e 100644 --- a/src/routes/api/[recipeLang=recipeLang]/nutrition/generate/[name]/+server.ts +++ b/src/routes/api/[recipeLang=recipeLang]/nutrition/generate/[name]/+server.ts @@ -4,7 +4,7 @@ import { dbConnect } from '$utils/db'; import { isEnglish } from '$lib/server/recipeHelpers'; import { generateNutritionMappings } from '$lib/server/nutritionMatcher'; -export const POST: RequestHandler = async ({ params, locals }) => { +export const POST: RequestHandler = async ({ params, locals, url }) => { await locals.auth(); await dbConnect(); @@ -19,6 +19,14 @@ export const POST: RequestHandler = async ({ params, locals }) => { const ingredients = recipe.ingredients || []; const translatedIngredients = recipe.translations?.en?.ingredients; + const newMappings = await generateNutritionMappings(ingredients, translatedIngredients); + const preview = url.searchParams.get('preview') === 'true'; + + // In preview mode, return pure auto-matches without saving (client merges manual edits) + if (preview) { + return json({ mappings: newMappings, count: newMappings.length }); + } + // Preserve manually edited mappings const existingMappings = recipe.nutritionMappings || []; const manualMappings = new Map( @@ -27,8 +35,6 @@ export const POST: RequestHandler = async ({ params, locals }) => { .map((m: any) => [`${m.sectionIndex}-${m.ingredientIndex}`, m]) ); - const newMappings = await generateNutritionMappings(ingredients, translatedIngredients); - // Merge: keep manual edits, use new auto-matches for the rest const finalMappings = newMappings.map(m => { const key = `${m.sectionIndex}-${m.ingredientIndex}`; diff --git a/src/routes/api/nutrition/search/+server.ts b/src/routes/api/nutrition/search/+server.ts index 74ab505..99d0e37 100644 --- a/src/routes/api/nutrition/search/+server.ts +++ b/src/routes/api/nutrition/search/+server.ts @@ -1,41 +1,48 @@ import { json, type RequestHandler } from '@sveltejs/kit'; import { NUTRITION_DB } from '$lib/data/nutritionDb'; import { BLS_DB } from '$lib/data/blsDb'; +import { fuzzyScore } from '$lib/js/fuzzy'; -/** GET: Search BLS + USDA nutrition databases by name substring */ +/** GET: Search BLS + USDA nutrition databases by fuzzy name match */ export const GET: RequestHandler = async ({ url }) => { const q = (url.searchParams.get('q') || '').toLowerCase().trim(); if (q.length < 2) return json([]); - const results: { source: 'bls' | 'usda'; id: string; name: string; category: string; calories: number }[] = []; + const scored: { source: 'bls' | 'usda'; id: string; name: string; category: string; calories: number; score: number }[] = []; - // Search BLS first (primary) + // Search BLS (primary) for (const entry of BLS_DB) { - if (results.length >= 30) break; - if (entry.nameDe.toLowerCase().includes(q) || entry.nameEn.toLowerCase().includes(q)) { - results.push({ + const scoreDe = fuzzyScore(q, entry.nameDe.toLowerCase()); + const scoreEn = entry.nameEn ? fuzzyScore(q, entry.nameEn.toLowerCase()) : 0; + const best = Math.max(scoreDe, scoreEn); + if (best > 0) { + scored.push({ source: 'bls', id: entry.blsCode, name: `${entry.nameDe}${entry.nameEn ? ` (${entry.nameEn})` : ''}`, category: entry.category, calories: entry.per100g.calories, + score: best, }); } } - // Then USDA + // Search USDA for (const entry of NUTRITION_DB) { - if (results.length >= 40) break; - if (entry.name.toLowerCase().includes(q)) { - results.push({ + const s = fuzzyScore(q, entry.name.toLowerCase()); + if (s > 0) { + scored.push({ source: 'usda', id: String(entry.fdcId), name: entry.name, category: entry.category, calories: entry.per100g.calories, + score: s, }); } } - return json(results.slice(0, 30)); + // Sort by score descending, return top 30 (without score field) + scored.sort((a, b) => b.score - a.score); + return json(scored.slice(0, 30).map(({ score, ...rest }) => rest)); };