i18n(rekommendationer): localisera 💡-raden + meal-box-text (12 språk)

- 💡 why-texten: what-to-eat skickar nu användarens languageTag till motorn;
  scoring använder buildWhy(..., ctx.languageTag) i stället för hårdkodat buildWhySv.
  WHY_TEMPLATES har redan alla 12 språk – felet var bara att sv-SE var fast.
  Default sv-SE = oförändrat beteende (säkert). (Rest: utgår-ingrediensens NAMN
  i meningen är fortf. svenskt – motorn har bara svenska namn; noterat.)
- meal-box-förslagens text: whySv-sträng -> useByUrgent-flagga + mealbox.eatBy/ready
  i alla 12 språk (klienten renderar via t()).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-08-21 02:32:31 +00:00
parent 6d36ec7459
commit 2076bbc15b
16 changed files with 40 additions and 16 deletions
+4 -11
View File
@@ -433,6 +433,7 @@ export async function recommendationRoutes(app: FastifyInstance) {
cravingCuisine: craving?.cuisine,
cravingMaxKcal: craving?.maxKcal,
personalizationEnabled,
languageTag: localePrefs.languageTag,
memoryItems: personalizationEnabled ? memoryItems : undefined,
tasteSignals: personalizationEnabled ? tasteSignals : undefined,
cookingAssumptions: personalizationEnabled ? cookingAssumptions : undefined,
@@ -583,20 +584,14 @@ export async function recommendationRoutes(app: FastifyInstance) {
titleSv: box.titleSv,
portionsRemaining: box.portionsRemaining,
recommendedUseBy: box.recommendedUseBy,
whySv:
Date.parse(box.recommendedUseBy) <= today.getTime() + 2 * 86_400_000
? `Matlådan bör ätas senast ${box.recommendedUseBy}. Noll matlagning, noll svinn.`
: "Färdig mat som väntar snabbaste middagen i huset.",
useByUrgent: Date.parse(box.recommendedUseBy) <= today.getTime() + 2 * 86_400_000,
}));
// Betyg (stjärnor) finns på kandidaterna men följer INTE med i
// ScoredRecommendation som rankAll returnerar. Återkoppla dem per recept så
// mobilen kan visa "★ 4,5 · 12" i listan (annars syns aldrig satta betyg).
const ratingByRecipe = new Map(
scoredCandidates.map((c) => [
c.recipeId,
{ average: c.ratingAverage, count: c.ratingCount },
]),
scoredCandidates.map((c) => [c.recipeId, { average: c.ratingAverage, count: c.ratingCount }]),
);
// "Menade du …?" bara när en sökning gav noll träffar (grov felstavning).
@@ -614,9 +609,7 @@ export async function recommendationRoutes(app: FastifyInstance) {
);
const localizedById = await resolveIngredientNames(
app.db,
allIngredients
.map((i) => i.canonicalIngredientId)
.filter((id): id is string => id != null),
allIngredients.map((i) => i.canonicalIngredientId).filter((id): id is string => id != null),
languageTag,
);
const svToLocalized = new Map<string, string>();
+6 -2
View File
@@ -43,7 +43,7 @@ interface MealBoxSuggestion {
titleSv: string;
portionsRemaining: number;
recommendedUseBy: string;
whySv: string;
useByUrgent: boolean;
}
interface WhatToEatResponse {
recommendations: Recommendation[];
@@ -227,7 +227,11 @@ export default function WhatToEatScreen() {
tone="success"
/>
</Row>
<Small>{box.whySv}</Small>
<Small>
{t(box.useByUrgent ? "mealbox.eatBy" : "mealbox.ready", {
date: box.recommendedUseBy,
})}
</Small>
</Card>
))}
<Spacer size={spacing.sm} />
+2
View File
@@ -51,6 +51,8 @@
"common.add": "Tilføj",
"common.back": "Tilbage",
"common.cancel": "Annuller",
"mealbox.eatBy": "Spis denne madkasse senest den {date}. Nul madlavning, nul spild.",
"mealbox.ready": "Færdig mad der venter husets hurtigste aftensmad.",
"common.remove": "Fjern",
"common.all": "Alle",
"auth.tagline": "Mindre spild, mindre stress, mere tilbage i pungen",
+2
View File
@@ -51,6 +51,8 @@
"common.add": "Hinzufügen",
"common.back": "Zurück",
"common.cancel": "Abbrechen",
"mealbox.eatBy": "Iss diese Meal-Box spätestens am {date}. Kein Kochen, keine Verschwendung.",
"mealbox.ready": "Fertiges Essen wartet das schnellste Abendessen im Haus.",
"common.remove": "Entfernen",
"common.all": "Alle",
"auth.tagline": "Weniger Verschwendung, weniger Stress, mehr im Geldbeutel",
+2
View File
@@ -51,6 +51,8 @@
"common.add": "Add",
"common.back": "Back",
"common.cancel": "Cancel",
"mealbox.eatBy": "Eat this meal box by {date} at the latest. Zero cooking, zero waste.",
"mealbox.ready": "Ready-made food waiting the fastest dinner in the house.",
"common.remove": "Remove",
"common.all": "All",
"auth.tagline": "Less waste, less stress, more left in your wallet",
+2
View File
@@ -51,6 +51,8 @@
"common.add": "Añadir",
"common.back": "Atrás",
"common.cancel": "Cancelar",
"mealbox.eatBy": "Cómete esta tarrina como muy tarde el {date}. Cero cocina, cero desperdicio.",
"mealbox.ready": "Comida lista esperando: la cena más rápida de casa.",
"common.remove": "Eliminar",
"common.all": "Todos",
"auth.tagline": "Menos desperdicio, menos estrés, más dinero en el bolsillo",
+2
View File
@@ -51,6 +51,8 @@
"common.add": "Lisää",
"common.back": "Takaisin",
"common.cancel": "Peruuta",
"mealbox.eatBy": "Syö tämä ateriarasia viimeistään {date}. Ei ruoanlaittoa, ei hävikkiä.",
"mealbox.ready": "Valmista ruokaa odottamassa talon nopein illallinen.",
"common.remove": "Poista",
"common.all": "Kaikki",
"auth.tagline": "Vähemmän hävikkiä, vähemmän stressiä, enemmän jää lompakkoon",
+2
View File
@@ -51,6 +51,8 @@
"common.add": "Ajouter",
"common.back": "Retour",
"common.cancel": "Annuler",
"mealbox.eatBy": "À manger avant le {date}. Zéro cuisine, zéro gaspillage.",
"mealbox.ready": "Un plat tout prêt qui attend le dîner le plus rapide de la maison.",
"common.remove": "Supprimer",
"common.all": "Tous",
"auth.tagline": "Moins de gaspillage, moins de stress, plus d'argent dans votre portefeuille",
+2
View File
@@ -51,6 +51,8 @@
"common.add": "Aggiungi",
"common.back": "Indietro",
"common.cancel": "Annulla",
"mealbox.eatBy": "Consuma questo pasto entro il {date}. Zero cucina, zero sprechi.",
"mealbox.ready": "Cibo pronto in attesa: la cena più veloce di casa.",
"common.remove": "Rimuovi",
"common.all": "Tutti",
"auth.tagline": "Meno sprechi, meno stress, più soldi in tasca",
+2
View File
@@ -51,6 +51,8 @@
"common.add": "Legg til",
"common.back": "Tilbake",
"common.cancel": "Avbryt",
"mealbox.eatBy": "Spis denne matboksen innen {date}. Null matlaging, null svinn.",
"mealbox.ready": "Ferdig mat som venter husets raskeste middag.",
"common.remove": "Fjern",
"common.all": "Alle",
"auth.tagline": "Mindre svinn, mindre stress, mer igjen i lommeboka",
+2
View File
@@ -51,6 +51,8 @@
"common.add": "Toevoegen",
"common.back": "Terug",
"common.cancel": "Annuleren",
"mealbox.eatBy": "Eet deze maaltijdbox uiterlijk op {date}. Geen kookwerk, geen verspilling.",
"mealbox.ready": "Kant-en-klaar eten dat wacht het snelste avondeten in huis.",
"common.remove": "Verwijderen",
"common.all": "Alle",
"auth.tagline": "Minder verspilling, minder stress, meer over in je portemonnee",
+2
View File
@@ -51,6 +51,8 @@
"common.add": "Dodaj",
"common.back": "Wstecz",
"common.cancel": "Anuluj",
"mealbox.eatBy": "Zjedz ten pojemnik najpóźniej {date}. Zero gotowania, zero marnowania.",
"mealbox.ready": "Gotowe jedzenie czeka najszybsza kolacja w domu.",
"common.remove": "Usuń",
"common.all": "Wszystkie",
"auth.tagline": "Mniej marnowania, mniej stresu, więcej w portfelu",
+2
View File
@@ -51,6 +51,8 @@
"common.add": "Adicionar",
"common.back": "Voltar",
"common.cancel": "Cancelar",
"mealbox.eatBy": "Come esta refeição até {date}, o mais tardar. Zero cozinha, zero desperdício.",
"mealbox.ready": "Comida pronta à espera o jantar mais rápido cá de casa.",
"common.remove": "Remover",
"common.all": "Todos",
"auth.tagline": "Menos desperdício, menos stress, mais dinheiro na carteira",
+2
View File
@@ -51,6 +51,8 @@
"common.add": "Lägg till",
"common.back": "Tillbaka",
"common.cancel": "Avbryt",
"mealbox.eatBy": "Matlådan bör ätas senast {date}. Noll matlagning, noll svinn.",
"mealbox.ready": "Färdig mat som väntar snabbaste middagen i huset.",
"common.remove": "Ta bort",
"common.all": "Alla",
"auth.tagline": "Mindre svinn, mindre stress, mer kvar i plånboken",
@@ -8,7 +8,7 @@ import type {
ScoringWeights,
} from "./types.js";
import { DEFAULT_WEIGHTS } from "./types.js";
import { buildWhySv } from "./explain.js";
import { buildWhy } from "./explain.js";
/**
* Poängsätt en kandidat (0100-skala per delkomponent, viktad summa).
@@ -111,7 +111,8 @@ export function scoreCandidate(
// 16. Måltids-lämplighet: nedvikta lätta övergångsrätter (frukost/dessert) vid huvudmåltid.
const mainMeal = ctx.mealType === "dinner" || ctx.mealType === "lunch";
const crossover =
(candidate.mealTypes ?? []).includes("breakfast") || (candidate.mealTypes ?? []).includes("dessert");
(candidate.mealTypes ?? []).includes("breakfast") ||
(candidate.mealTypes ?? []).includes("dessert");
parts.mealFit = mainMeal && crossover ? 0.15 : 1;
// 17. Diet-affinitet (spec §diet): mjuk rankning mot matvanan. Aldrig ett hårt
@@ -125,7 +126,7 @@ export function scoreCandidate(
titleSv: candidate.titleSv,
score: Math.round(score * 10) / 10,
parts,
whySv: buildWhySv(candidate, ctx, parts, provenance),
whySv: buildWhy(candidate, ctx, parts, provenance, ctx.languageTag ?? "sv-SE"),
missingIngredients: candidate.coverage.missing
.filter((m) => !m.optional)
.map((m) => m.displayNameSv),
@@ -82,6 +82,8 @@ export interface RecommendationContext {
cookingAssumptions?: CookingAssumption[];
/** Hård grind: false = ignorera alla personliga signaler. */
personalizationEnabled: boolean;
/** Användarens språk (BCP 47) för why-texten. Default sv-SE. */
languageTag?: string | undefined;
}
export interface ScoredRecommendation {