i18n(hemskärm): översätt rekommendationstitlar + ingrediensnamn + hårdkodade strängar

- what-to-eat översätter nu recepttitlar (resolveRecipeTitles, batch) + /saknas-
  ingrediensnamn (via allIngredients-id-karta + resolveIngredientNames)
- index.tsx använder rec.title/item.name; hårdkodat -> t(): sökfält, Rekommenderat,
  Toppval, Menade du, Baka (kategori), tomma-lägen
- 7 nya nycklar (wte.searchPlaceholder/recommended/topPick/didYouMean/emptySearch/
  emptyHoliday, cat.baking) i alla 12 språk
- KVAR (separat): whySv-prosan (kräver strukturerade skäl), säsongsval (region), meal-box-titlar

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-08-21 00:36:35 +00:00
parent 4e86b1b724
commit 28c4d0db71
15 changed files with 154 additions and 10 deletions
+34
View File
@@ -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<Map<string, string>> {
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<string, string>();
// 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).
+25
View File
@@ -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<string, string>();
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,
})),
+11 -10
View File
@@ -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). */}
<Input
placeholder="Sök recept, t.ex. pannkaka…"
placeholder={t("wte.searchPlaceholder")}
value={searchText}
onChangeText={setSearchText}
onSubmitEditing={() => submitSearch(searchText)}
@@ -187,7 +188,7 @@ export default function WhatToEatScreen() {
{/* Kategori-flikar med enhetliga rutor (2 kolumner). Filtrerar in-place. */}
<Row style={{ flexWrap: "wrap", justifyContent: "space-between" }}>
<Button
label="🍽️ Rekommenderat"
label={`🍽️ ${t("wte.recommended")}`}
variant={cat === null ? "secondary" : "ghost"}
onPress={() => selectCat(null)}
style={{ width: "48%", marginBottom: spacing.xs }}
@@ -237,9 +238,9 @@ export default function WhatToEatScreen() {
<EmptyState
text={
submittedSearch
? "Inga recept matchar din sökning."
? t("wte.emptySearch")
: holiday
? "Inga recept för den här högtiden än."
? t("wte.emptyHoliday")
: t("wte.empty")
}
/>
@@ -248,16 +249,16 @@ export default function WhatToEatScreen() {
{/* "Menade du …?" stavningsförslag när sök gav noll träffar. */}
{allRecs.length === 0 && submittedSearch && query.data.context.searchSuggestion && (
<Pressable onPress={() => submitSearch(query.data!.context.searchSuggestion!)}>
<Tag label={`Menade du ${query.data.context.searchSuggestion}?`} tone="accent" />
<Tag label={t("wte.didYouMean", { suggestion: query.data.context.searchSuggestion })} tone="accent" />
</Pressable>
)}
{shownRecs.map((rec, index) => (
<Card key={rec.recipeId} onPress={() => router.push(`/recipe/${rec.recipeId}`)}>
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
<Heading>{rec.titleSv}</Heading>
<Heading>{rec.title ?? rec.titleSv}</Heading>
{page === 0 && index === 0 && !submittedSearch && !holiday && (
<Tag label="Toppval" tone="accent" />
<Tag label={t("wte.topPick")} tone="accent" />
)}
</Row>
<Row>
@@ -272,7 +273,7 @@ export default function WhatToEatScreen() {
tone={rec.coveragePercent >= 80 ? "success" : "neutral"}
/>
{rec.usesExpiring.slice(0, 2).map((item) => (
<Tag key={item.nameSv} label={`${item.nameSv}`} tone="warning" />
<Tag key={item.nameSv} label={`${item.name ?? item.nameSv}`} tone="warning" />
))}
</Row>
<View
+7
View File
@@ -376,6 +376,13 @@
"plan.activate": "Aktivér plan",
"plan.createShopping": "Opret indkøbsliste",
"wte.coverage": "{pct} % derhjemme",
"wte.searchPlaceholder": "Søg opskrifter, f.eks. pandekager…",
"wte.recommended": "Anbefalet",
"wte.topPick": "Bedste valg",
"wte.didYouMean": "Mente du {suggestion}?",
"wte.emptySearch": "Ingen opskrifter matcher din søgning.",
"wte.emptyHoliday": "Ingen opskrifter til denne anledning endnu.",
"cat.baking": "Bagning",
"wte.cravingPlaceholder": "Jeg har lyst til … (fx cremet, asiatisk, under 500 kcal)",
"wte.empty": "Ingen forslag endnu læg lidt mad på lager eller løsn filtrene.",
"wte.mealBoxFirst": "Færdig mad derhjemme",
+7
View File
@@ -376,6 +376,13 @@
"plan.activate": "Plan aktivieren",
"plan.createShopping": "Einkaufsliste erstellen",
"wte.coverage": "{pct} % zuhause",
"wte.searchPlaceholder": "Rezepte suchen, z. B. Pfannkuchen…",
"wte.recommended": "Empfohlen",
"wte.topPick": "Top-Wahl",
"wte.didYouMean": "Meintest du {suggestion}?",
"wte.emptySearch": "Keine Rezepte passen zu deiner Suche.",
"wte.emptyHoliday": "Noch keine Rezepte für diesen Anlass.",
"cat.baking": "Backen",
"wte.cravingPlaceholder": "Ich habe Lust auf … (z. B. cremig, asiatisch, unter 500 kcal)",
"wte.empty": "Noch keine Vorschläge fülle den Vorrat oder lockere die Filter.",
"wte.mealBoxFirst": "Fertiges Essen zuhause",
+7
View File
@@ -376,6 +376,13 @@
"plan.activate": "Activate plan",
"plan.createShopping": "Create shopping list",
"wte.coverage": "{pct}% at home",
"wte.searchPlaceholder": "Search recipes, e.g. pancakes…",
"wte.recommended": "Recommended",
"wte.topPick": "Top pick",
"wte.didYouMean": "Did you mean {suggestion}?",
"wte.emptySearch": "No recipes match your search.",
"wte.emptyHoliday": "No recipes for this occasion yet.",
"cat.baking": "Baking",
"wte.cravingPlaceholder": "I'm craving … (e.g. creamy, Asian, under 500 kcal)",
"wte.empty": "No suggestions yet add some food to your inventory or loosen the filters.",
"wte.mealBoxFirst": "Ready-made food at home",
+7
View File
@@ -376,6 +376,13 @@
"plan.activate": "Activar plan",
"plan.createShopping": "Crear lista de compra",
"wte.coverage": "{pct}% en casa",
"wte.searchPlaceholder": "Busca recetas, p. ej. tortitas…",
"wte.recommended": "Recomendado",
"wte.topPick": "Mejor opción",
"wte.didYouMean": "¿Quisiste decir {suggestion}?",
"wte.emptySearch": "Ninguna receta coincide con tu búsqueda.",
"wte.emptyHoliday": "Aún no hay recetas para esta ocasión.",
"cat.baking": "Repostería",
"wte.cravingPlaceholder": "Me apetece … (p. ej. cremoso, asiático, menos de 500 kcal)",
"wte.empty": "Aún no hay sugerencias: añade comida al inventario o relaja los filtros.",
"wte.mealBoxFirst": "Comida preparada en casa",
+7
View File
@@ -376,6 +376,13 @@
"plan.activate": "Aktivoi suunnitelma",
"plan.createShopping": "Luo ostoslista",
"wte.coverage": "{pct} % kotona",
"wte.searchPlaceholder": "Hae reseptejä, esim. lettuja…",
"wte.recommended": "Suositeltu",
"wte.topPick": "Paras valinta",
"wte.didYouMean": "Tarkoititko {suggestion}?",
"wte.emptySearch": "Yksikään resepti ei vastaa hakuasi.",
"wte.emptyHoliday": "Ei vielä reseptejä tähän tilaisuuteen.",
"cat.baking": "Leivonta",
"wte.cravingPlaceholder": "Tekisi mieli … (esim. kermaista, aasialaista, alle 500 kcal)",
"wte.empty": "Ei vielä ehdotuksia lisää ruokaa varastoon tai löysää suodattimia.",
"wte.mealBoxFirst": "Valmista ruokaa kotona",
+7
View File
@@ -376,6 +376,13 @@
"plan.activate": "Activer le plan",
"plan.createShopping": "Créer la liste de courses",
"wte.coverage": "{pct} % à la maison",
"wte.searchPlaceholder": "Rechercher des recettes, ex. crêpes…",
"wte.recommended": "Recommandé",
"wte.topPick": "Meilleur choix",
"wte.didYouMean": "Vouliez-vous dire {suggestion} ?",
"wte.emptySearch": "Aucune recette ne correspond à votre recherche.",
"wte.emptyHoliday": "Pas encore de recettes pour cette occasion.",
"cat.baking": "Pâtisserie",
"wte.cravingPlaceholder": "J'ai envie de … (ex. crémeux, asiatique, moins de 500 kcal)",
"wte.empty": "Pas encore de suggestions : ajoutez des aliments ou assouplissez les filtres.",
"wte.mealBoxFirst": "Plats prêts à la maison",
+7
View File
@@ -376,6 +376,13 @@
"plan.activate": "Attiva piano",
"plan.createShopping": "Crea lista della spesa",
"wte.coverage": "{pct}% in casa",
"wte.searchPlaceholder": "Cerca ricette, es. pancake…",
"wte.recommended": "Consigliato",
"wte.topPick": "Scelta migliore",
"wte.didYouMean": "Intendevi {suggestion}?",
"wte.emptySearch": "Nessuna ricetta corrisponde alla ricerca.",
"wte.emptyHoliday": "Ancora nessuna ricetta per questa occasione.",
"cat.baking": "Dolci",
"wte.cravingPlaceholder": "Ho voglia di … (es. cremoso, asiatico, meno di 500 kcal)",
"wte.empty": "Ancora nessun suggerimento: aggiungi cibo alla dispensa o allenta i filtri.",
"wte.mealBoxFirst": "Cibo pronto in casa",
+7
View File
@@ -376,6 +376,13 @@
"plan.activate": "Aktiver plan",
"plan.createShopping": "Lag handleliste",
"wte.coverage": "{pct} % hjemme",
"wte.searchPlaceholder": "Søk oppskrifter, f.eks. pannekaker…",
"wte.recommended": "Anbefalt",
"wte.topPick": "Toppvalg",
"wte.didYouMean": "Mente du {suggestion}?",
"wte.emptySearch": "Ingen oppskrift samsvarer med søket.",
"wte.emptyHoliday": "Ingen oppskrifter for denne anledningen ennå.",
"cat.baking": "Baking",
"wte.cravingPlaceholder": "Jeg har lyst på … (f.eks. kremet, asiatisk, under 500 kcal)",
"wte.empty": "Ingen forslag ennå legg inn litt mat på lageret eller løsne filtrene.",
"wte.mealBoxFirst": "Ferdig mat hjemme",
+7
View File
@@ -376,6 +376,13 @@
"plan.activate": "Plan activeren",
"plan.createShopping": "Boodschappenlijst maken",
"wte.coverage": "{pct}% in huis",
"wte.searchPlaceholder": "Zoek recepten, bijv. pannenkoeken…",
"wte.recommended": "Aanbevolen",
"wte.topPick": "Topkeuze",
"wte.didYouMean": "Bedoelde je {suggestion}?",
"wte.emptySearch": "Geen recepten komen overeen met je zoekopdracht.",
"wte.emptyHoliday": "Nog geen recepten voor deze gelegenheid.",
"cat.baking": "Bakken",
"wte.cravingPlaceholder": "Ik heb zin in … (bijv. romig, Aziatisch, onder 500 kcal)",
"wte.empty": "Nog geen suggesties vul de voorraad aan of versoepel de filters.",
"wte.mealBoxFirst": "Kant-en-klaar eten in huis",
+7
View File
@@ -390,6 +390,13 @@
"plan.activate": "Aktywuj plan",
"plan.createShopping": "Utwórz listę zakupów",
"wte.coverage": "{pct}% w domu",
"wte.searchPlaceholder": "Szukaj przepisów, np. naleśniki…",
"wte.recommended": "Polecane",
"wte.topPick": "Najlepszy wybór",
"wte.didYouMean": "Czy chodziło Ci o {suggestion}?",
"wte.emptySearch": "Żaden przepis nie pasuje do wyszukiwania.",
"wte.emptyHoliday": "Brak przepisów na tę okazję.",
"cat.baking": "Pieczenie",
"wte.cravingPlaceholder": "Mam ochotę na … (np. kremowe, azjatyckie, poniżej 500 kcal)",
"wte.empty": "Brak propozycji dodaj jedzenie do spiżarni albo poluzuj filtry.",
"wte.mealBoxFirst": "Gotowe jedzenie w domu",
+7
View File
@@ -376,6 +376,13 @@
"plan.activate": "Ativar plano",
"plan.createShopping": "Criar lista de compras",
"wte.coverage": "{pct}% em casa",
"wte.searchPlaceholder": "Pesquisar receitas, ex. panquecas…",
"wte.recommended": "Recomendado",
"wte.topPick": "Melhor escolha",
"wte.didYouMean": "Quis dizer {suggestion}?",
"wte.emptySearch": "Nenhuma receita corresponde à tua pesquisa.",
"wte.emptyHoliday": "Ainda não há receitas para esta ocasião.",
"cat.baking": "Pastelaria",
"wte.cravingPlaceholder": "Apetece-me … (ex.: cremoso, asiático, menos de 500 kcal)",
"wte.empty": "Ainda sem sugestões adicione comida à despensa ou alivie os filtros.",
"wte.mealBoxFirst": "Comida pronta em casa",
+7
View File
@@ -384,6 +384,13 @@
"plan.activate": "Aktivera plan",
"plan.createShopping": "Skapa inköpslista",
"wte.coverage": "{pct} % hemma",
"wte.searchPlaceholder": "Sök recept, t.ex. pannkaka…",
"wte.recommended": "Rekommenderat",
"wte.topPick": "Toppval",
"wte.didYouMean": "Menade du {suggestion}?",
"wte.emptySearch": "Inga recept matchar din sökning.",
"wte.emptyHoliday": "Inga recept för den här högtiden än.",
"cat.baking": "Baka",
"wte.cravingPlaceholder": "Jag är sugen på … (t.ex. krämigt, asiatiskt, under 500 kcal)",
"wte.empty": "Inga förslag ännu lägg in lite mat i lagret eller lätta på filtren.",
"wte.mealBoxFirst": "Färdig mat hemma",