Files
Cibello-app/apps/mobile/src/app/(tabs)/index.tsx
T
Claude 2d59a3e636 i18n(app-svep) + i18n-vakt: hela mobil-UI:t översatt + CI-vakt mot regress
- Svepte 51 hårdkodade strängar -> t() över konto, sök/bläddra, sparade recept,
  kök, planera, skanna, byt måltid, inköp, minne, cooking, api-fel (+ locLabel/
  kategorier via nycklar). 47 nya nycklar, översatta till alla 12 språk.
- NY: apps/mobile/scripts/i18n-check.mjs (i18n:check) – fäller bygget om ett språk
  saknar en nyckel, en använd nyckel saknas, eller det finns hårdkodad UI-text.
  Wire:ad i CI (.github/workflows/ci.yml) efter typecheck. => 'lägg till språk'
  blir: kör vakten, den listar exakt vad som fattas. Aldrig mer leta för hand.
- Prettier-fixade 5 filer från tidigare leveranser så format:check blir grön.
- submit-recipe fri-text-payload + firebase dev-stubs markerade // i18n-ignore.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 01:50:08 +00:00

316 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { Pressable, View } from "react-native";
import { router } from "expo-router";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { api, ApiError } from "@/lib/api";
import { useAnalytics } from "@/lib/analytics";
import { recommendationsViewed } from "@app/analytics";
import { t } from "@/lib/i18n";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
Heading,
Input,
LoadingView,
Row,
Screen,
Small,
Spacer,
Tag,
Title,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
/** "Vad ska vi äta?" (spec §4.1, §18) appens viktigaste vy. */
interface Recommendation {
recipeId: string;
titleSv: string;
title?: string;
score: number;
whySv: string;
missingIngredients: string[];
usesExpiring: Array<{ nameSv: string; name?: string; daysLeft: number | null }>;
coveragePercent: number;
ratingAverage: number | null;
ratingCount: number;
}
interface MealBoxSuggestion {
mealBoxId: string;
titleSv: string;
portionsRemaining: number;
recommendedUseBy: string;
whySv: string;
}
interface WhatToEatResponse {
recommendations: Recommendation[];
mealBoxSuggestions: MealBoxSuggestion[];
context: {
activeHolidays: Array<{ slug: string; name: string }>;
remainingKcal: number;
remainingProteinG: number;
searchSuggestion?: string | null;
};
}
// Kategori-flikar. Alla använder SAMMA rekommendations-motor (what-to-eat) så
// vyn blir identisk överallt: %-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: "🥣" },
{ key: "lunch", labelKey: "myday.mealType.lunch", mealType: "lunch", glyph: "🥗" },
{ 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: "cat.baking", tag: "baking", ctxMeal: "dessert", glyph: "🥐" },
];
const errText = (e: unknown): string | undefined =>
e instanceof ApiError ? `[${e.status}] ${e.message}` : e instanceof Error ? e.message : undefined;
export default function WhatToEatScreen() {
const [searchText, setSearchText] = useState("");
const [submittedSearch, setSubmittedSearch] = useState("");
const [page, setPage] = useState(0);
const [cat, setCat] = useState<string | null>(null); // null = Rekommenderat
const [holiday, setHoliday] = useState<string | null>(null); // vald högtid (slug)
const { track } = useAnalytics();
const hour = new Date().getHours();
const currentMeal = hour < 10 ? "breakfast" : hour < 14 ? "lunch" : "dinner";
const activeCat = cat === null ? null : CATS.find((c) => c.key === cat);
const query = useQuery({
queryKey: ["what-to-eat", cat, holiday, submittedSearch, currentMeal],
queryFn: () => {
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 (holiday) path += `&holiday=${encodeURIComponent(holiday)}`;
// Sök bland alla recept (på titel) oavsett vald flik/högtid.
if (submittedSearch) path += `&search=${encodeURIComponent(submittedSearch)}`;
return api<WhatToEatResponse>(path);
},
placeholderData: keepPreviousData,
});
// Sök, flik och högtid är ömsesidigt uteslutande ett aktivt filter i taget.
const submitSearch = (value: string) => {
setSearchText(value);
setSubmittedSearch(value.trim());
setHoliday(null);
setPage(0);
};
const selectCat = (value: string | null) => {
setCat(value);
setHoliday(null);
setSearchText("");
setSubmittedSearch("");
setPage(0);
};
const selectHoliday = (slug: string) => {
setHoliday((cur) => (cur === slug ? null : slug)); // toggla av/på
setCat(null);
setSearchText("");
setSubmittedSearch("");
setPage(0);
};
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)
: [];
useEffect(() => {
if (query.data) {
track(
recommendationsViewed({
properties: {
craving: submittedSearch || undefined,
count: query.data.recommendations.length,
hasMealBoxSuggestions: query.data.mealBoxSuggestions.length > 0,
},
}),
);
}
}, [query.data]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<Screen>
<Title>{t("wte.title")}</Title>
<Small>{t("wte.subtitle")}</Small>
<Spacer size={spacing.sm} />
{/* Sök bland recept (fungerar i alla flikar, söker på namn). */}
<Input
placeholder={t("wte.searchPlaceholder")}
value={searchText}
onChangeText={setSearchText}
onSubmitEditing={() => submitSearch(searchText)}
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((h) => (
<Pressable key={h.slug} onPress={() => selectHoliday(h.slug)}>
<Tag label={`🎉 ${h.name}`} tone={holiday === h.slug ? "success" : "accent"} />
</Pressable>
))}
</Row>
</>
)}
<Spacer size={spacing.sm} />
{/* Kategori-flikar med enhetliga rutor (2 kolumner). Filtrerar in-place. */}
<Row style={{ flexWrap: "wrap", justifyContent: "space-between" }}>
<Button
label={`🍽️ ${t("wte.recommended")}`}
variant={cat === null ? "secondary" : "ghost"}
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={() => selectCat(c.key)}
style={{ width: "48%", marginBottom: spacing.xs }}
/>
))}
</Row>
<Spacer size={spacing.sm} />
{query.isLoading && <LoadingView />}
{query.isError && (
<ErrorView message={errText(query.error)} onRetry={() => void query.refetch()} />
)}
{query.data && (
<>
{cat === null &&
!submittedSearch &&
!holiday &&
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={
submittedSearch
? t("wte.emptySearch")
: holiday
? t("wte.emptyHoliday")
: t("wte.empty")
}
/>
)}
{/* "Menade du …?" stavningsförslag när sök gav noll träffar. */}
{allRecs.length === 0 && submittedSearch && query.data.context.searchSuggestion && (
<Pressable onPress={() => submitSearch(query.data!.context.searchSuggestion!)}>
<Tag
label={t("wte.didYouMean", { suggestion: query.data.context.searchSuggestion })}
tone="accent"
/>
</Pressable>
)}
{shownRecs.map((rec, index) => (
<Card key={rec.recipeId} onPress={() => router.push(`/recipe/${rec.recipeId}`)}>
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
<Heading>{rec.title ?? rec.titleSv}</Heading>
{page === 0 && index === 0 && !submittedSearch && !holiday && (
<Tag label={t("wte.topPick")} 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.name ?? 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 > 5 && (
<>
<Spacer size={spacing.sm} />
<Button
label={t("wte.refresh")}
variant="secondary"
loading={query.isFetching}
onPress={() => setPage((p) => p + 1)}
/>
</>
)}
</>
)}
</Screen>
);
}