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 }; 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() { export default function WhatToEatScreen() {
const [craving, setCraving] = useState(""); const [craving, setCraving] = useState("");
const [submittedCraving, setSubmittedCraving] = useState(""); const [submittedCraving, setSubmittedCraving] = useState("");
@@ -59,17 +75,24 @@ export default function WhatToEatScreen() {
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
// 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({ 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=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 allRecs = query.data?.recommendations ?? [];
const shownRecs: Recommendation[] = allRecs.length 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(() => { useEffect(() => {
@@ -98,13 +121,30 @@ export default function WhatToEatScreen() {
placeholder={t("wte.cravingPlaceholder")} placeholder={t("wte.cravingPlaceholder")}
value={craving} value={craving}
onChangeText={setCraving} onChangeText={setCraving}
onSubmitEditing={() => { setSubmittedCraving(craving); setPage(0); }} onSubmitEditing={() => {
setSubmittedCraving(craving);
setPage(0);
}}
returnKeyType="search" returnKeyType="search"
/> />
</View> </View>
</Row> </Row>
<Spacer size={spacing.sm} /> <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.isLoading && <LoadingView />}
{query.isError && <ErrorView onRetry={() => void query.refetch()} />} {query.isError && <ErrorView onRetry={() => void query.refetch()} />}
@@ -156,7 +196,10 @@ export default function WhatToEatScreen() {
</Row> </Row>
<Row> <Row>
{rec.ratingCount > 0 && rec.ratingAverage != null && ( {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 <Tag
label={t("wte.coverage", { pct: rec.coveragePercent })} 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 { Alert, Pressable, View } from "react-native";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { t } from "@/lib/i18n";
import { formatMinor } from "@/lib/money"; import { formatMinor } from "@/lib/money";
import { import {
@@ -162,8 +162,16 @@ export default function RecipeScreen() {
`${count} ${count === 1 ? "vara" : "varor"} lades till.`, `${count} ${count === 1 ? "vara" : "varor"} lades till.`,
); );
}, },
onError: (err) => onError: (err) => {
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")), // 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 />; if (query.isLoading) return <LoadingView />;
@@ -269,7 +277,10 @@ export default function RecipeScreen() {
</Row> </Row>
</Row> </Row>
{ingredients.map((ing) => ( {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> <Body>
{ing.displayName ?? ing.displayNameSv} {ing.displayName ?? ing.displayNameSv}
{ing.optional ? " (valfritt)" : ""} {ing.optional ? " (valfritt)" : ""}
+43 -4
View File
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { router } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
@@ -63,19 +63,44 @@ const CATEGORIES: ReadonlyArray<{
{ key: "baking", labelKey: null, label: "Baka", tag: "baking", glyph: "🥐" }, { 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() { 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 [search, setSearch] = useState("");
const term = search.trim(); 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({ const query = useQuery({
queryKey: ["browse-recipes", category, term], queryKey: ["browse-recipes", category, sub, term],
queryFn: () => { queryFn: () => {
// Bygg query-strängen som resten av appen (template + encodeURIComponent). // Bygg query-strängen som resten av appen (template + encodeURIComponent).
const cat = CATEGORIES.find((c) => c.key === category); const cat = CATEGORIES.find((c) => c.key === category);
let path = "/v1/recipes?limit=50"; let path = "/v1/recipes?limit=50";
if (cat?.mealType) path += `&mealType=${cat.mealType}`; if (cat?.mealType) path += `&mealType=${cat.mealType}`;
if (cat?.tag) path += `&tags=${cat.tag}`; 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)}`; if (term) path += `&search=${encodeURIComponent(term)}`;
return api<{ recipes: BrowseRecipe[] }>(path); return api<{ recipes: BrowseRecipe[] }>(path);
}, },
@@ -101,11 +126,25 @@ export default function BrowseRecipesScreen() {
key={c.key} key={c.key}
label={`${c.glyph} ${c.labelKey ? t(c.labelKey as never) : (c.label ?? "Alla")}`} label={`${c.glyph} ${c.labelKey ? t(c.labelKey as never) : (c.label ?? "Alla")}`}
variant={category === c.key ? "secondary" : "ghost"} variant={category === c.key ? "secondary" : "ghost"}
onPress={() => setCategory(c.key)} onPress={() => selectCategory(c.key)}
/> />
))} ))}
</Row> </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 ? ( {query.isLoading ? (
<LoadingView /> <LoadingView />
) : query.isError ? ( ) : query.isError ? (
@@ -23,8 +23,8 @@ export const EXTRA_RECIPES: SeedRecipe[] = [
descriptionSv: descriptionSv:
"Mjuka, saftiga bullar med smörig kanelfyllning den självklara fikan att baka en söndag.", "Mjuka, saftiga bullar med smörig kanelfyllning den självklara fikan att baka en söndag.",
cuisine: "swedish", cuisine: "swedish",
mealTypes: ["snack"], mealTypes: ["dessert"],
tags: ["baking", "vegetarian", "kid_friendly"], tags: ["baking", "bun", "vegetarian", "kid_friendly"],
methods: ["oven"], methods: ["oven"],
equipment: ["oven"], equipment: ["oven"],
difficulty: "medium", difficulty: "medium",
@@ -74,8 +74,8 @@ export const EXTRA_RECIPES: SeedRecipe[] = [
titleSv: "Kardemummabullar", titleSv: "Kardemummabullar",
descriptionSv: "Doftande bullar där kardemumman får ta plats både i degen och i fyllningen.", descriptionSv: "Doftande bullar där kardemumman får ta plats både i degen och i fyllningen.",
cuisine: "swedish", cuisine: "swedish",
mealTypes: ["snack"], mealTypes: ["dessert"],
tags: ["baking", "vegetarian", "kid_friendly"], tags: ["baking", "bun", "vegetarian", "kid_friendly"],
methods: ["oven"], methods: ["oven"],
equipment: ["oven"], equipment: ["oven"],
difficulty: "medium", difficulty: "medium",
@@ -121,7 +121,7 @@ export const EXTRA_RECIPES: SeedRecipe[] = [
"Syrliga äpplen under ett gyllene lock av havregryn och smör ljummen med grädde är den oslagbar.", "Syrliga äpplen under ett gyllene lock av havregryn och smör ljummen med grädde är den oslagbar.",
cuisine: "swedish", cuisine: "swedish",
mealTypes: ["dessert"], mealTypes: ["dessert"],
tags: ["baking", "vegetarian"], tags: ["baking", "pie", "vegetarian"],
methods: ["oven"], methods: ["oven"],
equipment: ["oven"], equipment: ["oven"],
difficulty: "beginner", difficulty: "beginner",
@@ -165,7 +165,7 @@ export const EXTRA_RECIPES: SeedRecipe[] = [
"Nygräddade frallor med krispig skorpa och luftigt inkråm perfekta till frukostbordet.", "Nygräddade frallor med krispig skorpa och luftigt inkråm perfekta till frukostbordet.",
cuisine: "swedish", cuisine: "swedish",
mealTypes: ["breakfast"], mealTypes: ["breakfast"],
tags: ["baking", "vegetarian"], tags: ["baking", "bread", "vegetarian"],
methods: ["oven"], methods: ["oven"],
equipment: ["oven"], equipment: ["oven"],
difficulty: "medium", difficulty: "medium",
@@ -690,7 +690,7 @@ export const EXTRA_RECIPES: SeedRecipe[] = [
"Seg, kladdig chokladkaka med krispig yta klar på en kvart plus ugnstid. Godast lite kladdig i mitten.", "Seg, kladdig chokladkaka med krispig yta klar på en kvart plus ugnstid. Godast lite kladdig i mitten.",
cuisine: "swedish", cuisine: "swedish",
mealTypes: ["dessert"], mealTypes: ["dessert"],
tags: ["baking", "vegetarian", "kid_friendly"], tags: ["baking", "cake", "vegetarian", "kid_friendly"],
methods: ["oven"], methods: ["oven"],
equipment: ["oven"], equipment: ["oven"],
difficulty: "beginner", difficulty: "beginner",
@@ -734,8 +734,8 @@ export const EXTRA_RECIPES: SeedRecipe[] = [
descriptionSv: descriptionSv:
"Klassiska, snabba chokladbollar rullade i kokos inget bak, bara att röra ihop.", "Klassiska, snabba chokladbollar rullade i kokos inget bak, bara att röra ihop.",
cuisine: "swedish", cuisine: "swedish",
mealTypes: ["snack"], mealTypes: ["dessert"],
tags: ["baking", "vegetarian", "kid_friendly"], tags: ["baking", "cookie", "vegetarian", "kid_friendly"],
methods: ["no_cook"], methods: ["no_cook"],
equipment: [], equipment: [],
difficulty: "beginner", difficulty: "beginner",
@@ -773,7 +773,7 @@ export const EXTRA_RECIPES: SeedRecipe[] = [
"Söta blåbär under ett gyllene smul av smör och havregryn sommarens självklara paj.", "Söta blåbär under ett gyllene smul av smör och havregryn sommarens självklara paj.",
cuisine: "swedish", cuisine: "swedish",
mealTypes: ["dessert"], mealTypes: ["dessert"],
tags: ["baking", "vegetarian"], tags: ["baking", "pie", "vegetarian"],
methods: ["oven"], methods: ["oven"],
equipment: ["oven"], equipment: ["oven"],
difficulty: "beginner", difficulty: "beginner",
@@ -816,8 +816,8 @@ export const EXTRA_RECIPES: SeedRecipe[] = [
descriptionSv: descriptionSv:
"Luftig, saftig sockerkaka den enklaste kakan att baka och alltid lika uppskattad till fikat.", "Luftig, saftig sockerkaka den enklaste kakan att baka och alltid lika uppskattad till fikat.",
cuisine: "swedish", cuisine: "swedish",
mealTypes: ["snack"], mealTypes: ["dessert"],
tags: ["baking", "vegetarian", "kid_friendly"], tags: ["baking", "cake", "vegetarian", "kid_friendly"],
methods: ["oven"], methods: ["oven"],
equipment: ["oven"], equipment: ["oven"],
difficulty: "beginner", difficulty: "beginner",
@@ -861,7 +861,7 @@ export const EXTRA_RECIPES: SeedRecipe[] = [
descriptionSv: "Saftig, kryddig morotskaka med valnötter mumsig till kaffet.", descriptionSv: "Saftig, kryddig morotskaka med valnötter mumsig till kaffet.",
cuisine: "swedish", cuisine: "swedish",
mealTypes: ["dessert"], mealTypes: ["dessert"],
tags: ["baking", "vegetarian"], tags: ["baking", "cake", "vegetarian"],
methods: ["oven"], methods: ["oven"],
equipment: ["oven"], equipment: ["oven"],
difficulty: "medium", difficulty: "medium",
@@ -905,8 +905,8 @@ export const EXTRA_RECIPES: SeedRecipe[] = [
titleSv: "Vaniljbullar", titleSv: "Vaniljbullar",
descriptionSv: "Mjuka bullar med vaniljfyllning och pärlsocker ett annat namn på söndagsmys.", descriptionSv: "Mjuka bullar med vaniljfyllning och pärlsocker ett annat namn på söndagsmys.",
cuisine: "swedish", cuisine: "swedish",
mealTypes: ["snack"], mealTypes: ["dessert"],
tags: ["baking", "vegetarian", "kid_friendly"], tags: ["baking", "bun", "vegetarian", "kid_friendly"],
methods: ["oven"], methods: ["oven"],
equipment: ["oven"], equipment: ["oven"],
difficulty: "medium", difficulty: "medium",
@@ -35097,7 +35097,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
"Saftiga saffransbullar formade som S, med russin som ögon. Hör Lucia och advent till.", "Saftiga saffransbullar formade som S, med russin som ögon. Hör Lucia och advent till.",
cuisine: "swedish", cuisine: "swedish",
mealTypes: ["dessert", "breakfast"], mealTypes: ["dessert", "breakfast"],
tags: ["baking", "vegetarian", "kid_friendly"], tags: ["baking", "bun", "vegetarian", "kid_friendly"],
methods: ["oven"], methods: ["oven"],
equipment: ["oven"], equipment: ["oven"],
difficulty: "medium", difficulty: "medium",
@@ -35213,7 +35213,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
"Saftiga saffrans- och kardemummabullar fyllda med mandelmassa. Ett festligt bakverk till Lucia och advent.", "Saftiga saffrans- och kardemummabullar fyllda med mandelmassa. Ett festligt bakverk till Lucia och advent.",
cuisine: "swedish", cuisine: "swedish",
mealTypes: ["dessert", "breakfast"], mealTypes: ["dessert", "breakfast"],
tags: ["baking", "vegetarian", "kid_friendly"], tags: ["baking", "bun", "vegetarian", "kid_friendly"],
methods: ["oven"], methods: ["oven"],
equipment: ["oven"], equipment: ["oven"],
difficulty: "medium", difficulty: "medium",
+7
View File
@@ -250,6 +250,13 @@ export const RECIPE_TAGS = [
// Tillagd (spec §14, browse): bakverk bullar, bröd, pajer, kakor. text[]- // Tillagd (spec §14, browse): bakverk bullar, bröd, pajer, kakor. text[]-
// kolumn, ingen migration. Driver "Baka"-kategorin i bläddra-vyn. // kolumn, ingen migration. Driver "Baka"-kategorin i bläddra-vyn.
"baking", "baking",
// Under-typer till "baking" (driver sub-kategorierna under Baka). Ett bakverk
// bär både "baking" och exakt en av dessa, t.ex. ["baking", "bun"].
"bread",
"bun",
"pie",
"cake",
"cookie",
] as const; ] as const;
export type RecipeTag = (typeof RECIPE_TAGS)[number]; export type RecipeTag = (typeof RECIPE_TAGS)[number];