fix(recept): bakverk ur mellanmal + sub-kategorier, bladdra pa forstasidan, relevantare forslag

Kategorisering (din notis - bakverk hor till Baka, inte mellanmal):
- Sub-typer bread/bun/pie/cake/cookie i RECIPE_TAGS.
- Bullar/kakor/pajer/chokladbollar -> dessert (inte snack) + sub-tagg; brod
  -> breakfast. Befintliga lussekatter/saffransbullar taggas 'bun'.
- Browse: sub-chips under Baka (Brod/Bullar/Paj/Tartor & kakor/Smakakor).

Bladdra pa forstasidan (din onskan):
- Kategori-genvagar pa 'Vad ska vi ata' -> oppnar bladdraren forvald
  (/recipes?cat=). recipes.tsx laser ?cat=.

Relevantare 'Vad ska vi ata' (snurrar samma triviala):
- view=pantry -> view=default (tackning vager tungt men inte allenaradande,
  ger variation), limit 15 -> 30 (storre pool sa 'Visa fler' upprepar senare).

Felsokning inkopslista:
- Tydligare felmeddelande (HTTP-status vs natverksfel) sa root cause syns.

Full typecheck 20/20, invariant-test gront.
This commit is contained in:
Claude
2026-08-19 00:20:30 +00:00
parent 654174e188
commit 242e09d59b
6 changed files with 129 additions and 29 deletions
+47 -4
View File
@@ -50,6 +50,22 @@ interface WhatToEatResponse {
context: { activeHolidays: string[]; remainingKcal: number; remainingProteinG: number };
}
// Bläddra-genvägar direkt på förstasidan (öppnar recept-bläddraren förvald på
// kategorin). Etiketter återanvänder myday.mealType.* där de finns.
const BROWSE_CATS: ReadonlyArray<{
key: string;
labelKey: string | null;
label?: string;
glyph: string;
}> = [
{ key: "breakfast", labelKey: "myday.mealType.breakfast", glyph: "🥣" },
{ key: "lunch", labelKey: "myday.mealType.lunch", glyph: "🥗" },
{ key: "dinner", labelKey: "myday.mealType.dinner", glyph: "🍽️" },
{ key: "snack", labelKey: "myday.mealType.snack", glyph: "🍎" },
{ key: "dessert", labelKey: "myday.mealType.dessert", glyph: "🍰" },
{ key: "baking", labelKey: null, label: "Baka", glyph: "🥐" },
];
export default function WhatToEatScreen() {
const [craving, setCraving] = useState("");
const [submittedCraving, setSubmittedCraving] = useState("");
@@ -59,17 +75,24 @@ export default function WhatToEatScreen() {
const hour = new Date().getHours();
const currentMeal = hour < 10 ? "breakfast" : hour < 14 ? "lunch" : "dinner";
// view=default ger en balanserad rankning (täckning väger tungt men inte
// allenarådande som i pantry-vyn) → mer varierade, relevanta förslag i stället
// 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({
queryKey: ["what-to-eat", submittedCraving, currentMeal],
queryFn: () =>
api<WhatToEatResponse>(
`/v1/recommendations/what-to-eat?limit=15&view=pantry&mealType=${currentMeal}${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`,
`/v1/recommendations/what-to-eat?limit=30&view=default&mealType=${currentMeal}${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`,
),
});
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)
? Array.from(
{ length: Math.min(5, allRecs.length) },
(_, i) => allRecs[(page * 5 + i) % allRecs.length],
).filter((r): r is Recommendation => r != null)
: [];
useEffect(() => {
@@ -98,13 +121,30 @@ export default function WhatToEatScreen() {
placeholder={t("wte.cravingPlaceholder")}
value={craving}
onChangeText={setCraving}
onSubmitEditing={() => { setSubmittedCraving(craving); setPage(0); }}
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" }}>
{BROWSE_CATS.map((c) => (
<Button
key={c.key}
label={`${c.glyph} ${c.labelKey ? t(c.labelKey as never) : (c.label ?? "")}`}
variant="ghost"
onPress={() => router.push(`/recipes?cat=${c.key}`)}
/>
))}
<Button label="Alla recept" variant="secondary" onPress={() => router.push("/recipes")} />
</Row>
<Spacer size={spacing.sm} />
{query.isLoading && <LoadingView />}
{query.isError && <ErrorView onRetry={() => void query.refetch()} />}
@@ -156,7 +196,10 @@ export default function WhatToEatScreen() {
</Row>
<Row>
{rec.ratingCount > 0 && rec.ratingAverage != null && (
<Tag label={`${rec.ratingAverage.toFixed(1).replace(".", ",")} · ${rec.ratingCount}`} tone="accent" />
<Tag
label={`${rec.ratingAverage.toFixed(1).replace(".", ",")} · ${rec.ratingCount}`}
tone="accent"
/>
)}
<Tag
label={t("wte.coverage", { pct: rec.coveragePercent })}
+15 -4
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { Alert, Pressable, View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { api, ApiError } from "@/lib/api";
import { t } from "@/lib/i18n";
import { formatMinor } from "@/lib/money";
import {
@@ -162,8 +162,16 @@ export default function RecipeScreen() {
`${count} ${count === 1 ? "vara" : "varor"} lades till.`,
);
},
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
onError: (err) => {
// Visa tydlig diagnostik: HTTP-status vs nätverksfel (hjälper felsökning).
const detail =
err instanceof ApiError
? `[${err.status}] ${err.message}`
: err instanceof Error
? err.message
: t("common.error");
Alert.alert(t("common.oops"), detail);
},
});
if (query.isLoading) return <LoadingView />;
@@ -269,7 +277,10 @@ export default function RecipeScreen() {
</Row>
</Row>
{ingredients.map((ing) => (
<Row key={ing.id ?? (ing.displayName ?? ing.displayNameSv)} style={{ justifyContent: "space-between" }}>
<Row
key={ing.id ?? ing.displayName ?? ing.displayNameSv}
style={{ justifyContent: "space-between" }}
>
<Body>
{ing.displayName ?? ing.displayNameSv}
{ing.optional ? " (valfritt)" : ""}
+43 -4
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { router } from "expo-router";
import { router, useLocalSearchParams } from "expo-router";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
@@ -63,19 +63,44 @@ const CATEGORIES: ReadonlyArray<{
{ key: "baking", labelKey: null, label: "Baka", tag: "baking", glyph: "🥐" },
];
// Under-kategorier som visas när "Baka" är vald. Varje bakverk bär både taggen
// "baking" och en under-typ, så vi kan filtrera på under-typen direkt.
const BAKING_SUBS: ReadonlyArray<{ key: string; label: string; tag?: string }> = [
{ key: "all", label: "Alla" },
{ key: "bread", label: "Bröd", tag: "bread" },
{ key: "bun", label: "Bullar", tag: "bun" },
{ key: "pie", label: "Paj", tag: "pie" },
{ key: "cake", label: "Tårtor & kakor", tag: "cake" },
{ key: "cookie", label: "Småkakor", tag: "cookie" },
];
export default function BrowseRecipesScreen() {
const [category, setCategory] = useState<string>("all");
// Förvald kategori via ?cat= (från förstasidans genvägar); annars "Alla".
const { cat } = useLocalSearchParams<{ cat?: string }>();
const initialCategory = CATEGORIES.some((c) => c.key === cat) ? (cat as string) : "all";
const [category, setCategory] = useState<string>(initialCategory);
const [sub, setSub] = useState<string>("all"); // under-kategori, bara relevant för Baka
const [search, setSearch] = useState("");
const term = search.trim();
const selectCategory = (key: string) => {
setCategory(key);
if (key !== "baking") setSub("all"); // nollställ under-val när man lämnar Baka
};
const query = useQuery({
queryKey: ["browse-recipes", category, term],
queryKey: ["browse-recipes", category, sub, term],
queryFn: () => {
// Bygg query-strängen som resten av appen (template + encodeURIComponent).
const cat = CATEGORIES.find((c) => c.key === category);
let path = "/v1/recipes?limit=50";
if (cat?.mealType) path += `&mealType=${cat.mealType}`;
if (cat?.tag) path += `&tags=${cat.tag}`;
// Under Baka: filtrera på vald under-typ (bullar/bröd/…) i stället för "baking".
if (category === "baking" && sub !== "all") {
const subTag = BAKING_SUBS.find((s) => s.key === sub)?.tag;
if (subTag) path = `/v1/recipes?limit=50&tags=${subTag}`;
}
if (term) path += `&search=${encodeURIComponent(term)}`;
return api<{ recipes: BrowseRecipe[] }>(path);
},
@@ -101,11 +126,25 @@ export default function BrowseRecipesScreen() {
key={c.key}
label={`${c.glyph} ${c.labelKey ? t(c.labelKey as never) : (c.label ?? "Alla")}`}
variant={category === c.key ? "secondary" : "ghost"}
onPress={() => setCategory(c.key)}
onPress={() => selectCategory(c.key)}
/>
))}
</Row>
{/* Under-kategorier: visas bara när Baka är valt (bröd/bullar/paj/…). */}
{category === "baking" && (
<Row style={{ flexWrap: "wrap" }}>
{BAKING_SUBS.map((s) => (
<Button
key={s.key}
label={s.label}
variant={sub === s.key ? "secondary" : "ghost"}
onPress={() => setSub(s.key)}
/>
))}
</Row>
)}
{query.isLoading ? (
<LoadingView />
) : query.isError ? (