import type { MetricsBreakdownRow, MetricsEventSeries, MetricsSnapshotView, MetricsTimeBucket, MetricsView, } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; /** UTC `YYYY-MM-DD` for a `Date` — the bucket key used by {@link bucketByDay}. */ function utcDayKey(date: Date): string { return date.toISOString().slice(0, 10); } /** Start-of-day (UTC) `Date` that is `daysAgo` days before `from`. */ function startOfUtcDay(from: Date, daysAgo: number): Date { return new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate() - daysAgo)); } /** * Buckets `dates` into `days` consecutive daily counts starting at `since` * (a start-of-UTC-day `Date`). Every day in the window is present, days * with no matching date carry `count: 0`. Pure/synchronous — factored out * so the bucketing is unit-testable without a database, same split as * `aggregateShoppingList`. */ export function bucketByDay(dates: Date[], since: Date, days: number): MetricsTimeBucket[] { const counts = new Map(); for (let i = 0; i < days; i++) { const day = new Date(since.getTime() + i * 86_400_000); counts.set(utcDayKey(day), 0); } for (const date of dates) { const key = utcDayKey(date); const current = counts.get(key); if (current !== undefined) counts.set(key, current + 1); } return [...counts.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([date, count]) => ({ date, count })); } /** Shapes a Prisma `groupBy ... _count` result into the frontend's `{ key, label, count }` rows, sorted by count desc. */ function toBreakdown( rows: { key: string | null; count: number }[], labelFor: (key: string) => string = (key) => key, ): MetricsBreakdownRow[] { return rows .map(({ key, count }) => { const resolved = key ?? "unknown"; return { key: resolved, label: labelFor(resolved), count }; }) .sort((a, b) => b.count - a.count); } /** Every point-in-time `COUNT` for the KPI tiles — see {@link MetricsSnapshotView}. */ async function getSnapshot(): Promise { try { const [ admins, users, households, activeHouseholdGroups, recipes, recipesManual, recipesImported, recipeBySourceGroups, sources, plannings, planningItems, steps, detectedTechniques, favorites, corrections, correctionsUnconsumed, correctionsRemoval, trainingSuggestions, suggestionStatusGroups, suggestionSourceTypeGroups, ] = await Promise.all([ prisma.adminUser.count(), prisma.userProfile.count(), prisma.house.count(), prisma.planning.groupBy({ by: ["houseId"] }), prisma.recipe.count(), prisma.recipe.count({ where: { sourceId: null } }), prisma.recipe.count({ where: { sourceId: { not: null } } }), prisma.recipe.groupBy({ by: ["sourceId"], where: { sourceId: { not: null } }, _count: { _all: true }, }), prisma.source.findMany({ select: { id: true, key: true, name: true } }), prisma.planning.count(), prisma.planningItem.count(), prisma.step.count(), prisma.stepTechStep.count(), prisma.recipeFavorite.count(), prisma.stepTechStepCorrection.count(), prisma.stepTechStepCorrection.count({ where: { consumedAt: null } }), prisma.stepTechStepCorrection.count({ where: { correctedTechStepId: null } }), prisma.techStepTrainingSuggestion.count(), prisma.techStepTrainingSuggestion.groupBy({ by: ["status"], _count: { _all: true } }), prisma.techStepTrainingSuggestion.groupBy({ by: ["sourceType"], _count: { _all: true } }), ]); const sourceById = new Map(sources.map((source) => [source.id, source])); return { admins, users, households, activeHouseholds: activeHouseholdGroups.length, recipes, recipesManual, recipesImported, recipesBySource: toBreakdown( recipeBySourceGroups.map((group) => ({ key: group.sourceId === null ? null : (sourceById.get(group.sourceId)?.key ?? null), count: group._count._all, })), (key) => { const source = sources.find((s) => s.key === key); return source ? source.name : key; }, ), plannings, planningItems, steps, detectedTechniques, favorites, corrections, correctionsUnconsumed, correctionsRemoval, trainingSuggestions, trainingSuggestionsByStatus: toBreakdown( suggestionStatusGroups.map((group) => ({ key: group.status, count: group._count._all })), ), trainingSuggestionsBySourceType: toBreakdown( suggestionSourceTypeGroups.map((group) => ({ key: group.sourceType, count: group._count._all, })), ), }; } catch (err) { throw err; // see recipe.service.ts's equivalent catch comment } } /** * Builds the admin dashboard's full metrics payload — a `snapshot` of * current totals plus `rangeDays` days of daily time series, derived from * the `createdAt` columns the `admin_metrics` migration added and from the * `AnalyticsEvent` table. `days` is the caller-validated `?days=` value * (see `getMetricsSchema`, 7–365). */ export async function getMetrics(days: number): Promise { try { const now = new Date(); const since = startOfUtcDay(now, days - 1); const [ snapshot, signups, recipesCreated, planningItemsAdded, correctionsSubmitted, trainingSuggestions, eventRows, ] = await Promise.all([ getSnapshot(), prisma.userProfile.findMany({ where: { createdAt: { gte: since } }, select: { createdAt: true }, }), prisma.recipe.findMany({ where: { createdAt: { gte: since } }, select: { createdAt: true } }), prisma.planningItem.findMany({ where: { createdAt: { gte: since } }, select: { createdAt: true }, }), prisma.stepTechStepCorrection.findMany({ where: { createdAt: { gte: since } }, select: { createdAt: true }, }), prisma.techStepTrainingSuggestion.findMany({ where: { createdAt: { gte: since } }, select: { createdAt: true }, }), prisma.analyticsEvent.findMany({ where: { createdAt: { gte: since } }, select: { type: true, createdAt: true }, }), ]); const eventsByType = new Map(); for (const row of eventRows) { const list = eventsByType.get(row.type); if (list) list.push(row.createdAt); else eventsByType.set(row.type, [row.createdAt]); } const events: MetricsEventSeries[] = [...eventsByType.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([type, dates]) => ({ type, buckets: bucketByDay(dates, since, days) })); return { generatedAt: now.toISOString(), rangeDays: days, snapshot, series: { signups: bucketByDay( signups.map((r) => r.createdAt), since, days, ), recipesCreated: bucketByDay( recipesCreated.map((r) => r.createdAt), since, days, ), planningItemsAdded: bucketByDay( planningItemsAdded.map((r) => r.createdAt), since, days, ), correctionsSubmitted: bucketByDay( correctionsSubmitted.map((r) => r.createdAt), since, days, ), trainingSuggestions: bucketByDay( trainingSuggestions.map((r) => r.createdAt), since, days, ), }, events, }; } catch (err) { throw err; // see recipe.service.ts's equivalent catch comment } }