/** * Katalog-gaprapport (docs/32 §4). * * Byggs ur consent-gatad nollträff-bankning + cook/rating-signaler. * Identifierar vad som saknas i receptkatalogen så påfyllningen kan * styras mot verklig efterfrågan. */ import type { GapReport, GapReportEntry } from "./types.js"; export interface AnalyticsEvent { eventName: string; occurredAt: Date; properties: Record; } export interface RecipeSignal { recipeId: string | null; titleSv: string | null; cookCount: number; ratingAverage: number | null; ratingCount: number; searchQuery?: string | null; } export interface GapReportOptions { /** Minsta antal nollträffar för att en sökning ska räknas. */ minMissThreshold?: number; /** Hur långt bak i tiden (dagar). */ lookbackDays?: number; /** Max antal entries i rapporten. */ maxEntries?: number; } const DEFAULT_GAP_OPTIONS: Required = { minMissThreshold: 2, lookbackDays: 30, maxEntries: 50, }; /** * Bygg gaprapport ur analytics-events och receptsignaler. * * @param searchMissEvents productAnalyticsEvents med eventName = "recipe_search_zero_results" * @param cookSignals recipe_cooks aggregerade per recept * @param ratingSignals recipe_ratings aggregerade per recept * @param options Filter och begränsningar */ export function buildGapReport( searchMissEvents: AnalyticsEvent[], cookSignals: RecipeSignal[], ratingSignals: RecipeSignal[], options: GapReportOptions = {}, ): GapReport { const opts = { ...DEFAULT_GAP_OPTIONS, ...options }; const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - opts.lookbackDays); // ── 1. Samla nollträffar ───────────────────────────────────────────────── const missMap = new Map }>(); for (const ev of searchMissEvents) { if (ev.occurredAt < cutoff) continue; const query = String(ev.properties?.query ?? "") .toLowerCase() .trim(); if (!query) continue; const ingredientId = ev.properties?.suggestedIngredientId ? String(ev.properties.suggestedIngredientId) : null; const mealType = ev.properties?.mealType ? String(ev.properties.mealType) : null; const key = `${query}::${ingredientId ?? "_"}::${mealType ?? "_"}`; const existing = missMap.get(key); if (existing) { existing.count++; existing.queries.add(query); if (ev.occurredAt > existing.lastAt) existing.lastAt = ev.occurredAt; } else { missMap.set(key, { count: 1, lastAt: ev.occurredAt, queries: new Set([query]), }); } } // ── 2. Filtrera och rangordna ──────────────────────────────────────────── const entries: GapReportEntry[] = []; for (const [key, data] of missMap) { if (data.count < opts.minMissThreshold) continue; const parts = key.split("::"); const ingredientId = parts[1] ?? null; const mealType = parts[2] ?? null; entries.push({ searchQuery: [...data.queries][0] ?? "", missCount: data.count, lastMissedAt: data.lastAt, suggestedIngredientId: ingredientId === "_" ? null : ingredientId, suggestedMealType: mealType === "_" ? null : mealType, priority: data.count >= 10 ? "high" : data.count >= 5 ? "medium" : "low", }); } // Sortera: high → medium → low, sedan antal entries.sort((a, b) => { const pOrder = { high: 0, medium: 1, low: 2 }; if (pOrder[a.priority] !== pOrder[b.priority]) { return pOrder[a.priority] - pOrder[b.priority]; } return b.missCount - a.missCount; }); const limitedEntries = entries.slice(0, opts.maxEntries); // ── 3. Aggregera topp-saknade ──────────────────────────────────────────── const ingredientCounts = new Map(); const mealTypeCounts = new Map(); for (const e of limitedEntries) { ingredientCounts.set( e.suggestedIngredientId, (ingredientCounts.get(e.suggestedIngredientId) ?? 0) + e.missCount, ); mealTypeCounts.set( e.suggestedMealType, (mealTypeCounts.get(e.suggestedMealType) ?? 0) + e.missCount, ); } const topMissingIngredients = [...ingredientCounts.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([id, count]) => ({ canonicalIngredientId: id as string | null, count })); const topMissingMealTypes = [...mealTypeCounts.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([mealType, count]) => ({ mealType: mealType as string | null, count })); return { generatedAt: new Date(), entries: limitedEntries, topMissingIngredients, topMissingMealTypes, }; } /** * Formatera gaprapporten som läsbar text för granskning. */ export function formatGapReport(report: GapReport): string { const lines: string[] = [ `# Katalog-gaprapport`, `Genererad: ${report.generatedAt.toISOString()}`, ``, `## Topp-saknade ingredienser`, ...report.topMissingIngredients.map( (i) => `- ${i.canonicalIngredientId ?? "(okänd)"}: ${i.count} nollträffar`, ), ``, `## Topp-saknade måltidstyper`, ...report.topMissingMealTypes.map( (m) => `- ${m.mealType ?? "(okänd)"}: ${m.count} nollträffar`, ), ``, `## Detaljer (${report.entries.length} entries)`, ...report.entries.map( (e) => `- [${e.priority.toUpperCase()}] "${e.searchQuery}" — ${e.missCount} missar, senast ${e.lastMissedAt.toISOString().slice(0, 10)}${e.suggestedIngredientId ? ` (ingrediens: ${e.suggestedIngredientId})` : ""}${e.suggestedMealType ? ` (måltid: ${e.suggestedMealType})` : ""}`, ), ]; return lines.join("\n"); }