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,
})),