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:
@@ -116,14 +116,17 @@ export async function recommendationRoutes(app: FastifyInstance) {
|
|||||||
const strictestSpice = Math.min(...allPrefs.map((p) => p.spiceLevelMax), 5);
|
const strictestSpice = Math.min(...allPrefs.map((p) => p.spiceLevelMax), 5);
|
||||||
const myPrefs = allPrefs.find((p) => p.userId === req.userId);
|
const myPrefs = allPrefs.find((p) => p.userId === req.userId);
|
||||||
|
|
||||||
// --- 3. Kandidater: publicerade recept för måltidstypen ---
|
// --- 3. Kandidater: publicerade recept för måltidstypen (eller taggen) ---
|
||||||
|
// Baka-fliken skickar tag=baking i stället för en måltidstyp.
|
||||||
const candidates = await app.db
|
const candidates = await app.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.recipes)
|
.from(schema.recipes)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(schema.recipes.status, "published"),
|
eq(schema.recipes.status, "published"),
|
||||||
sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`,
|
q.tag
|
||||||
|
? sql`${q.tag} = ANY(${schema.recipes.tags})`
|
||||||
|
: sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(200);
|
.limit(200);
|
||||||
|
|||||||
@@ -49,25 +49,18 @@ interface WhatToEatResponse {
|
|||||||
mealBoxSuggestions: MealBoxSuggestion[];
|
mealBoxSuggestions: MealBoxSuggestion[];
|
||||||
context: { activeHolidays: string[]; remainingKcal: number; remainingProteinG: number };
|
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;
|
// Kategori-flikar. Alla använder SAMMA rekommendations-motor (what-to-eat) så
|
||||||
// annars filtreras recepten IN-PLACE på samma sida (ingen navigering bort).
|
// vyn blir identisk överallt: sök, %-hemma, stjärnor och "Visa fler". De flesta
|
||||||
// De flesta filtrerar på måltidstyp; "Baka" på taggen "baking".
|
// 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<{
|
const CATS: ReadonlyArray<{
|
||||||
key: string;
|
key: string;
|
||||||
labelKey: string | null;
|
labelKey: string | null;
|
||||||
label?: string;
|
label?: string;
|
||||||
mealType?: string;
|
mealType?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
ctxMeal?: string;
|
||||||
glyph: string;
|
glyph: string;
|
||||||
}> = [
|
}> = [
|
||||||
{ key: "breakfast", labelKey: "myday.mealType.breakfast", mealType: "breakfast", glyph: "🥣" },
|
{ 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: "dinner", labelKey: "myday.mealType.dinner", mealType: "dinner", glyph: "🍽️" },
|
||||||
{ key: "snack", labelKey: "myday.mealType.snack", mealType: "snack", glyph: "🍎" },
|
{ key: "snack", labelKey: "myday.mealType.snack", mealType: "snack", glyph: "🍎" },
|
||||||
{ key: "dessert", labelKey: "myday.mealType.dessert", mealType: "dessert", 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 =>
|
const errText = (e: unknown): string | undefined =>
|
||||||
@@ -91,31 +84,28 @@ 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";
|
||||||
|
|
||||||
// Personliga rekommendationer (bara i "Rekommenderat"-läget). view=default ger
|
const activeCat = cat === null ? null : CATS.find((c) => c.key === cat);
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Kategori-bläddring in-place (när en flik är vald).
|
const query = useQuery({
|
||||||
const browse = useQuery({
|
queryKey: ["what-to-eat", cat, submittedCraving, currentMeal],
|
||||||
queryKey: ["wte-browse", cat],
|
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const c = CATS.find((x) => x.key === cat);
|
let path = "/v1/recommendations/what-to-eat?limit=20&view=default";
|
||||||
let path = "/v1/recipes?limit=50";
|
if (activeCat?.tag) {
|
||||||
if (c?.mealType) path += `&mealType=${c.mealType}`;
|
path += `&tag=${activeCat.tag}&mealType=${activeCat.ctxMeal ?? "dessert"}`;
|
||||||
if (c?.tag) path += `&tags=${c.tag}`;
|
} else {
|
||||||
return api<{ recipes: BrowseRecipe[] }>(path);
|
path += `&mealType=${activeCat?.mealType ?? currentMeal}`;
|
||||||
|
}
|
||||||
|
if (submittedCraving) path += `&craving=${encodeURIComponent(submittedCraving)}`;
|
||||||
|
return api<WhatToEatResponse>(path);
|
||||||
},
|
},
|
||||||
enabled: cat !== null,
|
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectCat = (value: string | null) => {
|
||||||
|
setCat(value);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
const allRecs = query.data?.recommendations ?? [];
|
const allRecs = query.data?.recommendations ?? [];
|
||||||
const shownRecs: Recommendation[] = allRecs.length
|
const shownRecs: Recommendation[] = allRecs.length
|
||||||
? Array.from(
|
? Array.from(
|
||||||
@@ -144,19 +134,34 @@ export default function WhatToEatScreen() {
|
|||||||
<Small>{t("wte.subtitle")}</Small>
|
<Small>{t("wte.subtitle")}</Small>
|
||||||
<Spacer size={spacing.sm} />
|
<Spacer size={spacing.sm} />
|
||||||
|
|
||||||
{/* Kategori-flikar – filtrerar på DENNA sida, ingen navigering bort. */}
|
{/* Sök (fungerar i alla flikar) */}
|
||||||
<Row style={{ flexWrap: "wrap" }}>
|
<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
|
<Button
|
||||||
label="🍽️ Rekommenderat"
|
label="🍽️ Rekommenderat"
|
||||||
variant={cat === null ? "secondary" : "ghost"}
|
variant={cat === null ? "secondary" : "ghost"}
|
||||||
onPress={() => setCat(null)}
|
onPress={() => selectCat(null)}
|
||||||
|
style={{ width: "48%", marginBottom: spacing.xs }}
|
||||||
/>
|
/>
|
||||||
{CATS.map((c) => (
|
{CATS.map((c) => (
|
||||||
<Button
|
<Button
|
||||||
key={c.key}
|
key={c.key}
|
||||||
label={`${c.glyph} ${c.labelKey ? t(c.labelKey as never) : (c.label ?? "")}`}
|
label={`${c.glyph} ${c.labelKey ? t(c.labelKey as never) : (c.label ?? "")}`}
|
||||||
variant={cat === c.key ? "secondary" : "ghost"}
|
variant={cat === c.key ? "secondary" : "ghost"}
|
||||||
onPress={() => setCat(c.key)}
|
onPress={() => selectCat(c.key)}
|
||||||
|
style={{ width: "48%", marginBottom: spacing.xs }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
@@ -165,109 +170,92 @@ export default function WhatToEatScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
<Spacer size={spacing.sm} />
|
<Spacer size={spacing.sm} />
|
||||||
|
|
||||||
{cat === null ? (
|
{query.isLoading && <LoadingView />}
|
||||||
// --- Rekommenderat-läge ---
|
{query.isError && (
|
||||||
<>
|
<ErrorView message={errText(query.error)} onRetry={() => void query.refetch()} />
|
||||||
<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.data && (
|
||||||
{query.isError && (
|
<>
|
||||||
<ErrorView message={errText(query.error)} onRetry={() => void query.refetch()} />
|
{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 && (
|
<Heading>{t("wte.mealBoxFirst")}</Heading>
|
||||||
<Row style={{ flexWrap: "wrap" }}>
|
{query.data.mealBoxSuggestions.map((box) => (
|
||||||
{query.data.context.activeHolidays.map((holiday) => (
|
<Card key={box.mealBoxId} onPress={() => router.push("/meal-boxes")}>
|
||||||
<Pressable
|
<Row style={{ justifyContent: "space-between" }}>
|
||||||
key={holiday}
|
<Body>🍱 {box.titleSv}</Body>
|
||||||
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"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Tag
|
<Tag
|
||||||
label={t("wte.coverage", { pct: rec.coveragePercent })}
|
label={t("mealbox.portionsLeft", { count: box.portionsRemaining })}
|
||||||
tone={rec.coveragePercent >= 80 ? "success" : "neutral"}
|
tone="success"
|
||||||
/>
|
/>
|
||||||
{rec.usesExpiring.slice(0, 2).map((item) => (
|
|
||||||
<Tag key={item.nameSv} label={`⏳ ${item.nameSv}`} tone="warning" />
|
|
||||||
))}
|
|
||||||
</Row>
|
</Row>
|
||||||
<View
|
<Small>{box.whySv}</Small>
|
||||||
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>
|
</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} />
|
<Spacer size={spacing.sm} />
|
||||||
<Button
|
<Button
|
||||||
label={t("wte.refresh")}
|
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>
|
</Screen>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -96,7 +96,12 @@ export function Card({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={withHaptic(onPress)}
|
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}
|
{children}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -110,12 +115,14 @@ export function Button({
|
|||||||
variant = "primary",
|
variant = "primary",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
style,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
}) {
|
}) {
|
||||||
const bg =
|
const bg =
|
||||||
variant === "primary"
|
variant === "primary"
|
||||||
@@ -133,14 +140,25 @@ export function Button({
|
|||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.button,
|
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 },
|
variant === "ghost" && { borderWidth: 1, borderColor: colors.border },
|
||||||
|
style,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator color={fg} />
|
<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>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { MEAL_TYPES } from "@app/shared-types";
|
import { MEAL_TYPES, RECIPE_TAGS } from "@app/shared-types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Vad ska vi äta?" (spec §18) + "Jag är sugen på" (spec §19).
|
* "Vad ska vi äta?" (spec §18) + "Jag är sugen på" (spec §19).
|
||||||
@@ -7,6 +7,9 @@ import { MEAL_TYPES } from "@app/shared-types";
|
|||||||
*/
|
*/
|
||||||
export const whatToEatQuerySchema = z.object({
|
export const whatToEatQuerySchema = z.object({
|
||||||
mealType: z.enum(MEAL_TYPES).default("dinner"),
|
mealType: z.enum(MEAL_TYPES).default("dinner"),
|
||||||
|
/** Filtrera kandidater på en recept-tagg (t.ex. "baking" för Baka-fliken)
|
||||||
|
* i stället för måltidstyp. mealType används fortfarande för poängsättning. */
|
||||||
|
tag: z.enum(RECIPE_TAGS).optional(),
|
||||||
persons: z.coerce.number().int().min(1).max(20).optional(),
|
persons: z.coerce.number().int().min(1).max(20).optional(),
|
||||||
maxMinutes: z.coerce.number().int().min(5).max(600).optional(),
|
maxMinutes: z.coerce.number().int().min(5).max(600).optional(),
|
||||||
maxCostMinorPerPortion: z.coerce.number().int().min(0).max(100_000).optional(),
|
maxCostMinorPerPortion: z.coerce.number().int().min(0).max(100_000).optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user