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";
|
} from "@app/recommendation-engine";
|
||||||
import { DEFAULT_TARGETS, computeDailyTargets, summarizeDay } from "@app/nutrition-engine";
|
import { DEFAULT_TARGETS, computeDailyTargets, summarizeDay } from "@app/nutrition-engine";
|
||||||
import { getActiveHouseholdId, todayIso } from "./helpers.js";
|
import { getActiveHouseholdId, todayIso } from "./helpers.js";
|
||||||
|
import { resolveRecipeTitles, userLanguageTag } from "./contentLanguage.js";
|
||||||
|
|
||||||
export interface MemoryImpactOptions {
|
export interface MemoryImpactOptions {
|
||||||
db: Database;
|
db: Database;
|
||||||
@@ -41,6 +42,7 @@ export interface MemoryImpactResult {
|
|||||||
impacted: Array<{
|
impacted: Array<{
|
||||||
recipeId: string;
|
recipeId: string;
|
||||||
titleSv: string;
|
titleSv: string;
|
||||||
|
title: string;
|
||||||
scoreWith: number;
|
scoreWith: number;
|
||||||
scoreWithout: number;
|
scoreWithout: number;
|
||||||
delta: number;
|
delta: number;
|
||||||
@@ -433,6 +435,14 @@ export async function computeMemoryImpact(
|
|||||||
};
|
};
|
||||||
const withoutMemory = rankAll(scoredCandidates, withoutCtx, weights, limit * 2);
|
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 withoutById = new Map(withoutMemory.map((r) => [r.recipeId, r]));
|
||||||
const impacted: MemoryImpactResult["impacted"] = [];
|
const impacted: MemoryImpactResult["impacted"] = [];
|
||||||
|
|
||||||
@@ -444,6 +454,7 @@ export async function computeMemoryImpact(
|
|||||||
impacted.push({
|
impacted.push({
|
||||||
recipeId: rec.recipeId,
|
recipeId: rec.recipeId,
|
||||||
titleSv: rec.titleSv,
|
titleSv: rec.titleSv,
|
||||||
|
title: titleByRecipe.get(rec.recipeId) ?? rec.titleSv,
|
||||||
scoreWith: rec.score,
|
scoreWith: rec.score,
|
||||||
scoreWithout: without.score,
|
scoreWithout: without.score,
|
||||||
delta,
|
delta,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { and, desc, eq, sql } from "drizzle-orm";
|
|||||||
import { schema } from "@app/database";
|
import { schema } from "@app/database";
|
||||||
import { idParamSchema } from "@app/validation";
|
import { idParamSchema } from "@app/validation";
|
||||||
import { errors, parse } from "../lib/errors.js";
|
import { errors, parse } from "../lib/errors.js";
|
||||||
|
import { resolveRecipeTitles } from "../lib/contentLanguage.js";
|
||||||
|
import { loadLocalePreferences } from "../lib/localeContext.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Community & creators (spec §35–38).
|
* Community & creators (spec §35–38).
|
||||||
@@ -42,6 +44,13 @@ export async function communityRoutes(app: FastifyInstance) {
|
|||||||
.orderBy(desc(schema.recipes.cookCount))
|
.orderBy(desc(schema.recipes.cookCount))
|
||||||
.limit(30);
|
.limit(30);
|
||||||
|
|
||||||
|
const languageTag = (await loadLocalePreferences(app.db, req.userId)).languageTag;
|
||||||
|
const titleMap = await resolveRecipeTitles(
|
||||||
|
app.db,
|
||||||
|
recipes.map((r) => r.id),
|
||||||
|
languageTag,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: id,
|
userId: id,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
@@ -51,7 +60,7 @@ export async function communityRoutes(app: FastifyInstance) {
|
|||||||
totalCooks: stats?.totalCooks ?? 0,
|
totalCooks: stats?.totalCooks ?? 0,
|
||||||
averageRating: stats?.averageRating ?? null,
|
averageRating: stats?.averageRating ?? null,
|
||||||
badges: stats?.badges ?? [],
|
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 kind = (req.params as { kind: string }).kind;
|
||||||
const MIN_RATINGS = 5;
|
const MIN_RATINGS = 5;
|
||||||
|
|
||||||
|
let rows: Array<{ id: string; titleSv: string; value: number | null }>;
|
||||||
if (kind === "most-cooked") {
|
if (kind === "most-cooked") {
|
||||||
const rows = await app.db
|
rows = await app.db
|
||||||
.select({
|
.select({
|
||||||
id: schema.recipes.id,
|
id: schema.recipes.id,
|
||||||
titleSv: schema.recipes.titleSv,
|
titleSv: schema.recipes.titleSv,
|
||||||
@@ -108,10 +118,8 @@ export async function communityRoutes(app: FastifyInstance) {
|
|||||||
.where(eq(schema.recipes.status, "published"))
|
.where(eq(schema.recipes.status, "published"))
|
||||||
.orderBy(desc(schema.recipes.cookCount))
|
.orderBy(desc(schema.recipes.cookCount))
|
||||||
.limit(20);
|
.limit(20);
|
||||||
return { enabled: true, kind, entries: rows };
|
} else if (kind === "top-rated") {
|
||||||
}
|
rows = await app.db
|
||||||
if (kind === "top-rated") {
|
|
||||||
const rows = await app.db
|
|
||||||
.select({
|
.select({
|
||||||
id: schema.recipes.id,
|
id: schema.recipes.id,
|
||||||
titleSv: schema.recipes.titleSv,
|
titleSv: schema.recipes.titleSv,
|
||||||
@@ -126,10 +134,8 @@ export async function communityRoutes(app: FastifyInstance) {
|
|||||||
)
|
)
|
||||||
.orderBy(desc(schema.recipes.ratingAverage))
|
.orderBy(desc(schema.recipes.ratingAverage))
|
||||||
.limit(20);
|
.limit(20);
|
||||||
return { enabled: true, kind, entries: rows };
|
} else if (kind === "budget") {
|
||||||
}
|
rows = await app.db
|
||||||
if (kind === "budget") {
|
|
||||||
const rows = await app.db
|
|
||||||
.select({
|
.select({
|
||||||
id: schema.recipes.id,
|
id: schema.recipes.id,
|
||||||
titleSv: schema.recipes.titleSv,
|
titleSv: schema.recipes.titleSv,
|
||||||
@@ -144,10 +150,8 @@ export async function communityRoutes(app: FastifyInstance) {
|
|||||||
)
|
)
|
||||||
.orderBy(schema.recipes.estimatedCostMinorPerPortion)
|
.orderBy(schema.recipes.estimatedCostMinorPerPortion)
|
||||||
.limit(20);
|
.limit(20);
|
||||||
return { enabled: true, kind, entries: rows };
|
} else if (kind === "protein") {
|
||||||
}
|
rows = await app.db
|
||||||
if (kind === "protein") {
|
|
||||||
const rows = await app.db
|
|
||||||
.select({
|
.select({
|
||||||
id: schema.recipes.id,
|
id: schema.recipes.id,
|
||||||
titleSv: schema.recipes.titleSv,
|
titleSv: schema.recipes.titleSv,
|
||||||
@@ -157,9 +161,21 @@ export async function communityRoutes(app: FastifyInstance) {
|
|||||||
.where(eq(schema.recipes.status, "published"))
|
.where(eq(schema.recipes.status, "published"))
|
||||||
.orderBy(desc(sql`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float`))
|
.orderBy(desc(sql`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float`))
|
||||||
.limit(20);
|
.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 { EMPTY_NUTRITION, type NutritionValues } from "@app/shared-types";
|
||||||
import { errors, parse } from "../lib/errors.js";
|
import { errors, parse } from "../lib/errors.js";
|
||||||
import { emitEvent, requireActiveHousehold, requireMembership, todayIso } from "../lib/helpers.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).
|
* 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 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 {
|
return {
|
||||||
date,
|
date,
|
||||||
meals,
|
meals: meals.map((m) => ({
|
||||||
|
...m,
|
||||||
|
title: m.recipeId ? (titleMap.get(m.recipeId) ?? m.titleSv) : m.titleSv,
|
||||||
|
})),
|
||||||
summary,
|
summary,
|
||||||
note: hasEstimates
|
note: hasEstimates
|
||||||
? "Dagen innehåller uppskattade värden från foto – justera gärna vid behov."
|
? "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);
|
.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) => {
|
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))
|
.where(eq(schema.meals.userId, req.userId))
|
||||||
.orderBy(desc(schema.meals.loggedAt))
|
.orderBy(desc(schema.meals.loggedAt))
|
||||||
.limit(20);
|
.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 { errors, parse } from "../lib/errors.js";
|
||||||
import { emitEvent, requireActiveHousehold, requireMembership } from "../lib/helpers.js";
|
import { emitEvent, requireActiveHousehold, requireMembership } from "../lib/helpers.js";
|
||||||
import { requireFeature } from "../lib/entitlements.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
|
* 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))
|
.orderBy(desc(schema.weekPlans.weekStartDate), desc(schema.weekPlans.createdAt))
|
||||||
.limit(8);
|
.limit(8);
|
||||||
|
|
||||||
const result = [];
|
const plansWithEntries = [];
|
||||||
for (const plan of plans) {
|
for (const plan of plans) {
|
||||||
const entries = await app.db
|
const entries = await app.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.weekPlanEntries)
|
.from(schema.weekPlanEntries)
|
||||||
.where(eq(schema.weekPlanEntries.weekPlanId, plan.id))
|
.where(eq(schema.weekPlanEntries.weekPlanId, plan.id))
|
||||||
.orderBy(schema.weekPlanEntries.date, schema.weekPlanEntries.sortOrder);
|
.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 };
|
return { plans: result };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { loadLocalePreferences } from "../lib/localeContext.js";
|
|||||||
import {
|
import {
|
||||||
languageCandidates,
|
languageCandidates,
|
||||||
resolveIngredientNames,
|
resolveIngredientNames,
|
||||||
|
resolveRecipeTitles,
|
||||||
resolveRecipeTranslation,
|
resolveRecipeTranslation,
|
||||||
userLanguageTag,
|
userLanguageTag,
|
||||||
} from "../lib/contentLanguage.js";
|
} from "../lib/contentLanguage.js";
|
||||||
@@ -255,6 +256,12 @@ export async function recipeRoutes(app: FastifyInstance) {
|
|||||||
recipe.ingredients.map((i) => i.canonicalIngredientId),
|
recipe.ingredients.map((i) => i.canonicalIngredientId),
|
||||||
languageTag,
|
languageTag,
|
||||||
);
|
);
|
||||||
|
// Varianttitlar på användarens språk (svensk källa = fallback).
|
||||||
|
const variantTitles = await resolveRecipeTitles(
|
||||||
|
app.db,
|
||||||
|
variants.map((v) => v.id),
|
||||||
|
languageTag,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...recipe,
|
...recipe,
|
||||||
@@ -272,7 +279,7 @@ export async function recipeRoutes(app: FastifyInstance) {
|
|||||||
return { ...s, instruction: ts?.instruction ?? s.instructionSv, tip: ts?.tip ?? s.tip };
|
return { ...s, instruction: ts?.instruction ?? s.instructionSv, tip: ts?.tip ?? s.tip };
|
||||||
}),
|
}),
|
||||||
safety,
|
safety,
|
||||||
variants,
|
variants: variants.map((v) => ({ ...v, title: variantTitles.get(v.id) ?? v.titleSv })),
|
||||||
coverage: coverage && {
|
coverage: coverage && {
|
||||||
...coverage,
|
...coverage,
|
||||||
missing: coverage.missing.map((m) => ({
|
missing: coverage.missing.map((m) => ({
|
||||||
@@ -304,10 +311,19 @@ export async function recipeRoutes(app: FastifyInstance) {
|
|||||||
recipe.portions,
|
recipe.portions,
|
||||||
portions,
|
portions,
|
||||||
);
|
);
|
||||||
|
const languageTag = (await loadLocalePreferences(app.db, req.userId)).languageTag;
|
||||||
|
const ingredientNames = await resolveIngredientNames(
|
||||||
|
app.db,
|
||||||
|
scaled.map((i) => i.canonicalIngredientId),
|
||||||
|
languageTag,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
recipeId: id,
|
recipeId: id,
|
||||||
portions,
|
portions,
|
||||||
ingredients: scaled,
|
ingredients: scaled.map((i) => ({
|
||||||
|
...i,
|
||||||
|
displayName: ingredientNames.get(i.canonicalIngredientId) ?? i.displayNameSv,
|
||||||
|
})),
|
||||||
nutritionPerPortion: recipe.nutritionPerPortion,
|
nutritionPerPortion: recipe.nutritionPerPortion,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -430,7 +446,13 @@ export async function recipeRoutes(app: FastifyInstance) {
|
|||||||
.innerJoin(schema.recipes, eq(schema.recipeFavorites.recipeId, schema.recipes.id))
|
.innerJoin(schema.recipes, eq(schema.recipeFavorites.recipeId, schema.recipes.id))
|
||||||
.where(eq(schema.recipeFavorites.userId, req.userId))
|
.where(eq(schema.recipeFavorites.userId, req.userId))
|
||||||
.orderBy(desc(schema.recipeFavorites.createdAt));
|
.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))
|
.where(eq(schema.substitutions.fromIngredientId, q.fromIngredientId))
|
||||||
.orderBy(desc(schema.substitutions.priority));
|
.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 {
|
return {
|
||||||
substitutions: subs.map((s) => ({
|
substitutions: subs.map((s) => ({
|
||||||
...s.sub,
|
...s.sub,
|
||||||
toNameSv: s.toName,
|
toNameSv: s.toName,
|
||||||
|
toName: toNames.get(s.sub.toIngredientId) ?? s.toName,
|
||||||
contextWarning:
|
contextWarning:
|
||||||
q.context && s.sub.notRecommendedFor.includes(q.context)
|
q.context && s.sub.notRecommendedFor.includes(q.context)
|
||||||
? `Rekommenderas inte för ${q.context}.`
|
? `Rekommenderas inte för ${q.context}.`
|
||||||
|
|||||||
@@ -579,9 +579,15 @@ export async function recommendationRoutes(app: FastifyInstance) {
|
|||||||
.orderBy(schema.mealBoxes.recommendedUseBy)
|
.orderBy(schema.mealBoxes.recommendedUseBy)
|
||||||
.limit(5)
|
.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) => ({
|
const mealBoxSuggestions = mealBoxes.map((box) => ({
|
||||||
mealBoxId: box.id,
|
mealBoxId: box.id,
|
||||||
titleSv: box.titleSv,
|
titleSv: box.titleSv,
|
||||||
|
title: box.recipeId ? (boxTitleByRecipe.get(box.recipeId) ?? box.titleSv) : box.titleSv,
|
||||||
portionsRemaining: box.portionsRemaining,
|
portionsRemaining: box.portionsRemaining,
|
||||||
recommendedUseBy: box.recommendedUseBy,
|
recommendedUseBy: box.recommendedUseBy,
|
||||||
useByUrgent: Date.parse(box.recommendedUseBy) <= today.getTime() + 2 * 86_400_000,
|
useByUrgent: Date.parse(box.recommendedUseBy) <= today.getTime() + 2 * 86_400_000,
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ const POS =
|
|||||||
/(?:label|placeholder|title|text|header|message)\s*[=:]\s*(["'`])((?:(?!\1).)*[A-Za-zÅÄÖåäö]{2,}(?:(?!\1).)*)\1/g;
|
/(?:label|placeholder|title|text|header|message)\s*[=:]\s*(["'`])((?:(?!\1).)*[A-Za-zÅÄÖåäö]{2,}(?:(?!\1).)*)\1/g;
|
||||||
const ALERT =
|
const ALERT =
|
||||||
/(?:Alert\.alert|setError|EmptyState\s+text=)\s*\(?\s*(["'`])((?:(?!\1).)*[A-Za-zÅÄÖåäö]{2,}(?:(?!\1).)*)\1/g;
|
/(?:Alert\.alert|setError|EmptyState\s+text=)\s*\(?\s*(["'`])((?:(?!\1).)*[A-Za-zÅÄÖåäö]{2,}(?:(?!\1).)*)\1/g;
|
||||||
|
// prop={`…`} – mall-literal i JSX-UTTRYCK. POS ser inte {…}, så no-diakritisk
|
||||||
|
// svenska (»Skapat av ${x}«) slank förr igenom. Granska den literala resten.
|
||||||
|
const PROP_EXPR =
|
||||||
|
/(?:label|placeholder|title|text|header|message)\s*=\s*\{\s*`((?:[^`\\]|\\.)*)`\s*\}/g;
|
||||||
// JSX-text: >Svensk text< (fångar diakriter i textnoder)
|
// JSX-text: >Svensk text< (fångar diakriter i textnoder)
|
||||||
const JSXTEXT = />\s*([^<>{}\n]*[åäöÅÄÖ][^<>{}\n]*?)\s*</g;
|
const JSXTEXT = />\s*([^<>{}\n]*[åäöÅÄÖ][^<>{}\n]*?)\s*</g;
|
||||||
|
|
||||||
@@ -132,10 +136,20 @@ for (const file of files) {
|
|||||||
let mm;
|
let mm;
|
||||||
while ((mm = re.exec(line))) {
|
while ((mm = re.exec(line))) {
|
||||||
const val = mm[2];
|
const val = mm[2];
|
||||||
if (val.includes("${")) continue; // ren interpolationsdel
|
// Mallsträngar granskas nu på den LITERALA resten (blind fläck förr –
|
||||||
if (!allowed(val)) hits.add(val);
|
// `label={\`Skapat av ${namn}\`}` slank igenom hela). Strippa ${…} och
|
||||||
|
// se om ord blir kvar; bara emoji/tal/enheter kvar = ok.
|
||||||
|
const bare = val.replace(/\$\{[^}]*\}/g, " ");
|
||||||
|
if (!/[A-Za-zÅÄÖåäö]{2,}/.test(bare)) continue;
|
||||||
|
if (!allowed(bare)) hits.add(val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PROP_EXPR.lastIndex = 0;
|
||||||
|
let pm;
|
||||||
|
while ((pm = PROP_EXPR.exec(line))) {
|
||||||
|
const bare = pm[1].replace(/\$\{[^}]*\}/g, " ");
|
||||||
|
if (/[A-Za-zÅÄÖåäö]{2,}/.test(bare) && !allowed(bare)) hits.add(pm[1]);
|
||||||
|
}
|
||||||
JSXTEXT.lastIndex = 0;
|
JSXTEXT.lastIndex = 0;
|
||||||
let jm;
|
let jm;
|
||||||
while ((jm = JSXTEXT.exec(line))) {
|
while ((jm = JSXTEXT.exec(line))) {
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export default function RegisterScreen() {
|
|||||||
onChangeText={setEmail}
|
onChangeText={setEmail}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder={`${t("auth.password")} (minst 8 tecken)`}
|
placeholder={`${t("auth.password")} (${t("auth.passwordHint")})`}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
value={password}
|
value={password}
|
||||||
onChangeText={setPassword}
|
onChangeText={setPassword}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|||||||
import { api, ApiError } from "@/lib/api";
|
import { api, ApiError } from "@/lib/api";
|
||||||
import { useAnalytics } from "@/lib/analytics";
|
import { useAnalytics } from "@/lib/analytics";
|
||||||
import { recommendationsViewed } from "@app/analytics";
|
import { recommendationsViewed } from "@app/analytics";
|
||||||
import { t } from "@/lib/i18n";
|
import { getLanguageTag, t } from "@/lib/i18n";
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Button,
|
Button,
|
||||||
@@ -41,6 +41,7 @@ interface Recommendation {
|
|||||||
interface MealBoxSuggestion {
|
interface MealBoxSuggestion {
|
||||||
mealBoxId: string;
|
mealBoxId: string;
|
||||||
titleSv: string;
|
titleSv: string;
|
||||||
|
title?: string;
|
||||||
portionsRemaining: number;
|
portionsRemaining: number;
|
||||||
recommendedUseBy: string;
|
recommendedUseBy: string;
|
||||||
useByUrgent: boolean;
|
useByUrgent: boolean;
|
||||||
@@ -225,7 +226,7 @@ export default function WhatToEatScreen() {
|
|||||||
{query.data.mealBoxSuggestions.map((box) => (
|
{query.data.mealBoxSuggestions.map((box) => (
|
||||||
<Card key={box.mealBoxId} onPress={() => router.push("/meal-boxes")}>
|
<Card key={box.mealBoxId} onPress={() => router.push("/meal-boxes")}>
|
||||||
<Row style={{ justifyContent: "space-between" }}>
|
<Row style={{ justifyContent: "space-between" }}>
|
||||||
<Body>🍱 {box.titleSv}</Body>
|
<Body>🍱 {box.title ?? box.titleSv}</Body>
|
||||||
<Tag
|
<Tag
|
||||||
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })}
|
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })}
|
||||||
tone="success"
|
tone="success"
|
||||||
@@ -275,7 +276,10 @@ export default function WhatToEatScreen() {
|
|||||||
<Row>
|
<Row>
|
||||||
{rec.ratingCount > 0 && rec.ratingAverage != null && (
|
{rec.ratingCount > 0 && rec.ratingAverage != null && (
|
||||||
<Tag
|
<Tag
|
||||||
label={`★ ${rec.ratingAverage.toFixed(1).replace(".", ",")} · ${rec.ratingCount}`}
|
label={`★ ${new Intl.NumberFormat(getLanguageTag(), {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}).format(rec.ratingAverage)} · ${rec.ratingCount}`}
|
||||||
tone="accent"
|
tone="accent"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface DayResponse {
|
|||||||
meals: Array<{
|
meals: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
titleSv: string;
|
titleSv: string;
|
||||||
|
title?: string;
|
||||||
mealType: string;
|
mealType: string;
|
||||||
nutrition: { kcal: number; proteinG: number };
|
nutrition: { kcal: number; proteinG: number };
|
||||||
nutritionIsEstimate: boolean;
|
nutritionIsEstimate: boolean;
|
||||||
@@ -163,7 +164,7 @@ export default function MyDayScreen() {
|
|||||||
<Card key={meal.id}>
|
<Card key={meal.id}>
|
||||||
<Row style={{ justifyContent: "space-between" }}>
|
<Row style={{ justifyContent: "space-between" }}>
|
||||||
<Body>
|
<Body>
|
||||||
{meal.titleSv}
|
{meal.title ?? meal.titleSv}
|
||||||
{meal.nutritionIsEstimate ? " ~" : ""}
|
{meal.nutritionIsEstimate ? " ~" : ""}
|
||||||
</Body>
|
</Body>
|
||||||
<Tag label={mealTypeLabel(meal.mealType)} />
|
<Tag label={mealTypeLabel(meal.mealType)} />
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface WeekPlanEntry {
|
|||||||
recipeId: string | null;
|
recipeId: string | null;
|
||||||
mealBoxId: string | null;
|
mealBoxId: string | null;
|
||||||
titleSv: string;
|
titleSv: string;
|
||||||
|
title?: string;
|
||||||
portions: number;
|
portions: number;
|
||||||
status: string;
|
status: string;
|
||||||
rescheduleReasonSv: string | null;
|
rescheduleReasonSv: string | null;
|
||||||
@@ -342,7 +343,7 @@ export default function PlanScreen() {
|
|||||||
return (
|
return (
|
||||||
<Card key={entry.id} onPress={() => openActions(entry)}>
|
<Card key={entry.id} onPress={() => openActions(entry)}>
|
||||||
<Row style={{ justifyContent: "space-between" }}>
|
<Row style={{ justifyContent: "space-between" }}>
|
||||||
<Body muted={cooked || skipped}>{entry.titleSv}</Body>
|
<Body muted={cooked || skipped}>{entry.title ?? entry.titleSv}</Body>
|
||||||
<Row>
|
<Row>
|
||||||
{cooked ? <Tag label={t("plan.cooked")} tone="success" /> : null}
|
{cooked ? <Tag label={t("plan.cooked")} tone="success" /> : null}
|
||||||
{skipped ? <Tag label={t("plan.skipped")} tone="warning" /> : null}
|
{skipped ? <Tag label={t("plan.skipped")} tone="warning" /> : null}
|
||||||
@@ -500,7 +501,7 @@ export default function PlanScreen() {
|
|||||||
>
|
>
|
||||||
{actionEntry ? (
|
{actionEntry ? (
|
||||||
<>
|
<>
|
||||||
<Heading>{actionEntry.titleSv}</Heading>
|
<Heading>{actionEntry.title ?? actionEntry.titleSv}</Heading>
|
||||||
{actionEntry.date === todayKey ? (
|
{actionEntry.date === todayKey ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { Alert, ScrollView, StyleSheet, View } from "react-native";
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import * as ImagePicker from "expo-image-picker";
|
import * as ImagePicker from "expo-image-picker";
|
||||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
import { ALLERGEN_LABELS_SV, type Allergen } from "@app/shared-types";
|
import { ALLERGENS } from "@app/shared-types";
|
||||||
import { api, uploadImage, ApiError } from "@/lib/api";
|
import { api, uploadImage, ApiError } from "@/lib/api";
|
||||||
import { t } from "@/lib/i18n";
|
import { getLanguageTag, t } from "@/lib/i18n";
|
||||||
import { Body, Button, Card, Heading, Row, Screen, Small, Spacer } from "@/components/ui";
|
import { Body, Button, Card, Heading, Row, Screen, Small, Spacer } from "@/components/ui";
|
||||||
import { colors, spacing } from "@/lib/theme";
|
import { colors, spacing } from "@/lib/theme";
|
||||||
|
|
||||||
@@ -76,10 +76,14 @@ async function pollScan(id: string): Promise<ProductInfo | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmtG = (n: number) => `${(Math.round(n * 10) / 10).toString().replace(".", ",")} g`;
|
const fmtG = (n: number) =>
|
||||||
|
`${new Intl.NumberFormat(getLanguageTag(), { maximumFractionDigits: 1 }).format(Math.round(n * 10) / 10)} g`;
|
||||||
|
// EU:s 14 allergener har i18n-nycklar (onboarding.allergen.<id>); okända koder
|
||||||
|
// visas råa i stället för att krascha.
|
||||||
|
const KNOWN_ALLERGENS = new Set<string>(ALLERGENS);
|
||||||
const allergenLabels = (codes: string[] | null | undefined): string =>
|
const allergenLabels = (codes: string[] | null | undefined): string =>
|
||||||
(codes ?? [])
|
(codes ?? [])
|
||||||
.map((c) => ALLERGEN_LABELS_SV[c as Allergen] ?? c)
|
.map((c) => (KNOWN_ALLERGENS.has(c) ? t(`onboarding.allergen.${c}`) : c))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
|
|||||||
@@ -154,12 +154,13 @@ export default function KitchenScreen() {
|
|||||||
|
|
||||||
<Row style={{ justifyContent: "space-between" }}>
|
<Row style={{ justifyContent: "space-between" }}>
|
||||||
<Small>
|
<Small>
|
||||||
{visible.length} varor{selected.size > 0 ? ` · ${selected.size} valda` : ""}
|
{t("kitchen.itemCount", { count: visible.length })}
|
||||||
|
{selected.size > 0 ? ` · ${t("kitchen.selectedCount", { count: selected.size })}` : ""}
|
||||||
</Small>
|
</Small>
|
||||||
<Row style={{ justifyContent: "flex-end" }}>
|
<Row style={{ justifyContent: "flex-end" }}>
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<Button
|
<Button
|
||||||
label={`Ta bort valda (${selected.size})`}
|
label={t("kitchen.removeSelected", { count: selected.size })}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onPress={() => confirmDelete([...selected], t("kitchen.scopeSelection"))}
|
onPress={() => confirmDelete([...selected], t("kitchen.scopeSelection"))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const MEAL_TYPES = ["breakfast", "lunch", "dinner", "snack"] as const;
|
|||||||
interface RecentMeal {
|
interface RecentMeal {
|
||||||
id: string;
|
id: string;
|
||||||
titleSv: string;
|
titleSv: string;
|
||||||
|
title?: string;
|
||||||
mealType: string;
|
mealType: string;
|
||||||
nutrition: {
|
nutrition: {
|
||||||
kcal: number;
|
kcal: number;
|
||||||
@@ -108,7 +109,7 @@ export default function LogMealScreen() {
|
|||||||
{(recent.data?.meals ?? []).slice(0, 5).map((meal) => (
|
{(recent.data?.meals ?? []).slice(0, 5).map((meal) => (
|
||||||
<Card key={meal.id} onPress={() => logPrevious(meal)}>
|
<Card key={meal.id} onPress={() => logPrevious(meal)}>
|
||||||
<Row style={{ justifyContent: "space-between" }}>
|
<Row style={{ justifyContent: "space-between" }}>
|
||||||
<Body>{meal.titleSv}</Body>
|
<Body>{meal.title ?? meal.titleSv}</Body>
|
||||||
<Small>{Math.round(meal.nutrition.kcal)} kcal</Small>
|
<Small>{Math.round(meal.nutrition.kcal)} kcal</Small>
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
interface MealBox {
|
interface MealBox {
|
||||||
id: string;
|
id: string;
|
||||||
titleSv: string;
|
titleSv: string;
|
||||||
|
title?: string;
|
||||||
portionsRemaining: number;
|
portionsRemaining: number;
|
||||||
recommendedUseBy: string;
|
recommendedUseBy: string;
|
||||||
frozen: boolean;
|
frozen: boolean;
|
||||||
@@ -82,7 +83,7 @@ export default function MealBoxesScreen() {
|
|||||||
<Row style={{ justifyContent: "space-between" }}>
|
<Row style={{ justifyContent: "space-between" }}>
|
||||||
<Body>
|
<Body>
|
||||||
{box.frozen ? "❄️ " : "🍱 "}
|
{box.frozen ? "❄️ " : "🍱 "}
|
||||||
{box.titleSv}
|
{box.title ?? box.titleSv}
|
||||||
</Body>
|
</Body>
|
||||||
<Tag
|
<Tag
|
||||||
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })}
|
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ interface RecipeDetail {
|
|||||||
temperatureC: number | null;
|
temperatureC: number | null;
|
||||||
}>;
|
}>;
|
||||||
safety: { safe: boolean; violations: Array<{ severity: string; messageSv: string }> };
|
safety: { safe: boolean; violations: Array<{ severity: string; messageSv: string }> };
|
||||||
variants: Array<{ id: string; titleSv: string; variantType: string }>;
|
variants: Array<{ id: string; titleSv: string; title?: string; variantType: string }>;
|
||||||
coverage: {
|
coverage: {
|
||||||
percent: number;
|
percent: number;
|
||||||
missing: Array<{
|
missing: Array<{
|
||||||
@@ -170,7 +170,7 @@ export default function RecipeScreen() {
|
|||||||
// Skicka riktig mängd + enhet + katalog-id (annars blir det "1 st").
|
// Skicka riktig mängd + enhet + katalog-id (annars blir det "1 st").
|
||||||
// Katalog-id gör att servern slår ihop med ev. befintlig rad.
|
// Katalog-id gör att servern slår ihop med ev. befintlig rad.
|
||||||
body: {
|
body: {
|
||||||
displayName: m.displayNameSv,
|
displayName: m.displayName ?? m.displayNameSv,
|
||||||
canonicalIngredientId: m.canonicalIngredientId,
|
canonicalIngredientId: m.canonicalIngredientId,
|
||||||
quantity: m.quantity,
|
quantity: m.quantity,
|
||||||
unit: m.unit,
|
unit: m.unit,
|
||||||
@@ -211,7 +211,7 @@ export default function RecipeScreen() {
|
|||||||
<Tag label={t("recipe.time", { min: recipe.totalTimeMinutes })} />
|
<Tag label={t("recipe.time", { min: recipe.totalTimeMinutes })} />
|
||||||
<Tag label={`${Math.round(recipe.nutritionPerPortion.kcal)} kcal`} />
|
<Tag label={`${Math.round(recipe.nutritionPerPortion.kcal)} kcal`} />
|
||||||
<Tag
|
<Tag
|
||||||
label={`${Math.round(recipe.nutritionPerPortion.proteinG)} g protein`}
|
label={t("recipe.proteinTag", { g: Math.round(recipe.nutritionPerPortion.proteinG) })}
|
||||||
tone="success"
|
tone="success"
|
||||||
/>
|
/>
|
||||||
{recipe.estimatedCostMinorPerPortion != null && (
|
{recipe.estimatedCostMinorPerPortion != null && (
|
||||||
@@ -227,11 +227,7 @@ export default function RecipeScreen() {
|
|||||||
</Row>
|
</Row>
|
||||||
<Body muted>{recipe.description ?? recipe.descriptionSv}</Body>
|
<Body muted>{recipe.description ?? recipe.descriptionSv}</Body>
|
||||||
{recipe.creatorDisplayName && (
|
{recipe.creatorDisplayName && (
|
||||||
<Small>
|
<Small>{t("recipe.source", { name: recipe.creatorDisplayName })}</Small>
|
||||||
{recipe.creatorDisplayName.includes("Redaktion")
|
|
||||||
? `Skapat av ${recipe.creatorDisplayName}`
|
|
||||||
: t("recipe.source", { name: recipe.creatorDisplayName })}
|
|
||||||
</Small>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!recipe.safety.safe && (
|
{!recipe.safety.safe && (
|
||||||
@@ -253,7 +249,7 @@ export default function RecipeScreen() {
|
|||||||
{recipe.variants.length > 0 && (
|
{recipe.variants.length > 0 && (
|
||||||
<Row>
|
<Row>
|
||||||
{recipe.variants.map((variant) => (
|
{recipe.variants.map((variant) => (
|
||||||
<Tag key={variant.id} label={`↔ ${variant.titleSv}`} tone="accent" />
|
<Tag key={variant.id} label={`↔ ${variant.title ?? variant.titleSv}`} tone="accent" />
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { spacing } from "@/lib/theme";
|
|||||||
interface SavedRecipe {
|
interface SavedRecipe {
|
||||||
id: string;
|
id: string;
|
||||||
titleSv: string;
|
titleSv: string;
|
||||||
|
title?: string;
|
||||||
totalTimeMinutes: number | null;
|
totalTimeMinutes: number | null;
|
||||||
nutritionPerPortion: { kcal?: number } | null;
|
nutritionPerPortion: { kcal?: number } | null;
|
||||||
}
|
}
|
||||||
@@ -60,7 +61,7 @@ export default function SavedRecipesScreen() {
|
|||||||
onPress={() => router.push(`/recipe/${r.id}`)}
|
onPress={() => router.push(`/recipe/${r.id}`)}
|
||||||
style={{ gap: spacing.xs }}
|
style={{ gap: spacing.xs }}
|
||||||
>
|
>
|
||||||
<Heading>{r.titleSv}</Heading>
|
<Heading>{r.title ?? r.titleSv}</Heading>
|
||||||
<Small>
|
<Small>
|
||||||
{[
|
{[
|
||||||
r.totalTimeMinutes != null ? `${r.totalTimeMinutes} min` : null,
|
r.totalTimeMinutes != null ? `${r.totalTimeMinutes} min` : null,
|
||||||
|
|||||||
@@ -205,7 +205,10 @@ export default function ScanReviewScreen() {
|
|||||||
<Row style={{ justifyContent: "space-between", alignItems: "flex-start" }}>
|
<Row style={{ justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
<Row style={{ flex: 1, flexWrap: "wrap", gap: spacing.xs }}>
|
<Row style={{ flex: 1, flexWrap: "wrap", gap: spacing.xs }}>
|
||||||
{item.duplicate && (
|
{item.duplicate && (
|
||||||
<Tag label={`🔁 Verkar redan finnas: ${item.duplicate.name}`} tone="accent" />
|
<Tag
|
||||||
|
label={`🔁 ${t("scan.review.possibleDuplicate", { name: item.duplicate.name })}`}
|
||||||
|
tone="accent"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{item.confidence < 0.7 && !item.rejected && (
|
{item.confidence < 0.7 && !item.rejected && (
|
||||||
<Tag label={`⚠️ ${t("scan.review.uncertain")}`} tone="warning" />
|
<Tag label={`⚠️ ${t("scan.review.uncertain")}`} tone="warning" />
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { spacing } from "@/lib/theme";
|
|||||||
interface Rec {
|
interface Rec {
|
||||||
recipeId: string;
|
recipeId: string;
|
||||||
titleSv: string;
|
titleSv: string;
|
||||||
|
title?: string;
|
||||||
coveragePercent: number;
|
coveragePercent: number;
|
||||||
}
|
}
|
||||||
interface WhatToEatResponse {
|
interface WhatToEatResponse {
|
||||||
@@ -118,7 +119,7 @@ export default function SwapMealScreen() {
|
|||||||
{recs.map((rec) => (
|
{recs.map((rec) => (
|
||||||
<Card key={rec.recipeId} onPress={() => swap.mutate(rec.recipeId)}>
|
<Card key={rec.recipeId} onPress={() => swap.mutate(rec.recipeId)}>
|
||||||
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
|
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||||
<Body>{rec.titleSv}</Body>
|
<Body>{rec.title ?? rec.titleSv}</Body>
|
||||||
<Tag
|
<Tag
|
||||||
label={t("wte.coverage", { pct: rec.coveragePercent })}
|
label={t("wte.coverage", { pct: rec.coveragePercent })}
|
||||||
tone={rec.coveragePercent >= 80 ? "success" : "neutral"}
|
tone={rec.coveragePercent >= 80 ? "success" : "neutral"}
|
||||||
|
|||||||
@@ -590,5 +590,16 @@
|
|||||||
"season.alla-hjartans-dag": "Valentinsdag",
|
"season.alla-hjartans-dag": "Valentinsdag",
|
||||||
"season.halloween": "Halloween",
|
"season.halloween": "Halloween",
|
||||||
"season.oktoberfest": "Oktoberfest",
|
"season.oktoberfest": "Oktoberfest",
|
||||||
"season.eid": "Eid al-Fitr"
|
"season.eid": "Eid al-Fitr",
|
||||||
|
"kitchen.itemCount": "{count} varer",
|
||||||
|
"kitchen.selectedCount": "{count} valgt",
|
||||||
|
"kitchen.removeSelected": "Fjern valgte ({count})",
|
||||||
|
"scan.review.possibleDuplicate": "Findes måske allerede: {name}",
|
||||||
|
"auth.passwordHint": "mindst 8 tegn",
|
||||||
|
"recipe.proteinTag": "{g} g protein",
|
||||||
|
"onboarding.allergen.celery": "Selleri",
|
||||||
|
"onboarding.allergen.mustard": "Sennep",
|
||||||
|
"onboarding.allergen.sulphites": "Sulfitter",
|
||||||
|
"onboarding.allergen.lupin": "Lupin",
|
||||||
|
"onboarding.allergen.molluscs": "Bløddyr"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -590,5 +590,16 @@
|
|||||||
"season.alla-hjartans-dag": "Valentinstag",
|
"season.alla-hjartans-dag": "Valentinstag",
|
||||||
"season.halloween": "Halloween",
|
"season.halloween": "Halloween",
|
||||||
"season.oktoberfest": "Oktoberfest",
|
"season.oktoberfest": "Oktoberfest",
|
||||||
"season.eid": "Eid al-Fitr"
|
"season.eid": "Eid al-Fitr",
|
||||||
|
"kitchen.itemCount": "{count} Artikel",
|
||||||
|
"kitchen.selectedCount": "{count} ausgewählt",
|
||||||
|
"kitchen.removeSelected": "Ausgewählte entfernen ({count})",
|
||||||
|
"scan.review.possibleDuplicate": "Scheint bereits vorhanden zu sein: {name}",
|
||||||
|
"auth.passwordHint": "mindestens 8 Zeichen",
|
||||||
|
"recipe.proteinTag": "{g} g Protein",
|
||||||
|
"onboarding.allergen.celery": "Sellerie",
|
||||||
|
"onboarding.allergen.mustard": "Senf",
|
||||||
|
"onboarding.allergen.sulphites": "Sulfite",
|
||||||
|
"onboarding.allergen.lupin": "Lupinen",
|
||||||
|
"onboarding.allergen.molluscs": "Weichtiere"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -590,5 +590,16 @@
|
|||||||
"season.alla-hjartans-dag": "Valentine's Day",
|
"season.alla-hjartans-dag": "Valentine's Day",
|
||||||
"season.halloween": "Halloween",
|
"season.halloween": "Halloween",
|
||||||
"season.oktoberfest": "Oktoberfest",
|
"season.oktoberfest": "Oktoberfest",
|
||||||
"season.eid": "Eid al-Fitr"
|
"season.eid": "Eid al-Fitr",
|
||||||
|
"kitchen.itemCount": "{count} items",
|
||||||
|
"kitchen.selectedCount": "{count} selected",
|
||||||
|
"kitchen.removeSelected": "Remove selected ({count})",
|
||||||
|
"scan.review.possibleDuplicate": "Seems to already exist: {name}",
|
||||||
|
"auth.passwordHint": "at least 8 characters",
|
||||||
|
"recipe.proteinTag": "{g} g protein",
|
||||||
|
"onboarding.allergen.celery": "Celery",
|
||||||
|
"onboarding.allergen.mustard": "Mustard",
|
||||||
|
"onboarding.allergen.sulphites": "Sulphites",
|
||||||
|
"onboarding.allergen.lupin": "Lupin",
|
||||||
|
"onboarding.allergen.molluscs": "Molluscs"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -590,5 +590,16 @@
|
|||||||
"season.alla-hjartans-dag": "San Valentín",
|
"season.alla-hjartans-dag": "San Valentín",
|
||||||
"season.halloween": "Halloween",
|
"season.halloween": "Halloween",
|
||||||
"season.oktoberfest": "Oktoberfest",
|
"season.oktoberfest": "Oktoberfest",
|
||||||
"season.eid": "Eid al-Fitr"
|
"season.eid": "Eid al-Fitr",
|
||||||
|
"kitchen.itemCount": "{count} artículos",
|
||||||
|
"kitchen.selectedCount": "{count} seleccionados",
|
||||||
|
"kitchen.removeSelected": "Eliminar seleccionados ({count})",
|
||||||
|
"scan.review.possibleDuplicate": "Parece que ya existe: {name}",
|
||||||
|
"auth.passwordHint": "al menos 8 caracteres",
|
||||||
|
"recipe.proteinTag": "{g} g de proteína",
|
||||||
|
"onboarding.allergen.celery": "Apio",
|
||||||
|
"onboarding.allergen.mustard": "Mostaza",
|
||||||
|
"onboarding.allergen.sulphites": "Sulfitos",
|
||||||
|
"onboarding.allergen.lupin": "Altramuces",
|
||||||
|
"onboarding.allergen.molluscs": "Moluscos"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -590,5 +590,16 @@
|
|||||||
"season.alla-hjartans-dag": "Ystävänpäivä",
|
"season.alla-hjartans-dag": "Ystävänpäivä",
|
||||||
"season.halloween": "Halloween",
|
"season.halloween": "Halloween",
|
||||||
"season.oktoberfest": "Oktoberfest",
|
"season.oktoberfest": "Oktoberfest",
|
||||||
"season.eid": "Eid al-Fitr"
|
"season.eid": "Eid al-Fitr",
|
||||||
|
"kitchen.itemCount": "{count} tuotetta",
|
||||||
|
"kitchen.selectedCount": "{count} valittu",
|
||||||
|
"kitchen.removeSelected": "Poista valitut ({count})",
|
||||||
|
"scan.review.possibleDuplicate": "Saattaa olla jo olemassa: {name}",
|
||||||
|
"auth.passwordHint": "vähintään 8 merkkiä",
|
||||||
|
"recipe.proteinTag": "{g} g proteiinia",
|
||||||
|
"onboarding.allergen.celery": "Selleri",
|
||||||
|
"onboarding.allergen.mustard": "Sinappi",
|
||||||
|
"onboarding.allergen.sulphites": "Sulfiitit",
|
||||||
|
"onboarding.allergen.lupin": "Lupiini",
|
||||||
|
"onboarding.allergen.molluscs": "Nilviäiset"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -590,5 +590,16 @@
|
|||||||
"season.alla-hjartans-dag": "Saint-Valentin",
|
"season.alla-hjartans-dag": "Saint-Valentin",
|
||||||
"season.halloween": "Halloween",
|
"season.halloween": "Halloween",
|
||||||
"season.oktoberfest": "Oktoberfest",
|
"season.oktoberfest": "Oktoberfest",
|
||||||
"season.eid": "Aïd el-Fitr"
|
"season.eid": "Aïd el-Fitr",
|
||||||
|
"kitchen.itemCount": "{count} articles",
|
||||||
|
"kitchen.selectedCount": "{count} sélectionnés",
|
||||||
|
"kitchen.removeSelected": "Supprimer la sélection ({count})",
|
||||||
|
"scan.review.possibleDuplicate": "Semble déjà exister : {name}",
|
||||||
|
"auth.passwordHint": "au moins 8 caractères",
|
||||||
|
"recipe.proteinTag": "{g} g de protéines",
|
||||||
|
"onboarding.allergen.celery": "Céleri",
|
||||||
|
"onboarding.allergen.mustard": "Moutarde",
|
||||||
|
"onboarding.allergen.sulphites": "Sulfites",
|
||||||
|
"onboarding.allergen.lupin": "Lupin",
|
||||||
|
"onboarding.allergen.molluscs": "Mollusques"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -590,5 +590,16 @@
|
|||||||
"season.alla-hjartans-dag": "San Valentino",
|
"season.alla-hjartans-dag": "San Valentino",
|
||||||
"season.halloween": "Halloween",
|
"season.halloween": "Halloween",
|
||||||
"season.oktoberfest": "Oktoberfest",
|
"season.oktoberfest": "Oktoberfest",
|
||||||
"season.eid": "Eid al-Fitr"
|
"season.eid": "Eid al-Fitr",
|
||||||
|
"kitchen.itemCount": "{count} articoli",
|
||||||
|
"kitchen.selectedCount": "{count} selezionati",
|
||||||
|
"kitchen.removeSelected": "Rimuovi selezionati ({count})",
|
||||||
|
"scan.review.possibleDuplicate": "Sembra già presente: {name}",
|
||||||
|
"auth.passwordHint": "almeno 8 caratteri",
|
||||||
|
"recipe.proteinTag": "{g} g di proteine",
|
||||||
|
"onboarding.allergen.celery": "Sedano",
|
||||||
|
"onboarding.allergen.mustard": "Senape",
|
||||||
|
"onboarding.allergen.sulphites": "Solfiti",
|
||||||
|
"onboarding.allergen.lupin": "Lupini",
|
||||||
|
"onboarding.allergen.molluscs": "Molluschi"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -590,5 +590,16 @@
|
|||||||
"season.alla-hjartans-dag": "Valentinsdagen",
|
"season.alla-hjartans-dag": "Valentinsdagen",
|
||||||
"season.halloween": "Halloween",
|
"season.halloween": "Halloween",
|
||||||
"season.oktoberfest": "Oktoberfest",
|
"season.oktoberfest": "Oktoberfest",
|
||||||
"season.eid": "Eid al-Fitr"
|
"season.eid": "Eid al-Fitr",
|
||||||
|
"kitchen.itemCount": "{count} varer",
|
||||||
|
"kitchen.selectedCount": "{count} valgt",
|
||||||
|
"kitchen.removeSelected": "Fjern valgte ({count})",
|
||||||
|
"scan.review.possibleDuplicate": "Finnes kanskje allerede: {name}",
|
||||||
|
"auth.passwordHint": "minst 8 tegn",
|
||||||
|
"recipe.proteinTag": "{g} g protein",
|
||||||
|
"onboarding.allergen.celery": "Selleri",
|
||||||
|
"onboarding.allergen.mustard": "Sennep",
|
||||||
|
"onboarding.allergen.sulphites": "Sulfitter",
|
||||||
|
"onboarding.allergen.lupin": "Lupin",
|
||||||
|
"onboarding.allergen.molluscs": "Bløtdyr"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -590,5 +590,16 @@
|
|||||||
"season.alla-hjartans-dag": "Valentijnsdag",
|
"season.alla-hjartans-dag": "Valentijnsdag",
|
||||||
"season.halloween": "Halloween",
|
"season.halloween": "Halloween",
|
||||||
"season.oktoberfest": "Oktoberfest",
|
"season.oktoberfest": "Oktoberfest",
|
||||||
"season.eid": "Eid al-Fitr"
|
"season.eid": "Eid al-Fitr",
|
||||||
|
"kitchen.itemCount": "{count} artikelen",
|
||||||
|
"kitchen.selectedCount": "{count} geselecteerd",
|
||||||
|
"kitchen.removeSelected": "Verwijder geselecteerde ({count})",
|
||||||
|
"scan.review.possibleDuplicate": "Lijkt al te bestaan: {name}",
|
||||||
|
"auth.passwordHint": "minstens 8 tekens",
|
||||||
|
"recipe.proteinTag": "{g} g eiwit",
|
||||||
|
"onboarding.allergen.celery": "Selderij",
|
||||||
|
"onboarding.allergen.mustard": "Mosterd",
|
||||||
|
"onboarding.allergen.sulphites": "Sulfieten",
|
||||||
|
"onboarding.allergen.lupin": "Lupine",
|
||||||
|
"onboarding.allergen.molluscs": "Weekdieren"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -604,5 +604,16 @@
|
|||||||
"season.alla-hjartans-dag": "Walentynki",
|
"season.alla-hjartans-dag": "Walentynki",
|
||||||
"season.halloween": "Halloween",
|
"season.halloween": "Halloween",
|
||||||
"season.oktoberfest": "Oktoberfest",
|
"season.oktoberfest": "Oktoberfest",
|
||||||
"season.eid": "Eid al-Fitr"
|
"season.eid": "Eid al-Fitr",
|
||||||
|
"kitchen.itemCount": "{count} produktów",
|
||||||
|
"kitchen.selectedCount": "{count} zaznaczonych",
|
||||||
|
"kitchen.removeSelected": "Usuń zaznaczone ({count})",
|
||||||
|
"scan.review.possibleDuplicate": "Wygląda na to, że już istnieje: {name}",
|
||||||
|
"auth.passwordHint": "co najmniej 8 znaków",
|
||||||
|
"recipe.proteinTag": "{g} g białka",
|
||||||
|
"onboarding.allergen.celery": "Seler",
|
||||||
|
"onboarding.allergen.mustard": "Gorczyca",
|
||||||
|
"onboarding.allergen.sulphites": "Siarczyny",
|
||||||
|
"onboarding.allergen.lupin": "Łubin",
|
||||||
|
"onboarding.allergen.molluscs": "Mięczaki"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -590,5 +590,16 @@
|
|||||||
"season.alla-hjartans-dag": "Dia dos Namorados",
|
"season.alla-hjartans-dag": "Dia dos Namorados",
|
||||||
"season.halloween": "Halloween",
|
"season.halloween": "Halloween",
|
||||||
"season.oktoberfest": "Oktoberfest",
|
"season.oktoberfest": "Oktoberfest",
|
||||||
"season.eid": "Eid al-Fitr"
|
"season.eid": "Eid al-Fitr",
|
||||||
|
"kitchen.itemCount": "{count} itens",
|
||||||
|
"kitchen.selectedCount": "{count} selecionados",
|
||||||
|
"kitchen.removeSelected": "Remover selecionados ({count})",
|
||||||
|
"scan.review.possibleDuplicate": "Parece já existir: {name}",
|
||||||
|
"auth.passwordHint": "pelo menos 8 caracteres",
|
||||||
|
"recipe.proteinTag": "{g} g de proteína",
|
||||||
|
"onboarding.allergen.celery": "Aipo",
|
||||||
|
"onboarding.allergen.mustard": "Mostarda",
|
||||||
|
"onboarding.allergen.sulphites": "Sulfitos",
|
||||||
|
"onboarding.allergen.lupin": "Tremoço",
|
||||||
|
"onboarding.allergen.molluscs": "Moluscos"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -590,5 +590,16 @@
|
|||||||
"season.alla-hjartans-dag": "Alla hjärtans dag",
|
"season.alla-hjartans-dag": "Alla hjärtans dag",
|
||||||
"season.halloween": "Halloween",
|
"season.halloween": "Halloween",
|
||||||
"season.oktoberfest": "Oktoberfest",
|
"season.oktoberfest": "Oktoberfest",
|
||||||
"season.eid": "Eid al-Fitr"
|
"season.eid": "Eid al-Fitr",
|
||||||
|
"kitchen.itemCount": "{count} varor",
|
||||||
|
"kitchen.selectedCount": "{count} valda",
|
||||||
|
"kitchen.removeSelected": "Ta bort valda ({count})",
|
||||||
|
"scan.review.possibleDuplicate": "Verkar redan finnas: {name}",
|
||||||
|
"auth.passwordHint": "minst 8 tecken",
|
||||||
|
"recipe.proteinTag": "{g} g protein",
|
||||||
|
"onboarding.allergen.celery": "Selleri",
|
||||||
|
"onboarding.allergen.mustard": "Senap",
|
||||||
|
"onboarding.allergen.sulphites": "Sulfiter",
|
||||||
|
"onboarding.allergen.lupin": "Lupin",
|
||||||
|
"onboarding.allergen.molluscs": "Blötdjur"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user