Files
Cibello-app/apps/mobile/src/app/(tabs)/index.tsx
T
Claude 18388bb31c 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>
2026-08-21 20:07:00 +00:00

328 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { Pressable, View } from "react-native";
import { router } from "expo-router";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { api, ApiError } from "@/lib/api";
import { useAnalytics } from "@/lib/analytics";
import { recommendationsViewed } from "@app/analytics";
import { getLanguageTag, t } from "@/lib/i18n";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
Heading,
Input,
LoadingView,
Row,
Screen,
Small,
Spacer,
Tag,
Title,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
/** "Vad ska vi äta?" (spec §4.1, §18) appens viktigaste vy. */
interface Recommendation {
recipeId: string;
titleSv: string;
title?: string;
score: number;
whySv: string;
missingIngredients: string[];
usesExpiring: Array<{ nameSv: string; name?: string; daysLeft: number | null }>;
coveragePercent: number;
ratingAverage: number | null;
ratingCount: number;
}
interface MealBoxSuggestion {
mealBoxId: string;
titleSv: string;
title?: string;
portionsRemaining: number;
recommendedUseBy: string;
useByUrgent: boolean;
}
interface WhatToEatResponse {
recommendations: Recommendation[];
mealBoxSuggestions: MealBoxSuggestion[];
context: {
activeHolidays: Array<{ slug: string; name: string }>;
remainingKcal: number;
remainingProteinG: number;
searchSuggestion?: string | null;
};
}
// Kategori-flikar. Alla använder SAMMA rekommendations-motor (what-to-eat) så
// vyn blir identisk överallt: %-hemma, stjärnor och "Visa fler". De flesta
// filtrerar på måltidstyp; "Baka" på taggen "baking" (ctxMeal styr bara
// poängsättningen så bakverk inte nedviktas som "fel måltid").
const CATS: ReadonlyArray<{
key: string;
labelKey: string | null;
label?: string;
mealType?: string;
tag?: string;
ctxMeal?: string;
glyph: string;
}> = [
{ key: "breakfast", labelKey: "myday.mealType.breakfast", mealType: "breakfast", glyph: "🥣" },
{ key: "lunch", labelKey: "myday.mealType.lunch", mealType: "lunch", glyph: "🥗" },
{ key: "dinner", labelKey: "myday.mealType.dinner", mealType: "dinner", glyph: "🍽️" },
{ key: "snack", labelKey: "myday.mealType.snack", mealType: "snack", glyph: "🍎" },
{ key: "dessert", labelKey: "myday.mealType.dessert", mealType: "dessert", glyph: "🍰" },
{ key: "baking", labelKey: "cat.baking", tag: "baking", ctxMeal: "dessert", glyph: "🥐" },
];
const errText = (e: unknown): string | undefined =>
e instanceof ApiError ? `[${e.status}] ${e.message}` : e instanceof Error ? e.message : undefined;
export default function WhatToEatScreen() {
const [searchText, setSearchText] = useState("");
const [submittedSearch, setSubmittedSearch] = useState("");
const [page, setPage] = useState(0);
const [cat, setCat] = useState<string | null>(null); // null = Rekommenderat
const [holiday, setHoliday] = useState<string | null>(null); // vald högtid (slug)
const { track } = useAnalytics();
const hour = new Date().getHours();
const currentMeal = hour < 10 ? "breakfast" : hour < 14 ? "lunch" : "dinner";
const activeCat = cat === null ? null : CATS.find((c) => c.key === cat);
const query = useQuery({
queryKey: ["what-to-eat", cat, holiday, submittedSearch, currentMeal],
queryFn: () => {
let path = "/v1/recommendations/what-to-eat?limit=20&view=default";
if (activeCat?.tag) {
path += `&tag=${activeCat.tag}&mealType=${activeCat.ctxMeal ?? "dessert"}`;
} else {
path += `&mealType=${activeCat?.mealType ?? currentMeal}`;
}
if (holiday) path += `&holiday=${encodeURIComponent(holiday)}`;
// Sök bland alla recept (på titel) oavsett vald flik/högtid.
if (submittedSearch) path += `&search=${encodeURIComponent(submittedSearch)}`;
return api<WhatToEatResponse>(path);
},
placeholderData: keepPreviousData,
});
// Sök, flik och högtid är ömsesidigt uteslutande ett aktivt filter i taget.
const submitSearch = (value: string) => {
setSearchText(value);
setSubmittedSearch(value.trim());
setHoliday(null);
setPage(0);
};
const selectCat = (value: string | null) => {
setCat(value);
setHoliday(null);
setSearchText("");
setSubmittedSearch("");
setPage(0);
};
const selectHoliday = (slug: string) => {
setHoliday((cur) => (cur === slug ? null : slug)); // toggla av/på
setCat(null);
setSearchText("");
setSubmittedSearch("");
setPage(0);
};
const allRecs = query.data?.recommendations ?? [];
const shownRecs: Recommendation[] = allRecs.length
? Array.from(
{ length: Math.min(5, allRecs.length) },
(_, i) => allRecs[(page * 5 + i) % allRecs.length],
).filter((r): r is Recommendation => r != null)
: [];
useEffect(() => {
if (query.data) {
track(
recommendationsViewed({
properties: {
craving: submittedSearch || undefined,
count: query.data.recommendations.length,
hasMealBoxSuggestions: query.data.mealBoxSuggestions.length > 0,
},
}),
);
}
}, [query.data]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<Screen>
<Title>{t("wte.title")}</Title>
<Small>{t("wte.subtitle")}</Small>
<Spacer size={spacing.sm} />
{/* Sök bland recept (fungerar i alla flikar, söker på namn). */}
<Input
placeholder={t("wte.searchPlaceholder")}
value={searchText}
onChangeText={setSearchText}
onSubmitEditing={() => submitSearch(searchText)}
returnKeyType="search"
autoCorrect={false}
/>
{/* Aktuella högtider/säsonger snabbknappar direkt under sökrutan. */}
{query.data && query.data.context.activeHolidays.length > 0 && (
<>
<Spacer size={spacing.xs} />
<Row style={{ flexWrap: "wrap" }}>
{query.data.context.activeHolidays.map((h) => {
// Säsongsnamn på användarens språk; fallback till serverns namn (egennamn m.m.).
const name = t(`season.${h.slug}`, { defaultValue: h.name });
return (
<Pressable key={h.slug} onPress={() => selectHoliday(h.slug)}>
<Tag label={`🎉 ${name}`} tone={holiday === h.slug ? "success" : "accent"} />
</Pressable>
);
})}
</Row>
</>
)}
<Spacer size={spacing.sm} />
{/* Kategori-flikar med enhetliga rutor (2 kolumner). Filtrerar in-place. */}
<Row style={{ flexWrap: "wrap", justifyContent: "space-between" }}>
<Button
label={`🍽️ ${t("wte.recommended")}`}
variant={cat === null ? "secondary" : "ghost"}
onPress={() => selectCat(null)}
style={{ width: "48%", marginBottom: spacing.xs }}
/>
{CATS.map((c) => (
<Button
key={c.key}
label={`${c.glyph} ${c.labelKey ? t(c.labelKey as never) : (c.label ?? "")}`}
variant={cat === c.key ? "secondary" : "ghost"}
onPress={() => selectCat(c.key)}
style={{ width: "48%", marginBottom: spacing.xs }}
/>
))}
</Row>
<Spacer size={spacing.sm} />
{query.isLoading && <LoadingView />}
{query.isError && (
<ErrorView message={errText(query.error)} onRetry={() => void query.refetch()} />
)}
{query.data && (
<>
{cat === null &&
!submittedSearch &&
!holiday &&
query.data.mealBoxSuggestions.length > 0 && (
<>
<Heading>{t("wte.mealBoxFirst")}</Heading>
{query.data.mealBoxSuggestions.map((box) => (
<Card key={box.mealBoxId} onPress={() => router.push("/meal-boxes")}>
<Row style={{ justifyContent: "space-between" }}>
<Body>🍱 {box.title ?? box.titleSv}</Body>
<Tag
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })}
tone="success"
/>
</Row>
<Small>
{t(box.useByUrgent ? "mealbox.eatBy" : "mealbox.ready", {
date: box.recommendedUseBy,
})}
</Small>
</Card>
))}
<Spacer size={spacing.sm} />
</>
)}
{allRecs.length === 0 && (
<EmptyState
text={
submittedSearch
? t("wte.emptySearch")
: holiday
? t("wte.emptyHoliday")
: t("wte.empty")
}
/>
)}
{/* "Menade du …?" stavningsförslag när sök gav noll träffar. */}
{allRecs.length === 0 && submittedSearch && query.data.context.searchSuggestion && (
<Pressable onPress={() => submitSearch(query.data!.context.searchSuggestion!)}>
<Tag
label={t("wte.didYouMean", { suggestion: query.data.context.searchSuggestion })}
tone="accent"
/>
</Pressable>
)}
{shownRecs.map((rec, index) => (
<Card key={rec.recipeId} onPress={() => router.push(`/recipe/${rec.recipeId}`)}>
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
<Heading>{rec.title ?? rec.titleSv}</Heading>
{page === 0 && index === 0 && !submittedSearch && !holiday && (
<Tag label={t("wte.topPick")} tone="accent" />
)}
</Row>
<Row>
{rec.ratingCount > 0 && rec.ratingAverage != null && (
<Tag
label={`${new Intl.NumberFormat(getLanguageTag(), {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(rec.ratingAverage)} · ${rec.ratingCount}`}
tone="accent"
/>
)}
<Tag
label={t("wte.coverage", { pct: rec.coveragePercent })}
tone={rec.coveragePercent >= 80 ? "success" : "neutral"}
/>
{rec.usesExpiring.slice(0, 2).map((item) => (
<Tag key={item.nameSv} label={`${item.name ?? item.nameSv}`} tone="warning" />
))}
</Row>
<View
style={{
backgroundColor: colors.surfaceAlt,
borderRadius: 10,
padding: spacing.sm,
marginTop: spacing.xs,
}}
>
<Small>💡 {rec.whySv}</Small>
</View>
{rec.missingIngredients.length > 0 && (
<Small>
{t("wte.missing", { items: rec.missingIngredients.slice(0, 4).join(", ") })}
</Small>
)}
</Card>
))}
{allRecs.length > 5 && (
<>
<Spacer size={spacing.sm} />
<Button
label={t("wte.refresh")}
variant="secondary"
loading={query.isFetching}
onPress={() => setPage((p) => p + 1)}
/>
</>
)}
</>
)}
</Screen>
);
}