diff --git a/apps/api/src/lib/contentLanguage.ts b/apps/api/src/lib/contentLanguage.ts index 59bff5f..04d0762 100644 --- a/apps/api/src/lib/contentLanguage.ts +++ b/apps/api/src/lib/contentLanguage.ts @@ -72,6 +72,40 @@ export async function resolveRecipeTranslation( }; } +/** + * Publicerade recepttitlar för flera recept på en gång: Map recipeId -> titel. + * För listor/rekommendationer där vi bara behöver titeln (inte hela receptet). + * Saknade id:n utelämnas (anroparen faller tillbaka på svensk titel). + */ +export async function resolveRecipeTitles( + db: Database, + recipeIds: string[], + languageTag: string, +): Promise> { + const candidates = languageCandidates(languageTag); + if (candidates.includes("sv") || recipeIds.length === 0) return new Map(); + const rows = await db + .select({ + recipeId: schema.recipeTranslations.recipeId, + languageTag: schema.recipeTranslations.languageTag, + title: schema.recipeTranslations.title, + }) + .from(schema.recipeTranslations) + .where( + and( + inArray(schema.recipeTranslations.recipeId, recipeIds), + inArray(schema.recipeTranslations.languageTag, candidates), + eq(schema.recipeTranslations.status, "published"), + ), + ); + const out = new Map(); + // Exakt språk (candidates[0]) vinner över primärt (candidates[1]). + for (const candidate of [...candidates].reverse()) { + for (const r of rows) if (r.languageTag === candidate) out.set(r.recipeId, r.title); + } + return out; +} + /** * Ingrediensnamn för användarens språk: Map ingredientId -> namn. * Saknade id:n behåller svenskt namn (fallback sker hos anroparen). diff --git a/apps/api/src/routes/recommendations.ts b/apps/api/src/routes/recommendations.ts index f219573..2c5f364 100644 --- a/apps/api/src/routes/recommendations.ts +++ b/apps/api/src/routes/recommendations.ts @@ -26,6 +26,7 @@ import { DEFAULT_TARGETS, computeDailyTargets, summarizeDay } from "@app/nutriti import { parse } from "../lib/errors.js"; import { getActiveHouseholdId, todayIso } from "../lib/helpers.js"; import { loadLocalePreferences } from "../lib/localeContext.js"; +import { resolveIngredientNames, resolveRecipeTitles } from "../lib/contentLanguage.js"; /** * "Vad ska vi äta?" (spec §18) – appens viktigaste endpoint. @@ -604,6 +605,27 @@ export async function recommendationRoutes(app: FastifyInstance) { ? await computeSearchSuggestion(app, q.search) : null; + // --- i18n: översätt titlar + ingrediensnamn till användarens språk (svensk källa = fallback) --- + const languageTag = localePrefs.languageTag; + const titleByRecipe = await resolveRecipeTitles( + app.db, + recommendations.map((r) => r.recipeId), + languageTag, + ); + const localizedById = await resolveIngredientNames( + app.db, + allIngredients + .map((i) => i.canonicalIngredientId) + .filter((id): id is string => id != null), + languageTag, + ); + const svToLocalized = new Map(); + for (const i of allIngredients) { + const loc = i.canonicalIngredientId ? localizedById.get(i.canonicalIngredientId) : undefined; + if (loc) svToLocalized.set(i.displayNameSv, loc); + } + const localizeName = (n: string): string => svToLocalized.get(n) ?? n; + return { mealType: q.mealType, context: { @@ -622,6 +644,9 @@ export async function recommendationRoutes(app: FastifyInstance) { mealBoxSuggestions, recommendations: recommendations.map((r) => ({ ...r, + title: titleByRecipe.get(r.recipeId) ?? r.titleSv, + usesExpiring: r.usesExpiring.map((u) => ({ ...u, name: localizeName(u.nameSv) })), + missingIngredients: r.missingIngredients.map(localizeName), ratingAverage: ratingByRecipe.get(r.recipeId)?.average ?? null, ratingCount: ratingByRecipe.get(r.recipeId)?.count ?? 0, })), diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 5f33cb6..b7d2c67 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -29,10 +29,11 @@ import { colors, spacing } from "@/lib/theme"; interface Recommendation { recipeId: string; titleSv: string; + title?: string; score: number; whySv: string; missingIngredients: string[]; - usesExpiring: Array<{ nameSv: string; daysLeft: number | null }>; + usesExpiring: Array<{ nameSv: string; name?: string; daysLeft: number | null }>; coveragePercent: number; ratingAverage: number | null; ratingCount: number; @@ -73,7 +74,7 @@ const CATS: ReadonlyArray<{ { key: "dinner", labelKey: "myday.mealType.dinner", mealType: "dinner", glyph: "🍽️" }, { key: "snack", labelKey: "myday.mealType.snack", mealType: "snack", glyph: "🍎" }, { key: "dessert", labelKey: "myday.mealType.dessert", mealType: "dessert", glyph: "🍰" }, - { key: "baking", labelKey: null, label: "Baka", tag: "baking", ctxMeal: "dessert", glyph: "🥐" }, + { key: "baking", labelKey: "cat.baking", label: "Baka", tag: "baking", ctxMeal: "dessert", glyph: "🥐" }, ]; const errText = (e: unknown): string | undefined => @@ -161,7 +162,7 @@ export default function WhatToEatScreen() { {/* Sök bland recept (fungerar i alla flikar, söker på namn). */} submitSearch(searchText)} @@ -187,7 +188,7 @@ export default function WhatToEatScreen() { {/* Kategori-flikar med enhetliga rutor (2 kolumner). Filtrerar in-place. */}