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:
@@ -106,7 +106,7 @@ export default function RegisterScreen() {
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
<Input
|
||||
placeholder={`${t("auth.password")} (minst 8 tecken)`}
|
||||
placeholder={`${t("auth.password")} (${t("auth.passwordHint")})`}
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import { recommendationsViewed } from "@app/analytics";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { getLanguageTag, t } from "@/lib/i18n";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
@@ -41,6 +41,7 @@ interface Recommendation {
|
||||
interface MealBoxSuggestion {
|
||||
mealBoxId: string;
|
||||
titleSv: string;
|
||||
title?: string;
|
||||
portionsRemaining: number;
|
||||
recommendedUseBy: string;
|
||||
useByUrgent: boolean;
|
||||
@@ -225,7 +226,7 @@ export default function WhatToEatScreen() {
|
||||
{query.data.mealBoxSuggestions.map((box) => (
|
||||
<Card key={box.mealBoxId} onPress={() => router.push("/meal-boxes")}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Body>🍱 {box.titleSv}</Body>
|
||||
<Body>🍱 {box.title ?? box.titleSv}</Body>
|
||||
<Tag
|
||||
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })}
|
||||
tone="success"
|
||||
@@ -275,7 +276,10 @@ export default function WhatToEatScreen() {
|
||||
<Row>
|
||||
{rec.ratingCount > 0 && rec.ratingAverage != null && (
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,7 @@ interface DayResponse {
|
||||
meals: Array<{
|
||||
id: string;
|
||||
titleSv: string;
|
||||
title?: string;
|
||||
mealType: string;
|
||||
nutrition: { kcal: number; proteinG: number };
|
||||
nutritionIsEstimate: boolean;
|
||||
@@ -163,7 +164,7 @@ export default function MyDayScreen() {
|
||||
<Card key={meal.id}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Body>
|
||||
{meal.titleSv}
|
||||
{meal.title ?? meal.titleSv}
|
||||
{meal.nutritionIsEstimate ? " ~" : ""}
|
||||
</Body>
|
||||
<Tag label={mealTypeLabel(meal.mealType)} />
|
||||
|
||||
@@ -30,6 +30,7 @@ interface WeekPlanEntry {
|
||||
recipeId: string | null;
|
||||
mealBoxId: string | null;
|
||||
titleSv: string;
|
||||
title?: string;
|
||||
portions: number;
|
||||
status: string;
|
||||
rescheduleReasonSv: string | null;
|
||||
@@ -342,7 +343,7 @@ export default function PlanScreen() {
|
||||
return (
|
||||
<Card key={entry.id} onPress={() => openActions(entry)}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Body muted={cooked || skipped}>{entry.titleSv}</Body>
|
||||
<Body muted={cooked || skipped}>{entry.title ?? entry.titleSv}</Body>
|
||||
<Row>
|
||||
{cooked ? <Tag label={t("plan.cooked")} tone="success" /> : null}
|
||||
{skipped ? <Tag label={t("plan.skipped")} tone="warning" /> : null}
|
||||
@@ -500,7 +501,7 @@ export default function PlanScreen() {
|
||||
>
|
||||
{actionEntry ? (
|
||||
<>
|
||||
<Heading>{actionEntry.titleSv}</Heading>
|
||||
<Heading>{actionEntry.title ?? actionEntry.titleSv}</Heading>
|
||||
{actionEntry.date === todayKey ? (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Alert, ScrollView, StyleSheet, View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
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 { t } from "@/lib/i18n";
|
||||
import { getLanguageTag, t } from "@/lib/i18n";
|
||||
import { Body, Button, Card, Heading, Row, Screen, Small, Spacer } from "@/components/ui";
|
||||
import { colors, spacing } from "@/lib/theme";
|
||||
|
||||
@@ -76,10 +76,14 @@ async function pollScan(id: string): Promise<ProductInfo | 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 =>
|
||||
(codes ?? [])
|
||||
.map((c) => ALLERGEN_LABELS_SV[c as Allergen] ?? c)
|
||||
.map((c) => (KNOWN_ALLERGENS.has(c) ? t(`onboarding.allergen.${c}`) : c))
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
|
||||
@@ -154,12 +154,13 @@ export default function KitchenScreen() {
|
||||
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<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>
|
||||
<Row style={{ justifyContent: "flex-end" }}>
|
||||
{selected.size > 0 && (
|
||||
<Button
|
||||
label={`Ta bort valda (${selected.size})`}
|
||||
label={t("kitchen.removeSelected", { count: selected.size })}
|
||||
variant="danger"
|
||||
onPress={() => confirmDelete([...selected], t("kitchen.scopeSelection"))}
|
||||
/>
|
||||
|
||||
@@ -18,6 +18,7 @@ const MEAL_TYPES = ["breakfast", "lunch", "dinner", "snack"] as const;
|
||||
interface RecentMeal {
|
||||
id: string;
|
||||
titleSv: string;
|
||||
title?: string;
|
||||
mealType: string;
|
||||
nutrition: {
|
||||
kcal: number;
|
||||
@@ -108,7 +109,7 @@ export default function LogMealScreen() {
|
||||
{(recent.data?.meals ?? []).slice(0, 5).map((meal) => (
|
||||
<Card key={meal.id} onPress={() => logPrevious(meal)}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Body>{meal.titleSv}</Body>
|
||||
<Body>{meal.title ?? meal.titleSv}</Body>
|
||||
<Small>{Math.round(meal.nutrition.kcal)} kcal</Small>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
interface MealBox {
|
||||
id: string;
|
||||
titleSv: string;
|
||||
title?: string;
|
||||
portionsRemaining: number;
|
||||
recommendedUseBy: string;
|
||||
frozen: boolean;
|
||||
@@ -82,7 +83,7 @@ export default function MealBoxesScreen() {
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Body>
|
||||
{box.frozen ? "❄️ " : "🍱 "}
|
||||
{box.titleSv}
|
||||
{box.title ?? box.titleSv}
|
||||
</Body>
|
||||
<Tag
|
||||
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })}
|
||||
|
||||
@@ -55,7 +55,7 @@ interface RecipeDetail {
|
||||
temperatureC: number | null;
|
||||
}>;
|
||||
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: {
|
||||
percent: number;
|
||||
missing: Array<{
|
||||
@@ -170,7 +170,7 @@ export default function RecipeScreen() {
|
||||
// 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.
|
||||
body: {
|
||||
displayName: m.displayNameSv,
|
||||
displayName: m.displayName ?? m.displayNameSv,
|
||||
canonicalIngredientId: m.canonicalIngredientId,
|
||||
quantity: m.quantity,
|
||||
unit: m.unit,
|
||||
@@ -211,7 +211,7 @@ export default function RecipeScreen() {
|
||||
<Tag label={t("recipe.time", { min: recipe.totalTimeMinutes })} />
|
||||
<Tag label={`${Math.round(recipe.nutritionPerPortion.kcal)} kcal`} />
|
||||
<Tag
|
||||
label={`${Math.round(recipe.nutritionPerPortion.proteinG)} g protein`}
|
||||
label={t("recipe.proteinTag", { g: Math.round(recipe.nutritionPerPortion.proteinG) })}
|
||||
tone="success"
|
||||
/>
|
||||
{recipe.estimatedCostMinorPerPortion != null && (
|
||||
@@ -227,11 +227,7 @@ export default function RecipeScreen() {
|
||||
</Row>
|
||||
<Body muted>{recipe.description ?? recipe.descriptionSv}</Body>
|
||||
{recipe.creatorDisplayName && (
|
||||
<Small>
|
||||
{recipe.creatorDisplayName.includes("Redaktion")
|
||||
? `Skapat av ${recipe.creatorDisplayName}`
|
||||
: t("recipe.source", { name: recipe.creatorDisplayName })}
|
||||
</Small>
|
||||
<Small>{t("recipe.source", { name: recipe.creatorDisplayName })}</Small>
|
||||
)}
|
||||
|
||||
{!recipe.safety.safe && (
|
||||
@@ -253,7 +249,7 @@ export default function RecipeScreen() {
|
||||
{recipe.variants.length > 0 && (
|
||||
<Row>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { spacing } from "@/lib/theme";
|
||||
interface SavedRecipe {
|
||||
id: string;
|
||||
titleSv: string;
|
||||
title?: string;
|
||||
totalTimeMinutes: number | null;
|
||||
nutritionPerPortion: { kcal?: number } | null;
|
||||
}
|
||||
@@ -60,7 +61,7 @@ export default function SavedRecipesScreen() {
|
||||
onPress={() => router.push(`/recipe/${r.id}`)}
|
||||
style={{ gap: spacing.xs }}
|
||||
>
|
||||
<Heading>{r.titleSv}</Heading>
|
||||
<Heading>{r.title ?? r.titleSv}</Heading>
|
||||
<Small>
|
||||
{[
|
||||
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={{ flex: 1, flexWrap: "wrap", gap: spacing.xs }}>
|
||||
{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 && (
|
||||
<Tag label={`⚠️ ${t("scan.review.uncertain")}`} tone="warning" />
|
||||
|
||||
@@ -23,6 +23,7 @@ import { spacing } from "@/lib/theme";
|
||||
interface Rec {
|
||||
recipeId: string;
|
||||
titleSv: string;
|
||||
title?: string;
|
||||
coveragePercent: number;
|
||||
}
|
||||
interface WhatToEatResponse {
|
||||
@@ -118,7 +119,7 @@ export default function SwapMealScreen() {
|
||||
{recs.map((rec) => (
|
||||
<Card key={rec.recipeId} onPress={() => swap.mutate(rec.recipeId)}>
|
||||
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Body>{rec.titleSv}</Body>
|
||||
<Body>{rec.title ?? rec.titleSv}</Body>
|
||||
<Tag
|
||||
label={t("wte.coverage", { pct: rec.coveragePercent })}
|
||||
tone={rec.coveragePercent >= 80 ? "success" : "neutral"}
|
||||
|
||||
Reference in New Issue
Block a user