fix(vad-ska-vi-ata): ratta 400-bugg (limit 30 > max 20) + kategorier filtrerar in-place

- KRITISKT: what-to-eat-anropet hade limit=30 men schemat tillater max 20
  -> 400 Bad Request -> tom sida med 'nagot gick fel'. Det var alltsa INTE
  uppkopplingen. Andrat till limit=20.
- Kategori-flikarna (Frukost/Lunch/Middag/Mellanmal/Efterratt/Baka) filtrerar
  nu recepten PA SAMMA sida i stallet for att navigera till bladdra-vyn.
  'Rekommenderat' visar de personliga forslagen som forut.
- ErrorView visar nu riktig HTTP-status (t.ex. [400]) sa fel gar att diagnosa.
- '/recipes' finns kvar via 'Alla recept & sok' for full bladdring + sok.

Mobil typecheck gron.
This commit is contained in:
Claude
2026-08-19 12:16:04 +00:00
parent e25bdca4fa
commit 0a16f1fefa
+194 -116
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Pressable, View } from "react-native"; import { Pressable, View } from "react-native";
import { router } from "expo-router"; import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { api } 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 { t } from "@/lib/i18n";
@@ -49,42 +49,71 @@ interface WhatToEatResponse {
mealBoxSuggestions: MealBoxSuggestion[]; mealBoxSuggestions: MealBoxSuggestion[];
context: { activeHolidays: string[]; remainingKcal: number; remainingProteinG: number }; context: { activeHolidays: string[]; remainingKcal: number; remainingProteinG: number };
} }
interface BrowseRecipe {
id: string;
titleSv: string;
title?: string;
totalTimeMinutes: number | null;
nutritionPerPortion: { kcal?: number } | null;
ratingAverage: number | null;
ratingCount: number;
}
// Bläddra-genvägar direkt på förstasidan (öppnar recept-bläddraren förvald på // Kategori-flikar direkt på förstasidan. null = personliga rekommendationer;
// kategorin). Etiketter återanvänder myday.mealType.* där de finns. // annars filtreras recepten IN-PLACE på samma sida (ingen navigering bort).
const BROWSE_CATS: ReadonlyArray<{ // De flesta filtrerar på måltidstyp; "Baka" på taggen "baking".
const CATS: ReadonlyArray<{
key: string; key: string;
labelKey: string | null; labelKey: string | null;
label?: string; label?: string;
mealType?: string;
tag?: string;
glyph: string; glyph: string;
}> = [ }> = [
{ key: "breakfast", labelKey: "myday.mealType.breakfast", glyph: "🥣" }, { key: "breakfast", labelKey: "myday.mealType.breakfast", mealType: "breakfast", glyph: "🥣" },
{ key: "lunch", labelKey: "myday.mealType.lunch", glyph: "🥗" }, { key: "lunch", labelKey: "myday.mealType.lunch", mealType: "lunch", glyph: "🥗" },
{ key: "dinner", labelKey: "myday.mealType.dinner", glyph: "🍽️" }, { key: "dinner", labelKey: "myday.mealType.dinner", mealType: "dinner", glyph: "🍽️" },
{ key: "snack", labelKey: "myday.mealType.snack", glyph: "🍎" }, { key: "snack", labelKey: "myday.mealType.snack", mealType: "snack", glyph: "🍎" },
{ key: "dessert", labelKey: "myday.mealType.dessert", glyph: "🍰" }, { key: "dessert", labelKey: "myday.mealType.dessert", mealType: "dessert", glyph: "🍰" },
{ key: "baking", labelKey: null, label: "Baka", glyph: "🥐" }, { key: "baking", labelKey: null, label: "Baka", tag: "baking", glyph: "🥐" },
]; ];
const errText = (e: unknown): string | undefined =>
e instanceof ApiError ? `[${e.status}] ${e.message}` : e instanceof Error ? e.message : undefined;
export default function WhatToEatScreen() { export default function WhatToEatScreen() {
const [craving, setCraving] = useState(""); const [craving, setCraving] = useState("");
const [submittedCraving, setSubmittedCraving] = useState(""); const [submittedCraving, setSubmittedCraving] = useState("");
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [cat, setCat] = useState<string | null>(null); // null = Rekommenderat
const { track } = useAnalytics(); const { track } = useAnalytics();
const hour = new Date().getHours(); const hour = new Date().getHours();
const currentMeal = hour < 10 ? "breakfast" : hour < 14 ? "lunch" : "dinner"; const currentMeal = hour < 10 ? "breakfast" : hour < 14 ? "lunch" : "dinner";
// view=default ger en balanserad rankning (täckning väger tungt men inte // Personliga rekommendationer (bara i "Rekommenderat"-läget). view=default ger
// allenarådande som i pantry-vyn) → mer varierade, relevanta förslag i stället // en balanserad rankning; limit=20 är schemats maxgräns.
// för samma triviala högtäckningsrätter. Större pool (30) så "Visa fler"
// bläddrar längre innan den upprepar.
const query = useQuery({ const query = useQuery({
queryKey: ["what-to-eat", submittedCraving, currentMeal], queryKey: ["what-to-eat", submittedCraving, currentMeal],
queryFn: () => queryFn: () =>
api<WhatToEatResponse>( api<WhatToEatResponse>(
`/v1/recommendations/what-to-eat?limit=30&view=default&mealType=${currentMeal}${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`, `/v1/recommendations/what-to-eat?limit=20&view=default&mealType=${currentMeal}${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`,
), ),
enabled: cat === null,
});
// Kategori-bläddring in-place (när en flik är vald).
const browse = useQuery({
queryKey: ["wte-browse", cat],
queryFn: () => {
const c = CATS.find((x) => x.key === cat);
let path = "/v1/recipes?limit=50";
if (c?.mealType) path += `&mealType=${c.mealType}`;
if (c?.tag) path += `&tags=${c.tag}`;
return api<{ recipes: BrowseRecipe[] }>(path);
},
enabled: cat !== null,
placeholderData: keepPreviousData,
}); });
const allRecs = query.data?.recommendations ?? []; const allRecs = query.data?.recommendations ?? [];
@@ -115,125 +144,174 @@ export default function WhatToEatScreen() {
<Small>{t("wte.subtitle")}</Small> <Small>{t("wte.subtitle")}</Small>
<Spacer size={spacing.sm} /> <Spacer size={spacing.sm} />
<Row> {/* Kategori-flikar filtrerar på DENNA sida, ingen navigering bort. */}
<View style={{ flex: 1 }}>
<Input
placeholder={t("wte.cravingPlaceholder")}
value={craving}
onChangeText={setCraving}
onSubmitEditing={() => {
setSubmittedCraving(craving);
setPage(0);
}}
returnKeyType="search"
/>
</View>
</Row>
<Spacer size={spacing.sm} />
{/* Bläddra bland recept per kategori direkt från förstasidan. */}
<Row style={{ flexWrap: "wrap" }}> <Row style={{ flexWrap: "wrap" }}>
{BROWSE_CATS.map((c) => ( <Button
label="🍽️ Rekommenderat"
variant={cat === null ? "secondary" : "ghost"}
onPress={() => setCat(null)}
/>
{CATS.map((c) => (
<Button <Button
key={c.key} key={c.key}
label={`${c.glyph} ${c.labelKey ? t(c.labelKey as never) : (c.label ?? "")}`} label={`${c.glyph} ${c.labelKey ? t(c.labelKey as never) : (c.label ?? "")}`}
variant="ghost" variant={cat === c.key ? "secondary" : "ghost"}
onPress={() => router.push(`/recipes?cat=${c.key}`)} onPress={() => setCat(c.key)}
/> />
))} ))}
<Button label="Alla recept" variant="secondary" onPress={() => router.push("/recipes")} />
</Row> </Row>
<Pressable onPress={() => router.push("/recipes")}>
<Small style={{ color: colors.primary }}>🔍 Alla recept &amp; sök</Small>
</Pressable>
<Spacer size={spacing.sm} /> <Spacer size={spacing.sm} />
{query.isLoading && <LoadingView />} {cat === null ? (
{query.isError && <ErrorView onRetry={() => void query.refetch()} />} // --- Rekommenderat-läge ---
{query.data && (
<> <>
{query.data.context.activeHolidays.length > 0 && ( <Row>
<Row style={{ flexWrap: "wrap" }}> <View style={{ flex: 1 }}>
{query.data.context.activeHolidays.map((holiday) => ( <Input
<Pressable placeholder={t("wte.cravingPlaceholder")}
key={holiday} value={craving}
onPress={() => { onChangeText={setCraving}
setCraving(holiday); onSubmitEditing={() => {
setSubmittedCraving(holiday); setSubmittedCraving(craving);
setPage(0); setPage(0);
}} }}
> returnKeyType="search"
<Tag label={`🎉 ${holiday}`} tone="accent" /> />
</Pressable> </View>
))} </Row>
</Row> <Spacer size={spacing.sm} />
{query.isLoading && <LoadingView />}
{query.isError && (
<ErrorView message={errText(query.error)} onRetry={() => void query.refetch()} />
)} )}
{query.data.mealBoxSuggestions.length > 0 && ( {query.data && (
<> <>
<Heading>{t("wte.mealBoxFirst")}</Heading> {query.data.context.activeHolidays.length > 0 && (
{query.data.mealBoxSuggestions.map((box) => ( <Row style={{ flexWrap: "wrap" }}>
<Card key={box.mealBoxId} onPress={() => router.push("/meal-boxes")}> {query.data.context.activeHolidays.map((holiday) => (
<Row style={{ justifyContent: "space-between" }}> <Pressable
<Body>🍱 {box.titleSv}</Body> key={holiday}
<Tag onPress={() => {
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })} setCraving(holiday);
tone="success" setSubmittedCraving(holiday);
/> setPage(0);
}}
>
<Tag label={`🎉 ${holiday}`} tone="accent" />
</Pressable>
))}
</Row>
)}
{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.titleSv}</Body>
<Tag
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })}
tone="success"
/>
</Row>
<Small>{box.whySv}</Small>
</Card>
))}
<Spacer size={spacing.sm} />
</>
)}
{allRecs.length === 0 && <EmptyState text={t("wte.empty")} />}
{shownRecs.map((rec, index) => (
<Card key={rec.recipeId} onPress={() => router.push(`/recipe/${rec.recipeId}`)}>
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
<Heading>{rec.titleSv}</Heading>
{index === 0 && <Tag label="Toppval" tone="accent" />}
</Row> </Row>
<Small>{box.whySv}</Small> <Row>
{rec.ratingCount > 0 && rec.ratingAverage != null && (
<Tag
label={`${rec.ratingAverage.toFixed(1).replace(".", ",")} · ${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.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> </Card>
))} ))}
<Spacer size={spacing.sm} /> <Spacer size={spacing.sm} />
<Button
label={t("wte.refresh")}
variant="secondary"
loading={query.isFetching}
onPress={() => setPage((p) => p + 1)}
/>
</> </>
)} )}
</>
{allRecs.length === 0 && <EmptyState text={t("wte.empty")} />} ) : (
// --- Kategori-läge (recept filtrerade in-place) ---
{shownRecs.map((rec, index) => ( <>
<Card key={rec.recipeId} onPress={() => router.push(`/recipe/${rec.recipeId}`)}> {browse.isLoading && <LoadingView />}
<Row style={{ justifyContent: "space-between", alignItems: "center" }}> {browse.isError && (
<Heading>{rec.titleSv}</Heading> <ErrorView message={errText(browse.error)} onRetry={() => void browse.refetch()} />
{index === 0 && <Tag label="Toppval" tone="accent" />} )}
</Row> {browse.data &&
<Row> (browse.data.recipes.length === 0 ? (
{rec.ratingCount > 0 && rec.ratingAverage != null && ( <EmptyState text="Inga recept i den här kategorin än." />
<Tag ) : (
label={`${rec.ratingAverage.toFixed(1).replace(".", ",")} · ${rec.ratingCount}`} browse.data.recipes.map((r) => (
tone="accent" <Card
/> key={r.id}
)} onPress={() => router.push(`/recipe/${r.id}`)}
<Tag style={{ gap: spacing.xs }}
label={t("wte.coverage", { pct: rec.coveragePercent })} >
tone={rec.coveragePercent >= 80 ? "success" : "neutral"} <Heading>{r.title ?? r.titleSv}</Heading>
/> <Small>
{rec.usesExpiring.slice(0, 2).map((item) => ( {[
<Tag key={item.nameSv} label={`${item.nameSv}`} tone="warning" /> r.totalTimeMinutes != null ? `${r.totalTimeMinutes} min` : null,
))} r.nutritionPerPortion?.kcal != null
</Row> ? `${Math.round(r.nutritionPerPortion.kcal)} kcal`
<View : null,
style={{ r.ratingCount > 0 && r.ratingAverage != null
backgroundColor: colors.surfaceAlt, ? `${r.ratingAverage.toFixed(1).replace(".", ",")}`
borderRadius: 10, : null,
padding: spacing.sm, ]
marginTop: spacing.xs, .filter(Boolean)
}} .join(" · ")}
> </Small>
<Small>💡 {rec.whySv}</Small> </Card>
</View> ))
{rec.missingIngredients.length > 0 && ( ))}
<Small>
{t("wte.missing", { items: rec.missingIngredients.slice(0, 4).join(", ") })}
</Small>
)}
</Card>
))}
<Spacer size={spacing.sm} />
<Button
label={t("wte.refresh")}
variant="secondary"
loading={query.isFetching}
onPress={() => setPage((p) => p + 1)}
/>
</> </>
)} )}
</Screen> </Screen>