180 lines
5.9 KiB
TypeScript
180 lines
5.9 KiB
TypeScript
/**
|
|
* 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<string, unknown>;
|
|
}
|
|
|
|
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<GapReportOptions> = {
|
|
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<string, { count: number; lastAt: Date; queries: Set<string> }>();
|
|
|
|
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<string | null, number>();
|
|
const mealTypeCounts = new Map<string | null, number>();
|
|
|
|
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");
|
|
}
|