diff --git a/apps/api/src/routes/recommendations.ts b/apps/api/src/routes/recommendations.ts index 834a654..afd7824 100644 --- a/apps/api/src/routes/recommendations.ts +++ b/apps/api/src/routes/recommendations.ts @@ -37,6 +37,66 @@ import { loadLocalePreferences } from "../lib/localeContext.js"; * 4. Matlådor rekommenderas före ny matlagning när rimligt (spec §24). * 5. (Bakom flagga) AAMOS får omranka topplistan – aldrig lägga till recept. */ +/** Kock-alias (spec: named-chef search) inkl. medvetna felstavningar, så att + * sök hittar rätt kock även vid felskrivning ("remsey" → Gordon Ramsay). */ +const CHEF_ALIASES: Record = { + gordon: "Gordon Ramsay", + ramsay: "Gordon Ramsay", + ramsey: "Gordon Ramsay", + remsey: "Gordon Ramsay", + remsay: "Gordon Ramsay", + jamie: "Jamie Oliver", + oliver: "Jamie Oliver", + nigella: "Nigella Lawson", + lawson: "Nigella Lawson", + yotam: "Yotam Ottolenghi", + ottolenghi: "Yotam Ottolenghi", + marcus: "Marcus Samuelsson", + samuelsson: "Marcus Samuelsson", + giada: "Giada De Laurentiis", + laurentiis: "Giada De Laurentiis", + emeril: "Emeril Lagasse", + lagasse: "Emeril Lagasse", + ina: "Ina Garten", + garten: "Ina Garten", + "barefoot contessa": "Ina Garten", + raymond: "Raymond Blanc", + blanc: "Raymond Blanc", + rick: "Rick Stein", + stein: "Rick Stein", +}; + +function resolveChefAliases(search: string): string[] { + const s = search.toLowerCase().trim(); + if (s.length < 3) return []; + const hits = new Set(); + for (const [alias, name] of Object.entries(CHEF_ALIASES)) { + if (s === alias || s.includes(alias) || alias.includes(s)) hits.add(name); + } + return [...hits]; +} + +/** "Menade du …?" – närmaste term via trigram word_similarity mot titlar, + * kocknamn, ingredienser och högtider. Null om inget är nära nog. */ +async function computeSearchSuggestion( + app: FastifyInstance, + search: string, +): Promise { + const result = await app.db.execute(sql` + SELECT term FROM ( + SELECT title_sv AS term FROM recipes WHERE status = 'published' + UNION SELECT DISTINCT creator_display_name FROM recipes WHERE creator_display_name NOT ILIKE '%redaktion%' + UNION SELECT name_sv FROM canonical_ingredients + UNION SELECT name_sv FROM season_events + ) c + WHERE word_similarity(${search}, term) > 0.4 + ORDER BY word_similarity(${search}, term) DESC + LIMIT 1 + `); + const rows = (Array.isArray(result) ? result : result.rows) as Array<{ term: string }>; + return rows[0]?.term ?? null; +} + export async function recommendationRoutes(app: FastifyInstance) { const auth = { preHandler: [app.authenticate] }; @@ -118,6 +178,8 @@ export async function recommendationRoutes(app: FastifyInstance) { // --- 3. Kandidater: publicerade recept för måltidstypen (eller taggen) --- // Baka-fliken skickar tag=baking i stället för en måltidstyp. + // Felstavade kocknamn (t.ex. "remsey") mappas via alias till rätt kock. + const chefMatches = q.search ? resolveChefAliases(q.search) : []; const candidates = await app.db .select() .from(schema.recipes) @@ -139,6 +201,12 @@ export async function recommendationRoutes(app: FastifyInstance) { sql`EXISTS (SELECT 1 FROM recipe_ingredients ri JOIN canonical_ingredients ci ON ci.id = ri.canonical_ingredient_id WHERE ri.recipe_id = ${schema.recipes.id} AND ci.name_sv ILIKE ${"%" + q.search + "%"})`, // Kock/källa: "ramsay"/"ottolenghi" → recept krediterade den kocken. ilike(schema.recipes.creatorDisplayName, `%${q.search}%`), + // Felstavat kocknamn via alias ("remsey" → Gordon Ramsay). + chefMatches.length + ? inArray(schema.recipes.creatorDisplayName, chefMatches) + : sql`false`, + // Fuzzy kocknamn (fångar t.ex. "ramzay"/"otolengi" utan aliaslista). + sql`word_similarity(${q.search}, ${schema.recipes.creatorDisplayName}) > 0.42`, ) : q.holiday ? sql`${q.holiday} = ANY(${schema.recipes.holidayTags})` @@ -528,6 +596,12 @@ export async function recommendationRoutes(app: FastifyInstance) { ]), ); + // "Menade du …?" – bara när en sökning gav noll träffar (grov felstavning). + const searchSuggestion = + q.search && recommendations.length === 0 + ? await computeSearchSuggestion(app, q.search) + : null; + return { mealType: q.mealType, context: { @@ -541,6 +615,7 @@ export async function recommendationRoutes(app: FastifyInstance) { remainingProteinG: ctx.remainingProteinG, craving: craving ?? null, view: q.view, + searchSuggestion, }, mealBoxSuggestions, recommendations: recommendations.map((r) => ({ diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 4cf87d6..5f33cb6 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -51,6 +51,7 @@ interface WhatToEatResponse { activeHolidays: Array<{ slug: string; name: string }>; remainingKcal: number; remainingProteinG: number; + searchSuggestion?: string | null; }; } @@ -244,6 +245,13 @@ export default function WhatToEatScreen() { /> )} + {/* "Menade du …?" – stavningsförslag när sök gav noll träffar. */} + {allRecs.length === 0 && submittedSearch && query.data.context.searchSuggestion && ( + submitSearch(query.data!.context.searchSuggestion!)}> + + + )} + {shownRecs.map((rec, index) => ( router.push(`/recipe/${rec.recipeId}`)}>