feat(vad-ska-vi-ata): identisk vy i alla flikar + enhetliga rutor

Alla kategori-flikar anvander nu SAMMA rekommendations-motor (what-to-eat)
som 'Rekommenderat', sa vyn blir identisk overallt:
- sok fungerar i alla flikar, %-hemma och stjarnor visas, samma kort-layout,
  och samma paginering med 'Visa fler' (5 at gangen).
- Backend: what-to-eat tar nu valfri ?tag= (Baka-fliken filtrerar pa 'baking'
  i stallet for maltidstyp; mealType styr bara poangsattningen).
- Enhetliga chip-rutor: Button far en style-prop, flikarna ligger i 2 kolumner
  med lika bred ruta (48%), centrerad text som krymper vid behov.
- 'Toppval' visas bara pa forsta sidan. 'Alla recept & sok' kvar som lank.

Full typecheck 20/20.
This commit is contained in:
Claude
2026-08-19 12:29:21 +00:00
parent 0a16f1fefa
commit 3d8bd68fe0
4 changed files with 147 additions and 170 deletions
+117 -164
View File
@@ -49,25 +49,18 @@ interface WhatToEatResponse {
mealBoxSuggestions: MealBoxSuggestion[];
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;
}
// Kategori-flikar direkt på förstasidan. null = personliga rekommendationer;
// annars filtreras recepten IN-PLACE på samma sida (ingen navigering bort).
// De flesta filtrerar på måltidstyp; "Baka" på taggen "baking".
// Kategori-flikar. Alla använder SAMMA rekommendations-motor (what-to-eat) så
// vyn blir identisk överallt: sök, %-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: "🥣" },
@@ -75,7 +68,7 @@ const CATS: ReadonlyArray<{
{ 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", glyph: "🥐" },
{ key: "baking", labelKey: null, label: "Baka", tag: "baking", ctxMeal: "dessert", glyph: "🥐" },
];
const errText = (e: unknown): string | undefined =>
@@ -91,31 +84,28 @@ export default function WhatToEatScreen() {
const hour = new Date().getHours();
const currentMeal = hour < 10 ? "breakfast" : hour < 14 ? "lunch" : "dinner";
// Personliga rekommendationer (bara i "Rekommenderat"-läget). view=default ger
// en balanserad rankning; limit=20 är schemats maxgräns.
const query = useQuery({
queryKey: ["what-to-eat", submittedCraving, currentMeal],
queryFn: () =>
api<WhatToEatResponse>(
`/v1/recommendations/what-to-eat?limit=20&view=default&mealType=${currentMeal}${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`,
),
enabled: cat === null,
});
const activeCat = cat === null ? null : CATS.find((c) => c.key === cat);
// Kategori-bläddring in-place (när en flik är vald).
const browse = useQuery({
queryKey: ["wte-browse", cat],
const query = useQuery({
queryKey: ["what-to-eat", cat, submittedCraving, currentMeal],
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);
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 (submittedCraving) path += `&craving=${encodeURIComponent(submittedCraving)}`;
return api<WhatToEatResponse>(path);
},
enabled: cat !== null,
placeholderData: keepPreviousData,
});
const selectCat = (value: string | null) => {
setCat(value);
setPage(0);
};
const allRecs = query.data?.recommendations ?? [];
const shownRecs: Recommendation[] = allRecs.length
? Array.from(
@@ -144,19 +134,34 @@ export default function WhatToEatScreen() {
<Small>{t("wte.subtitle")}</Small>
<Spacer size={spacing.sm} />
{/* Kategori-flikar filtrerar på DENNA sida, ingen navigering bort. */}
<Row style={{ flexWrap: "wrap" }}>
{/* Sök (fungerar i alla flikar) */}
<Input
placeholder={t("wte.cravingPlaceholder")}
value={craving}
onChangeText={setCraving}
onSubmitEditing={() => {
setSubmittedCraving(craving);
setPage(0);
}}
returnKeyType="search"
/>
<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={() => setCat(null)}
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={() => setCat(c.key)}
onPress={() => selectCat(c.key)}
style={{ width: "48%", marginBottom: spacing.xs }}
/>
))}
</Row>
@@ -165,109 +170,92 @@ export default function WhatToEatScreen() {
</Pressable>
<Spacer size={spacing.sm} />
{cat === null ? (
// --- Rekommenderat-läge ---
<>
<Row>
<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} />
{query.isLoading && <LoadingView />}
{query.isError && (
<ErrorView message={errText(query.error)} onRetry={() => void query.refetch()} />
)}
{query.isLoading && <LoadingView />}
{query.isError && (
<ErrorView message={errText(query.error)} onRetry={() => void query.refetch()} />
{query.data && (
<>
{query.data.context.activeHolidays.length > 0 && (
<Row style={{ flexWrap: "wrap" }}>
{query.data.context.activeHolidays.map((holiday) => (
<Pressable
key={holiday}
onPress={() => {
setCraving(holiday);
setSubmittedCraving(holiday);
setPage(0);
}}
>
<Tag label={`🎉 ${holiday}`} tone="accent" />
</Pressable>
))}
</Row>
)}
{query.data && (
{cat === null && query.data.mealBoxSuggestions.length > 0 && (
<>
{query.data.context.activeHolidays.length > 0 && (
<Row style={{ flexWrap: "wrap" }}>
{query.data.context.activeHolidays.map((holiday) => (
<Pressable
key={holiday}
onPress={() => {
setCraving(holiday);
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>
{rec.ratingCount > 0 && rec.ratingAverage != null && (
<Tag
label={`${rec.ratingAverage.toFixed(1).replace(".", ",")} · ${rec.ratingCount}`}
tone="accent"
/>
)}
<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("wte.coverage", { pct: rec.coveragePercent })}
tone={rec.coveragePercent >= 80 ? "success" : "neutral"}
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })}
tone="success"
/>
{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>
)}
<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>
{page === 0 && index === 0 && <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 > 0 && (
<>
<Spacer size={spacing.sm} />
<Button
label={t("wte.refresh")}
@@ -278,41 +266,6 @@ export default function WhatToEatScreen() {
</>
)}
</>
) : (
// --- Kategori-läge (recept filtrerade in-place) ---
<>
{browse.isLoading && <LoadingView />}
{browse.isError && (
<ErrorView message={errText(browse.error)} onRetry={() => void browse.refetch()} />
)}
{browse.data &&
(browse.data.recipes.length === 0 ? (
<EmptyState text="Inga recept i den här kategorin än." />
) : (
browse.data.recipes.map((r) => (
<Card
key={r.id}
onPress={() => router.push(`/recipe/${r.id}`)}
style={{ gap: spacing.xs }}
>
<Heading>{r.title ?? r.titleSv}</Heading>
<Small>
{[
r.totalTimeMinutes != null ? `${r.totalTimeMinutes} min` : null,
r.nutritionPerPortion?.kcal != null
? `${Math.round(r.nutritionPerPortion.kcal)} kcal`
: null,
r.ratingCount > 0 && r.ratingAverage != null
? `${r.ratingAverage.toFixed(1).replace(".", ",")}`
: null,
]
.filter(Boolean)
.join(" · ")}
</Small>
</Card>
))
))}
</>
)}
</Screen>
);
+21 -3
View File
@@ -96,7 +96,12 @@ export function Card({
return (
<Pressable
onPress={withHaptic(onPress)}
style={({ pressed }) => [styles.card, style, pressed && { opacity: 0.85, transform: [{ scale: 0.98 }] }]}>
style={({ pressed }) => [
styles.card,
style,
pressed && { opacity: 0.85, transform: [{ scale: 0.98 }] },
]}
>
{children}
</Pressable>
);
@@ -110,12 +115,14 @@ export function Button({
variant = "primary",
disabled = false,
loading = false,
style,
}: {
label: string;
onPress: () => void;
variant?: "primary" | "secondary" | "ghost" | "danger";
disabled?: boolean;
loading?: boolean;
style?: StyleProp<ViewStyle>;
}) {
const bg =
variant === "primary"
@@ -133,14 +140,25 @@ export function Button({
disabled={disabled || loading}
style={({ pressed }) => [
styles.button,
{ backgroundColor: bg, opacity: disabled ? 0.5 : pressed ? 0.85 : 1, transform: [{ scale: pressed ? 0.97 : 1 }] },
{
backgroundColor: bg,
opacity: disabled ? 0.5 : pressed ? 0.85 : 1,
transform: [{ scale: pressed ? 0.97 : 1 }],
},
variant === "ghost" && { borderWidth: 1, borderColor: colors.border },
style,
]}
>
{loading ? (
<ActivityIndicator color={fg} />
) : (
<Text style={{ color: fg, fontWeight: "600", fontSize: 15 }}>{label}</Text>
<Text
numberOfLines={1}
adjustsFontSizeToFit
style={{ color: fg, fontWeight: "600", fontSize: 15, textAlign: "center" }}
>
{label}
</Text>
)}
</Pressable>
);