feat(i18n): översätt kvarvarande vyer + härda vakten (audit-backlog)
- API: översatt title/displayName/toName läggs nu bredvid Sv-fälten i favoriter,
receptvarianter, /scaled, skapar-profiler, topplistor, memory-impact,
substitutions — samt de denormaliserade titlarna (min dag, historik, matlådor,
matlåde-förslag, veckoplan) via nullable recipeId med svensk fallback.
10 endpoints, batchade resolvers (ett anrop per endpoint).
- Mobil: konsumerar de nya fälten med ?? Sv-fallback (swap-meal, sparade recept,
varianter, min dag, logg, matlådor, veckoplan, inköpslista). Hårdkodade
strängar -> t() (kitchen, scan-review, register, recept protein/Skapat av);
decimalkomma -> Intl.NumberFormat. Allergen-etiketter -> t() (+5 nya nycklar).
11 nya nycklar i alla 12 språk.
- i18n-vakt härdad: fångar nu hårdkodad svenska i prop={`...`}-mallliteraler
(blind fläck förr). Verifierat att den fäller men inte ger falska positiv.
- whySv var redan lokaliserad (buildWhy med språktagg) - orörd.
- typecheck grönt (alla paket), vakt grön (603 nycklar).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
} from "@app/recommendation-engine";
|
||||
import { DEFAULT_TARGETS, computeDailyTargets, summarizeDay } from "@app/nutrition-engine";
|
||||
import { getActiveHouseholdId, todayIso } from "./helpers.js";
|
||||
import { resolveRecipeTitles, userLanguageTag } from "./contentLanguage.js";
|
||||
|
||||
export interface MemoryImpactOptions {
|
||||
db: Database;
|
||||
@@ -41,6 +42,7 @@ export interface MemoryImpactResult {
|
||||
impacted: Array<{
|
||||
recipeId: string;
|
||||
titleSv: string;
|
||||
title: string;
|
||||
scoreWith: number;
|
||||
scoreWithout: number;
|
||||
delta: number;
|
||||
@@ -433,6 +435,14 @@ export async function computeMemoryImpact(
|
||||
};
|
||||
const withoutMemory = rankAll(scoredCandidates, withoutCtx, weights, limit * 2);
|
||||
|
||||
// Titlar på användarens språk (svensk källa = fallback).
|
||||
const languageTag = await userLanguageTag(db, userId);
|
||||
const titleByRecipe = await resolveRecipeTitles(
|
||||
db,
|
||||
withMemory.map((r) => r.recipeId),
|
||||
languageTag,
|
||||
);
|
||||
|
||||
const withoutById = new Map(withoutMemory.map((r) => [r.recipeId, r]));
|
||||
const impacted: MemoryImpactResult["impacted"] = [];
|
||||
|
||||
@@ -444,6 +454,7 @@ export async function computeMemoryImpact(
|
||||
impacted.push({
|
||||
recipeId: rec.recipeId,
|
||||
titleSv: rec.titleSv,
|
||||
title: titleByRecipe.get(rec.recipeId) ?? rec.titleSv,
|
||||
scoreWith: rec.score,
|
||||
scoreWithout: without.score,
|
||||
delta,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { schema } from "@app/database";
|
||||
import { idParamSchema } from "@app/validation";
|
||||
import { errors, parse } from "../lib/errors.js";
|
||||
import { resolveRecipeTitles } from "../lib/contentLanguage.js";
|
||||
import { loadLocalePreferences } from "../lib/localeContext.js";
|
||||
|
||||
/**
|
||||
* Community & creators (spec §35–38).
|
||||
@@ -42,6 +44,13 @@ export async function communityRoutes(app: FastifyInstance) {
|
||||
.orderBy(desc(schema.recipes.cookCount))
|
||||
.limit(30);
|
||||
|
||||
const languageTag = (await loadLocalePreferences(app.db, req.userId)).languageTag;
|
||||
const titleMap = await resolveRecipeTitles(
|
||||
app.db,
|
||||
recipes.map((r) => r.id),
|
||||
languageTag,
|
||||
);
|
||||
|
||||
return {
|
||||
userId: id,
|
||||
displayName: user.displayName,
|
||||
@@ -51,7 +60,7 @@ export async function communityRoutes(app: FastifyInstance) {
|
||||
totalCooks: stats?.totalCooks ?? 0,
|
||||
averageRating: stats?.averageRating ?? null,
|
||||
badges: stats?.badges ?? [],
|
||||
recipes,
|
||||
recipes: recipes.map((r) => ({ ...r, title: titleMap.get(r.id) ?? r.titleSv })),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -97,8 +106,9 @@ export async function communityRoutes(app: FastifyInstance) {
|
||||
const kind = (req.params as { kind: string }).kind;
|
||||
const MIN_RATINGS = 5;
|
||||
|
||||
let rows: Array<{ id: string; titleSv: string; value: number | null }>;
|
||||
if (kind === "most-cooked") {
|
||||
const rows = await app.db
|
||||
rows = await app.db
|
||||
.select({
|
||||
id: schema.recipes.id,
|
||||
titleSv: schema.recipes.titleSv,
|
||||
@@ -108,10 +118,8 @@ export async function communityRoutes(app: FastifyInstance) {
|
||||
.where(eq(schema.recipes.status, "published"))
|
||||
.orderBy(desc(schema.recipes.cookCount))
|
||||
.limit(20);
|
||||
return { enabled: true, kind, entries: rows };
|
||||
}
|
||||
if (kind === "top-rated") {
|
||||
const rows = await app.db
|
||||
} else if (kind === "top-rated") {
|
||||
rows = await app.db
|
||||
.select({
|
||||
id: schema.recipes.id,
|
||||
titleSv: schema.recipes.titleSv,
|
||||
@@ -126,10 +134,8 @@ export async function communityRoutes(app: FastifyInstance) {
|
||||
)
|
||||
.orderBy(desc(schema.recipes.ratingAverage))
|
||||
.limit(20);
|
||||
return { enabled: true, kind, entries: rows };
|
||||
}
|
||||
if (kind === "budget") {
|
||||
const rows = await app.db
|
||||
} else if (kind === "budget") {
|
||||
rows = await app.db
|
||||
.select({
|
||||
id: schema.recipes.id,
|
||||
titleSv: schema.recipes.titleSv,
|
||||
@@ -144,10 +150,8 @@ export async function communityRoutes(app: FastifyInstance) {
|
||||
)
|
||||
.orderBy(schema.recipes.estimatedCostMinorPerPortion)
|
||||
.limit(20);
|
||||
return { enabled: true, kind, entries: rows };
|
||||
}
|
||||
if (kind === "protein") {
|
||||
const rows = await app.db
|
||||
} else if (kind === "protein") {
|
||||
rows = await app.db
|
||||
.select({
|
||||
id: schema.recipes.id,
|
||||
titleSv: schema.recipes.titleSv,
|
||||
@@ -157,9 +161,21 @@ export async function communityRoutes(app: FastifyInstance) {
|
||||
.where(eq(schema.recipes.status, "published"))
|
||||
.orderBy(desc(sql`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float`))
|
||||
.limit(20);
|
||||
return { enabled: true, kind, entries: rows };
|
||||
} else {
|
||||
// Spec §38: ingen ranking på vikt, viktnedgång, kalorier eller BMI.
|
||||
throw errors.badRequest("Okänd rankingtyp.");
|
||||
}
|
||||
// Spec §38: ingen ranking på vikt, viktnedgång, kalorier eller BMI.
|
||||
throw errors.badRequest("Okänd rankingtyp.");
|
||||
|
||||
const languageTag = (await loadLocalePreferences(app.db, req.userId)).languageTag;
|
||||
const titleMap = await resolveRecipeTitles(
|
||||
app.db,
|
||||
rows.map((r) => r.id),
|
||||
languageTag,
|
||||
);
|
||||
return {
|
||||
enabled: true,
|
||||
kind,
|
||||
entries: rows.map((r) => ({ ...r, title: titleMap.get(r.id) ?? r.titleSv })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
import { EMPTY_NUTRITION, type NutritionValues } from "@app/shared-types";
|
||||
import { errors, parse } from "../lib/errors.js";
|
||||
import { emitEvent, requireActiveHousehold, requireMembership, todayIso } from "../lib/helpers.js";
|
||||
import { resolveRecipeTitles } from "../lib/contentLanguage.js";
|
||||
import { loadLocalePreferences } from "../lib/localeContext.js";
|
||||
|
||||
/**
|
||||
* Måltidsloggning + "Min dag" (spec §4.3, §23) och matlådor (spec §24).
|
||||
@@ -169,9 +171,18 @@ export async function mealRoutes(app: FastifyInstance) {
|
||||
);
|
||||
|
||||
const hasEstimates = meals.some((m) => m.nutritionIsEstimate);
|
||||
const languageTag = (await loadLocalePreferences(app.db, req.userId)).languageTag;
|
||||
const titleMap = await resolveRecipeTitles(
|
||||
app.db,
|
||||
meals.map((m) => m.recipeId).filter((id): id is string => id != null),
|
||||
languageTag,
|
||||
);
|
||||
return {
|
||||
date,
|
||||
meals,
|
||||
meals: meals.map((m) => ({
|
||||
...m,
|
||||
title: m.recipeId ? (titleMap.get(m.recipeId) ?? m.titleSv) : m.titleSv,
|
||||
})),
|
||||
summary,
|
||||
note: hasEstimates
|
||||
? "Dagen innehåller uppskattade värden från foto – justera gärna vid behov."
|
||||
@@ -207,7 +218,18 @@ export async function mealRoutes(app: FastifyInstance) {
|
||||
),
|
||||
)
|
||||
.orderBy(schema.mealBoxes.recommendedUseBy);
|
||||
return { mealBoxes: boxes };
|
||||
const languageTag = (await loadLocalePreferences(app.db, req.userId)).languageTag;
|
||||
const titleMap = await resolveRecipeTitles(
|
||||
app.db,
|
||||
boxes.map((b) => b.recipeId).filter((id): id is string => id != null),
|
||||
languageTag,
|
||||
);
|
||||
return {
|
||||
mealBoxes: boxes.map((b) => ({
|
||||
...b,
|
||||
title: b.recipeId ? (titleMap.get(b.recipeId) ?? b.titleSv) : b.titleSv,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
app.post("/v1/meal-boxes", auth, async (req, reply) => {
|
||||
@@ -329,6 +351,17 @@ export async function mealRoutes(app: FastifyInstance) {
|
||||
.where(eq(schema.meals.userId, req.userId))
|
||||
.orderBy(desc(schema.meals.loggedAt))
|
||||
.limit(20);
|
||||
return { meals };
|
||||
const languageTag = (await loadLocalePreferences(app.db, req.userId)).languageTag;
|
||||
const titleMap = await resolveRecipeTitles(
|
||||
app.db,
|
||||
meals.map((m) => m.recipeId).filter((id): id is string => id != null),
|
||||
languageTag,
|
||||
);
|
||||
return {
|
||||
meals: meals.map((m) => ({
|
||||
...m,
|
||||
title: m.recipeId ? (titleMap.get(m.recipeId) ?? m.titleSv) : m.titleSv,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { errors, parse } from "../lib/errors.js";
|
||||
import { emitEvent, requireActiveHousehold, requireMembership } from "../lib/helpers.js";
|
||||
import { requireFeature } from "../lib/entitlements.js";
|
||||
import { resolveRecipeTitles } from "../lib/contentLanguage.js";
|
||||
import { loadLocalePreferences } from "../lib/localeContext.js";
|
||||
|
||||
/**
|
||||
* Veckoplanering (spec §25). Planen genereras asynkront av workern
|
||||
@@ -33,15 +35,30 @@ export async function planningRoutes(app: FastifyInstance) {
|
||||
.orderBy(desc(schema.weekPlans.weekStartDate), desc(schema.weekPlans.createdAt))
|
||||
.limit(8);
|
||||
|
||||
const result = [];
|
||||
const plansWithEntries = [];
|
||||
for (const plan of plans) {
|
||||
const entries = await app.db
|
||||
.select()
|
||||
.from(schema.weekPlanEntries)
|
||||
.where(eq(schema.weekPlanEntries.weekPlanId, plan.id))
|
||||
.orderBy(schema.weekPlanEntries.date, schema.weekPlanEntries.sortOrder);
|
||||
result.push({ ...plan, entries });
|
||||
plansWithEntries.push({ plan, entries });
|
||||
}
|
||||
const languageTag = (await loadLocalePreferences(app.db, req.userId)).languageTag;
|
||||
const titleMap = await resolveRecipeTitles(
|
||||
app.db,
|
||||
plansWithEntries.flatMap((p) =>
|
||||
p.entries.map((e) => e.recipeId).filter((id): id is string => id != null),
|
||||
),
|
||||
languageTag,
|
||||
);
|
||||
const result = plansWithEntries.map(({ plan, entries }) => ({
|
||||
...plan,
|
||||
entries: entries.map((e) => ({
|
||||
...e,
|
||||
title: e.recipeId ? (titleMap.get(e.recipeId) ?? e.titleSv) : e.titleSv,
|
||||
})),
|
||||
}));
|
||||
return { plans: result };
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { loadLocalePreferences } from "../lib/localeContext.js";
|
||||
import {
|
||||
languageCandidates,
|
||||
resolveIngredientNames,
|
||||
resolveRecipeTitles,
|
||||
resolveRecipeTranslation,
|
||||
userLanguageTag,
|
||||
} from "../lib/contentLanguage.js";
|
||||
@@ -255,6 +256,12 @@ export async function recipeRoutes(app: FastifyInstance) {
|
||||
recipe.ingredients.map((i) => i.canonicalIngredientId),
|
||||
languageTag,
|
||||
);
|
||||
// Varianttitlar på användarens språk (svensk källa = fallback).
|
||||
const variantTitles = await resolveRecipeTitles(
|
||||
app.db,
|
||||
variants.map((v) => v.id),
|
||||
languageTag,
|
||||
);
|
||||
|
||||
return {
|
||||
...recipe,
|
||||
@@ -272,7 +279,7 @@ export async function recipeRoutes(app: FastifyInstance) {
|
||||
return { ...s, instruction: ts?.instruction ?? s.instructionSv, tip: ts?.tip ?? s.tip };
|
||||
}),
|
||||
safety,
|
||||
variants,
|
||||
variants: variants.map((v) => ({ ...v, title: variantTitles.get(v.id) ?? v.titleSv })),
|
||||
coverage: coverage && {
|
||||
...coverage,
|
||||
missing: coverage.missing.map((m) => ({
|
||||
@@ -304,10 +311,19 @@ export async function recipeRoutes(app: FastifyInstance) {
|
||||
recipe.portions,
|
||||
portions,
|
||||
);
|
||||
const languageTag = (await loadLocalePreferences(app.db, req.userId)).languageTag;
|
||||
const ingredientNames = await resolveIngredientNames(
|
||||
app.db,
|
||||
scaled.map((i) => i.canonicalIngredientId),
|
||||
languageTag,
|
||||
);
|
||||
return {
|
||||
recipeId: id,
|
||||
portions,
|
||||
ingredients: scaled,
|
||||
ingredients: scaled.map((i) => ({
|
||||
...i,
|
||||
displayName: ingredientNames.get(i.canonicalIngredientId) ?? i.displayNameSv,
|
||||
})),
|
||||
nutritionPerPortion: recipe.nutritionPerPortion,
|
||||
};
|
||||
});
|
||||
@@ -430,7 +446,13 @@ export async function recipeRoutes(app: FastifyInstance) {
|
||||
.innerJoin(schema.recipes, eq(schema.recipeFavorites.recipeId, schema.recipes.id))
|
||||
.where(eq(schema.recipeFavorites.userId, req.userId))
|
||||
.orderBy(desc(schema.recipeFavorites.createdAt));
|
||||
return { recipes: rows };
|
||||
const languageTag = (await loadLocalePreferences(app.db, req.userId)).languageTag;
|
||||
const titleMap = await resolveRecipeTitles(
|
||||
app.db,
|
||||
rows.map((r) => r.id),
|
||||
languageTag,
|
||||
);
|
||||
return { recipes: rows.map((r) => ({ ...r, title: titleMap.get(r.id) ?? r.titleSv })) };
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -486,10 +508,17 @@ export async function recipeRoutes(app: FastifyInstance) {
|
||||
)
|
||||
.where(eq(schema.substitutions.fromIngredientId, q.fromIngredientId))
|
||||
.orderBy(desc(schema.substitutions.priority));
|
||||
const languageTag = (await loadLocalePreferences(app.db, req.userId)).languageTag;
|
||||
const toNames = await resolveIngredientNames(
|
||||
app.db,
|
||||
subs.map((s) => s.sub.toIngredientId),
|
||||
languageTag,
|
||||
);
|
||||
return {
|
||||
substitutions: subs.map((s) => ({
|
||||
...s.sub,
|
||||
toNameSv: s.toName,
|
||||
toName: toNames.get(s.sub.toIngredientId) ?? s.toName,
|
||||
contextWarning:
|
||||
q.context && s.sub.notRecommendedFor.includes(q.context)
|
||||
? `Rekommenderas inte för ${q.context}.`
|
||||
|
||||
@@ -579,9 +579,15 @@ export async function recommendationRoutes(app: FastifyInstance) {
|
||||
.orderBy(schema.mealBoxes.recommendedUseBy)
|
||||
.limit(5)
|
||||
: [];
|
||||
const boxTitleByRecipe = await resolveRecipeTitles(
|
||||
app.db,
|
||||
mealBoxes.map((b) => b.recipeId).filter((id): id is string => id != null),
|
||||
localePrefs.languageTag,
|
||||
);
|
||||
const mealBoxSuggestions = mealBoxes.map((box) => ({
|
||||
mealBoxId: box.id,
|
||||
titleSv: box.titleSv,
|
||||
title: box.recipeId ? (boxTitleByRecipe.get(box.recipeId) ?? box.titleSv) : box.titleSv,
|
||||
portionsRemaining: box.portionsRemaining,
|
||||
recommendedUseBy: box.recommendedUseBy,
|
||||
useByUrgent: Date.parse(box.recommendedUseBy) <= today.getTime() + 2 * 86_400_000,
|
||||
|
||||
Reference in New Issue
Block a user