export type TaggedRecipe = { id: string; tags: string[]; dietaryTags: Record | null; createdAt: Date; }; /** Collapses a recipe's tags + true dietary-tag keys into one flat tag list. */ function tagSet(recipe: Pick): string[] { const dietary = Object.entries(recipe.dietaryTags ?? {}) .filter(([, v]) => v) .map(([k]) => k); return [...recipe.tags, ...dietary]; } /** Builds a tag → frequency map from the recipes a user has favorited/highly rated. */ export function buildPreferenceMap(likedRecipes: Array>): Map { const map = new Map(); for (const recipe of likedRecipes) { for (const tag of tagSet(recipe)) { map.set(tag, (map.get(tag) ?? 0) + 1); } } return map; } /** * Scores a candidate recipe by how many of its tags overlap with the user's * preference map (weighted by how often that tag shows up in their history). * Recency is used only as a tiebreaker (see rankForYou) — an empty preference * map scores everything 0, which the caller should treat as "fall back to * trending" rather than a meaningful ranking. */ export function scoreCandidate(candidate: Pick, preferences: Map): number { return tagSet(candidate).reduce((sum, tag) => sum + (preferences.get(tag) ?? 0), 0); } export function rankForYou(candidates: T[], preferences: Map): T[] { return [...candidates] .map((c) => ({ c, score: scoreCandidate(c, preferences) })) .sort((a, b) => b.score - a.score || b.c.createdAt.getTime() - a.c.createdAt.getTime()) .map(({ c }) => c); }