PR 3 du chantier admin. Tableau de bord metriques : snapshot de compteurs
+ series temporelles journalieres.
Schema (migration admin_metrics) :
- AnalyticsEvent (type String libre, actorType/actorId sans FK, context Json,
index [type, created_at]) + WorkerHeartbeat (cable en PR 4).
- colonnes createdAt @default(now()) sur UserProfile / Recipe / Planning /
PlanningItem (lecture admin uniquement ; lignes existantes = timestamp de
la migration).
Instrumentation (lib/analytics.service.ts, fire-and-forget) :
- analytics.recordEvent(type, {actorId?, context?}) : retourne void, insert
detache, echec loggue+avale, jamais de latence sur la requete.
- points d'appel : user.signup, recipe.created, recipe.imported,
planning.item_added, tech_step.correction_submitted, shopping_list.viewed.
API : GET /admin/metrics?days= (7-365, defaut 30, requireAdmin) ->
admin-metrics.service.ts. bucketByDay pur (zero-remplissage, teste sans
base). MetricsView dans packages/shared.
Front : DashboardPage (tuiles KPI + un graphe recharts par serie + listes
recettes-par-source / evenements), logique pure dans dashboard.ts, i18n
admin.dashboard.*. AdminApiClient.getMetrics.
reset-db.ts truncate analytics_events + worker_heartbeats.
Tests : Mocha admin-metrics.test.ts (bucketByDay pur x2 verts ; snapshot,
series zero-remplies, event user.signup fire-and-forget) ; Cypress
dashboard.cy.ts (2 verts). specs/backend-architecture.md : section admin.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
242 lines
7.7 KiB
TypeScript
242 lines
7.7 KiB
TypeScript
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<string, number>();
|
||
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<MetricsSnapshotView> {
|
||
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<MetricsView> {
|
||
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<string, Date[]>();
|
||
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
|
||
}
|
||
}
|