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.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
|
import { and, desc, eq, gt, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
||||||
import { schema } from "@app/database";
|
import { schema } from "@app/database";
|
||||||
import type { MemoryItem, TasteSignal } from "@app/shared-types";
|
import type { MemoryItem, TasteSignal } from "@app/shared-types";
|
||||||
import { whatToEatQuerySchema } from "@app/validation";
|
import { whatToEatQuerySchema } from "@app/validation";
|
||||||
@@ -124,9 +124,16 @@ export async function recommendationRoutes(app: FastifyInstance) {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(schema.recipes.status, "published"),
|
eq(schema.recipes.status, "published"),
|
||||||
q.tag
|
// Titelsök (t.ex. "pannkaka") söker BLAND ALLA recept, oavsett flik.
|
||||||
? sql`${q.tag} = ANY(${schema.recipes.tags})`
|
// Utan sökterm filtreras i stället på flikens tagg/måltidstyp.
|
||||||
: sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`,
|
q.search
|
||||||
|
? or(
|
||||||
|
ilike(schema.recipes.titleSv, `%${q.search}%`),
|
||||||
|
ilike(schema.recipes.descriptionSv, `%${q.search}%`),
|
||||||
|
)
|
||||||
|
: q.tag
|
||||||
|
? sql`${q.tag} = ANY(${schema.recipes.tags})`
|
||||||
|
: sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(200);
|
.limit(200);
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ export default function HomeScreen() {
|
|||||||
// Distinkta platser (Kyl/Frys/Skafferi …) för genvägsknappar in i Matlager.
|
// Distinkta platser (Kyl/Frys/Skafferi …) för genvägsknappar in i Matlager.
|
||||||
const locations = useMemo(() => {
|
const locations = useMemo(() => {
|
||||||
const seen = new Map<string, string>();
|
const seen = new Map<string, string>();
|
||||||
for (const it of items) if (!seen.has(it.locationType)) seen.set(it.locationType, it.locationName);
|
for (const it of items)
|
||||||
|
if (!seen.has(it.locationType)) seen.set(it.locationType, it.locationName);
|
||||||
return [...seen.entries()]
|
return [...seen.entries()]
|
||||||
.map(([type, name]) => ({ type, name }))
|
.map(([type, name]) => ({ type, name }))
|
||||||
.sort((a, b) => orderIndex(a.type) - orderIndex(b.type));
|
.sort((a, b) => orderIndex(a.type) - orderIndex(b.type));
|
||||||
@@ -97,7 +98,12 @@ export default function HomeScreen() {
|
|||||||
<Screen>
|
<Screen>
|
||||||
<Title>{t("home.title")}</Title>
|
<Title>{t("home.title")}</Title>
|
||||||
{needsProfile && (
|
{needsProfile && (
|
||||||
<Card onPress={() => { setOnboardingStep(profileStep); router.push("/onboarding"); }}>
|
<Card
|
||||||
|
onPress={() => {
|
||||||
|
setOnboardingStep(profileStep);
|
||||||
|
router.push("/onboarding");
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Heading>{t("home.completeProfile.title")}</Heading>
|
<Heading>{t("home.completeProfile.title")}</Heading>
|
||||||
<Small>{t("home.completeProfile.body")}</Small>
|
<Small>{t("home.completeProfile.body")}</Small>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -125,18 +131,9 @@ export default function HomeScreen() {
|
|||||||
<QuickLink label={t("profile.title")} glyph="⚙️" onPress={() => router.push("/profile")} />
|
<QuickLink label={t("profile.title")} glyph="⚙️" onPress={() => router.push("/profile")} />
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row>
|
<Card onPress={() => router.push("/saved-recipes")}>
|
||||||
<View style={{ flex: 1 }}>
|
<Body>⭐ Dina sparade recept</Body>
|
||||||
<Card onPress={() => router.push("/recipes")}>
|
</Card>
|
||||||
<Body>📖 Bläddra bland recept</Body>
|
|
||||||
</Card>
|
|
||||||
</View>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Card onPress={() => router.push("/saved-recipes")}>
|
|
||||||
<Body>⭐ Sparade recept</Body>
|
|
||||||
</Card>
|
|
||||||
</View>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{/* Matlager – städat, öppnas per plats eller som helhet (spec §8). */}
|
{/* Matlager – städat, öppnas per plats eller som helhet (spec §8). */}
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ interface WhatToEatResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Kategori-flikar. Alla använder SAMMA rekommendations-motor (what-to-eat) så
|
// 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
|
// vyn blir identisk överallt: %-hemma, stjärnor och "Visa fler". De flesta
|
||||||
// filtrerar på måltidstyp; "Baka" på taggen "baking" (ctxMeal styr bara
|
// filtrerar på måltidstyp; "Baka" på taggen "baking" (ctxMeal styr bara
|
||||||
// poängsättningen så bakverk inte nedviktas som "fel måltid").
|
// poängsättningen så bakverk inte nedviktas som "fel måltid").
|
||||||
const CATS: ReadonlyArray<{
|
const CATS: ReadonlyArray<{
|
||||||
@@ -75,8 +75,8 @@ const errText = (e: unknown): string | undefined =>
|
|||||||
e instanceof ApiError ? `[${e.status}] ${e.message}` : e instanceof Error ? e.message : 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 [searchText, setSearchText] = useState("");
|
||||||
const [submittedCraving, setSubmittedCraving] = useState("");
|
const [submittedSearch, setSubmittedSearch] = useState("");
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [cat, setCat] = useState<string | null>(null); // null = Rekommenderat
|
const [cat, setCat] = useState<string | null>(null); // null = Rekommenderat
|
||||||
const { track } = useAnalytics();
|
const { track } = useAnalytics();
|
||||||
@@ -87,7 +87,7 @@ export default function WhatToEatScreen() {
|
|||||||
const activeCat = cat === null ? null : CATS.find((c) => c.key === cat);
|
const activeCat = cat === null ? null : CATS.find((c) => c.key === cat);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["what-to-eat", cat, submittedCraving, currentMeal],
|
queryKey: ["what-to-eat", cat, submittedSearch, currentMeal],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
let path = "/v1/recommendations/what-to-eat?limit=20&view=default";
|
let path = "/v1/recommendations/what-to-eat?limit=20&view=default";
|
||||||
if (activeCat?.tag) {
|
if (activeCat?.tag) {
|
||||||
@@ -95,12 +95,18 @@ export default function WhatToEatScreen() {
|
|||||||
} else {
|
} else {
|
||||||
path += `&mealType=${activeCat?.mealType ?? currentMeal}`;
|
path += `&mealType=${activeCat?.mealType ?? currentMeal}`;
|
||||||
}
|
}
|
||||||
if (submittedCraving) path += `&craving=${encodeURIComponent(submittedCraving)}`;
|
// Sök bland alla recept (titel/beskrivning) oavsett vald flik.
|
||||||
|
if (submittedSearch) path += `&search=${encodeURIComponent(submittedSearch)}`;
|
||||||
return api<WhatToEatResponse>(path);
|
return api<WhatToEatResponse>(path);
|
||||||
},
|
},
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const submitSearch = (value: string) => {
|
||||||
|
setSearchText(value);
|
||||||
|
setSubmittedSearch(value.trim());
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
const selectCat = (value: string | null) => {
|
const selectCat = (value: string | null) => {
|
||||||
setCat(value);
|
setCat(value);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
@@ -119,7 +125,7 @@ export default function WhatToEatScreen() {
|
|||||||
track(
|
track(
|
||||||
recommendationsViewed({
|
recommendationsViewed({
|
||||||
properties: {
|
properties: {
|
||||||
craving: submittedCraving || undefined,
|
craving: submittedSearch || undefined,
|
||||||
count: query.data.recommendations.length,
|
count: query.data.recommendations.length,
|
||||||
hasMealBoxSuggestions: query.data.mealBoxSuggestions.length > 0,
|
hasMealBoxSuggestions: query.data.mealBoxSuggestions.length > 0,
|
||||||
},
|
},
|
||||||
@@ -134,17 +140,29 @@ export default function WhatToEatScreen() {
|
|||||||
<Small>{t("wte.subtitle")}</Small>
|
<Small>{t("wte.subtitle")}</Small>
|
||||||
<Spacer size={spacing.sm} />
|
<Spacer size={spacing.sm} />
|
||||||
|
|
||||||
{/* Sök (fungerar i alla flikar) */}
|
{/* Sök bland recept (fungerar i alla flikar, söker på namn). */}
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("wte.cravingPlaceholder")}
|
placeholder="Sök recept, t.ex. pannkaka…"
|
||||||
value={craving}
|
value={searchText}
|
||||||
onChangeText={setCraving}
|
onChangeText={setSearchText}
|
||||||
onSubmitEditing={() => {
|
onSubmitEditing={() => submitSearch(searchText)}
|
||||||
setSubmittedCraving(craving);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
returnKeyType="search"
|
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} />
|
<Spacer size={spacing.sm} />
|
||||||
|
|
||||||
{/* Kategori-flikar med enhetliga rutor (2 kolumner). Filtrerar in-place. */}
|
{/* Kategori-flikar med enhetliga rutor (2 kolumner). Filtrerar in-place. */}
|
||||||
@@ -165,9 +183,6 @@ export default function WhatToEatScreen() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
<Pressable onPress={() => router.push("/recipes")}>
|
|
||||||
<Small style={{ color: colors.primary }}>🔍 Alla recept & sök</Small>
|
|
||||||
</Pressable>
|
|
||||||
<Spacer size={spacing.sm} />
|
<Spacer size={spacing.sm} />
|
||||||
|
|
||||||
{query.isLoading && <LoadingView />}
|
{query.isLoading && <LoadingView />}
|
||||||
@@ -177,24 +192,7 @@ export default function WhatToEatScreen() {
|
|||||||
|
|
||||||
{query.data && (
|
{query.data && (
|
||||||
<>
|
<>
|
||||||
{query.data.context.activeHolidays.length > 0 && (
|
{cat === null && !submittedSearch && query.data.mealBoxSuggestions.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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{cat === null && query.data.mealBoxSuggestions.length > 0 && (
|
|
||||||
<>
|
<>
|
||||||
<Heading>{t("wte.mealBoxFirst")}</Heading>
|
<Heading>{t("wte.mealBoxFirst")}</Heading>
|
||||||
{query.data.mealBoxSuggestions.map((box) => (
|
{query.data.mealBoxSuggestions.map((box) => (
|
||||||
@@ -213,13 +211,19 @@ export default function WhatToEatScreen() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{allRecs.length === 0 && <EmptyState text={t("wte.empty")} />}
|
{allRecs.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
text={submittedSearch ? "Inga recept matchar din sökning." : t("wte.empty")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{shownRecs.map((rec, index) => (
|
{shownRecs.map((rec, index) => (
|
||||||
<Card key={rec.recipeId} onPress={() => router.push(`/recipe/${rec.recipeId}`)}>
|
<Card key={rec.recipeId} onPress={() => router.push(`/recipe/${rec.recipeId}`)}>
|
||||||
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
|
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||||
<Heading>{rec.titleSv}</Heading>
|
<Heading>{rec.titleSv}</Heading>
|
||||||
{page === 0 && index === 0 && <Tag label="Toppval" tone="accent" />}
|
{page === 0 && index === 0 && !submittedSearch && (
|
||||||
|
<Tag label="Toppval" tone="accent" />
|
||||||
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
{rec.ratingCount > 0 && rec.ratingAverage != null && (
|
{rec.ratingCount > 0 && rec.ratingAverage != null && (
|
||||||
@@ -254,7 +258,7 @@ export default function WhatToEatScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{allRecs.length > 0 && (
|
{allRecs.length > 5 && (
|
||||||
<>
|
<>
|
||||||
<Spacer size={spacing.sm} />
|
<Spacer size={spacing.sm} />
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -35096,7 +35096,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
|||||||
descriptionSv:
|
descriptionSv:
|
||||||
"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"],
|
||||||
tags: ["baking", "bun", "vegetarian", "kid_friendly"],
|
tags: ["baking", "bun", "vegetarian", "kid_friendly"],
|
||||||
methods: ["oven"],
|
methods: ["oven"],
|
||||||
equipment: ["oven"],
|
equipment: ["oven"],
|
||||||
@@ -35212,7 +35212,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
|||||||
descriptionSv:
|
descriptionSv:
|
||||||
"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"],
|
||||||
tags: ["baking", "bun", "vegetarian", "kid_friendly"],
|
tags: ["baking", "bun", "vegetarian", "kid_friendly"],
|
||||||
methods: ["oven"],
|
methods: ["oven"],
|
||||||
equipment: ["oven"],
|
equipment: ["oven"],
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const whatToEatQuerySchema = z.object({
|
|||||||
maxCostMinorPerPortion: z.coerce.number().int().min(0).max(100_000).optional(),
|
maxCostMinorPerPortion: z.coerce.number().int().min(0).max(100_000).optional(),
|
||||||
/** Fritext eller röst-transkription: "krämigt", "asiatiskt", "under 500 kcal" … */
|
/** Fritext eller röst-transkription: "krämigt", "asiatiskt", "under 500 kcal" … */
|
||||||
craving: z.string().max(300).optional(),
|
craving: z.string().max(300).optional(),
|
||||||
|
/** Titel-/beskrivningssök: filtrerar kandidaterna på namn (t.ex. "pannkaka"). */
|
||||||
|
search: z.string().max(120).optional(),
|
||||||
includeLeftovers: z.coerce.boolean().default(true),
|
includeLeftovers: z.coerce.boolean().default(true),
|
||||||
limit: z.coerce.number().int().min(1).max(20).default(5),
|
limit: z.coerce.number().int().min(1).max(20).default(5),
|
||||||
/** Personaliseringsvy: smak, hälsa eller lager. Kräver personalization-samtycke. */
|
/** Personaliseringsvy: smak, hälsa eller lager. Kräver personalization-samtycke. */
|
||||||
|
|||||||
Reference in New Issue
Block a user