Files
Cibello-app/apps/mobile/src/app/(tabs)/index.tsx
T
Claude dc5df13f25 fix(vad-ska-vi-ata): fungerande titelsok, hogtider under sokrutan, ratta bakverk, ta bort separata bladdra-lankar
- Sok fungerar nu: what-to-eat tar ?search= och filtrerar recept pa titel/
  beskrivning BLAND ALLA recept (t.ex. 'pannkaka' hittar pannkakor), oavsett
  vald flik. Sokrutan skickar nu search i stallet for craving.
- Hogtids-/sasongsknapparna (kraftskiva, grill, skolstart) ligger nu direkt
  UNDER sokrutan, inte nere bland recept-forslagen.
- Saffransbullar + lussekatter ur 'Frukost' (mealTypes dessert+breakfast ->
  dessert). Bakverk hor inte till frukost i Sverige.
- Bort med 'Alla recept & sok'-lanken pa forstasidan och 'Bladdra bland recept'
  pa Hemma - allt nas nu via flikarna/soket pa 'Vad ska vi ata'.

Full typecheck gron.
2026-08-19 12:58:59 +00:00

277 lines
9.4 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 { 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;
score: number;
whySv: string;
missingIngredients: string[];
usesExpiring: Array<{ nameSv: string; daysLeft: number | null }>;
coveragePercent: number;
ratingAverage: number | null;
ratingCount: number;
}
interface MealBoxSuggestion {
mealBoxId: string;
titleSv: string;
portionsRemaining: number;
recommendedUseBy: string;
whySv: string;
}
interface WhatToEatResponse {
recommendations: Recommendation[];
mealBoxSuggestions: MealBoxSuggestion[];
context: { activeHolidays: string[]; remainingKcal: number; remainingProteinG: number };
}
// 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: null, label: "Baka", 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 { 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, 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}`;
}
// Sök bland alla recept (titel/beskrivning) oavsett vald flik.
if (submittedSearch) path += `&search=${encodeURIComponent(submittedSearch)}`;
return api<WhatToEatResponse>(path);
},
placeholderData: keepPreviousData,
});
const submitSearch = (value: string) => {
setSearchText(value);
setSubmittedSearch(value.trim());
setPage(0);
};
const selectCat = (value: string | null) => {
setCat(value);
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="Sök recept, t.ex. pannkaka…"
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((holiday) => (
<Pressable key={holiday} onPress={() => submitSearch(holiday)}>
<Tag label={`🎉 ${holiday}`} tone="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="🍽️ Rekommenderat"
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 && 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={submittedSearch ? "Inga recept matchar din sökning." : 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>
{page === 0 && index === 0 && !submittedSearch && (
<Tag label="Toppval" tone="accent" />
)}
</Row>
<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>
))}
{allRecs.length > 5 && (
<>
<Spacer size={spacing.sm} />
<Button
label={t("wte.refresh")}
variant="secondary"
loading={query.isFetching}
onPress={() => setPage((p) => p + 1)}
/>
</>
)}
</>
)}
</Screen>
);
}