fix(skan+sok): robust kvitto-enheter, titel-/fuzzy-sok, hogtidsfilter

Kvitto-skanning (rapporterad krasch 'svar matchar inte schema … invalid value'):
- AI-skan-enheter (kvitto/kyl/maltid) normaliseras nu fore validering:
  'st'->COUNT, 'kg'->KILOGRAM, 'forp'/'pase'->PACKAGE osv. Okand enhet -> null
  i stallet for att falla hela svaret. Skarpt kvitto-prompt med exakt enhetslista.
  17 nya tester bevisar normaliseringen.

Sok pa 'Vad ska vi ata':
- Endast TITELN (inte beskrivningen) -> 'pannkaka' ger inte langre appelkompott
  (som bara namnde pannkakor i beskrivningen).
- Stavningstolerant via trigram-similarity: 'pankakor' hittar pannkakor.

Hogtidsknapparna (kraftskiva/grill/jul ...):
- Filtrerar nu pa recept-holidayTag (slug) i stallet for en titelsok som gav
  tomt. Har event inga recept an visas ett arligt tomt-lage.

Full typecheck gron, ai-contracts 24 tester grona.
This commit is contained in:
Claude
2026-08-19 14:09:45 +00:00
parent dc5df13f25
commit de279a7c01
6 changed files with 225 additions and 37 deletions
+12 -6
View File
@@ -125,15 +125,19 @@ export async function recommendationRoutes(app: FastifyInstance) {
and(
eq(schema.recipes.status, "published"),
// Titelsök (t.ex. "pannkaka") söker BLAND ALLA recept, oavsett flik.
// Endast TITELN (inte beskrivningen) för relevans, och trigram-
// similarity gör söket stavningstolerant ("pankakor" hittar pannkakor).
// Utan sökterm filtreras i stället på flikens tagg/måltidstyp.
q.search
? or(
ilike(schema.recipes.titleSv, `%${q.search}%`),
ilike(schema.recipes.descriptionSv, `%${q.search}%`),
sql`similarity(${schema.recipes.titleSv}, ${q.search}) > 0.3`,
)
: q.tag
? sql`${q.tag} = ANY(${schema.recipes.tags})`
: sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`,
: q.holiday
? sql`${q.holiday} = ANY(${schema.recipes.holidayTags})`
: q.tag
? sql`${q.tag} = ANY(${schema.recipes.tags})`
: sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`,
),
)
.limit(200);
@@ -211,7 +215,6 @@ export async function recommendationRoutes(app: FastifyInstance) {
isEventActive({ dateRule: e.dateRule, leadDays: e.leadDays }, today),
);
const activeHolidayTags = activeEvents.map((e) => e.slug); // slugs → matchar recept-holidayTags (scoring)
const activeHolidayNames = activeEvents.map((e) => e.nameLocalized || e.nameSv); // lokaliserat namn, fallback nameSv
// --- 6. Senast lagat (variation) + hushållsbetyg ---
const cooks = householdId
@@ -504,7 +507,10 @@ export async function recommendationRoutes(app: FastifyInstance) {
context: {
persons: ctx.persons,
season: ctx.currentSeason,
activeHolidays: activeHolidayNames,
activeHolidays: activeEvents.map((e) => ({
slug: e.slug,
name: e.nameLocalized || e.nameSv,
})),
remainingKcal: ctx.remainingKcal,
remainingProteinG: ctx.remainingProteinG,
craving: craving ?? null,
+53 -26
View File
@@ -47,7 +47,11 @@ interface MealBoxSuggestion {
interface WhatToEatResponse {
recommendations: Recommendation[];
mealBoxSuggestions: MealBoxSuggestion[];
context: { activeHolidays: string[]; remainingKcal: number; remainingProteinG: number };
context: {
activeHolidays: Array<{ slug: string; name: string }>;
remainingKcal: number;
remainingProteinG: number;
};
}
// Kategori-flikar. Alla använder SAMMA rekommendations-motor (what-to-eat) så
@@ -79,6 +83,7 @@ export default function WhatToEatScreen() {
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();
@@ -87,7 +92,7 @@ export default function WhatToEatScreen() {
const activeCat = cat === null ? null : CATS.find((c) => c.key === cat);
const query = useQuery({
queryKey: ["what-to-eat", cat, submittedSearch, currentMeal],
queryKey: ["what-to-eat", cat, holiday, submittedSearch, currentMeal],
queryFn: () => {
let path = "/v1/recommendations/what-to-eat?limit=20&view=default";
if (activeCat?.tag) {
@@ -95,20 +100,33 @@ export default function WhatToEatScreen() {
} else {
path += `&mealType=${activeCat?.mealType ?? currentMeal}`;
}
// Sök bland alla recept (titel/beskrivning) oavsett vald flik.
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);
};
@@ -155,9 +173,9 @@ export default function WhatToEatScreen() {
<>
<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" />
{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>
@@ -192,28 +210,37 @@ export default function WhatToEatScreen() {
{query.data && (
<>
{cat === null && !submittedSearch && 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} />
</>
)}
{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 ? "Inga recept matchar din sökning." : t("wte.empty")}
text={
submittedSearch
? "Inga recept matchar din sökning."
: holiday
? "Inga recept för den här högtiden än."
: t("wte.empty")
}
/>
)}
@@ -221,7 +248,7 @@ export default function WhatToEatScreen() {
<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 && !submittedSearch && (
{page === 0 && index === 0 && !submittedSearch && !holiday && (
<Tag label="Toppval" tone="accent" />
)}
</Row>