18388bb31c
- 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>
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|