import type { Metadata } from "next"; import { db, users, recipes, userUsage, supportTickets, gte, sql } from "@epicure/db"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { BarChart } from "@/components/admin/charts/bar-chart"; import { TimeSeriesChart } from "@/components/admin/charts/time-series-chart"; export const metadata: Metadata = {}; const DAYS = 30; function lastNDays(n: number): string[] { const out: string[] = []; const now = new Date(); for (let i = n - 1; i >= 0; i--) { const d = new Date(now); d.setDate(d.getDate() - i); out.push(d.toISOString().slice(0, 10)); } return out; } function formatShortDate(d: string) { const date = new Date(`${d}T00:00:00Z`); return date.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" }); } function lastNMonths(n: number): string[] { const out: string[] = []; const now = new Date(); for (let i = n - 1; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); out.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`); } return out; } function formatMonth(m: string) { const [y, mo] = m.split("-"); return new Date(Number(y), Number(mo) - 1, 1).toLocaleDateString(undefined, { month: "short", year: "2-digit" }); } export default async function AdminInsightsPage() { const since = new Date(); since.setDate(since.getDate() - DAYS); // Promise.allSettled, not all — six independent aggregate queries feeding // six independent charts; one query breaking (e.g. a table that's empty // in a fresh install) shouldn't take down every chart on the page. const results = await Promise.allSettled([ db .select({ day: sql`to_char(${users.createdAt}, 'YYYY-MM-DD')`.as("day"), n: sql`count(*)::int` }) .from(users) .where(gte(users.createdAt, since)) .groupBy(sql`to_char(${users.createdAt}, 'YYYY-MM-DD')`), db .select({ day: sql`to_char(${recipes.createdAt}, 'YYYY-MM-DD')`.as("day"), aiGenerated: recipes.aiGenerated, n: sql`count(*)::int`, }) .from(recipes) .where(gte(recipes.createdAt, since)) .groupBy(sql`to_char(${recipes.createdAt}, 'YYYY-MM-DD')`, recipes.aiGenerated), db.select({ tier: users.tier, n: sql`count(*)::int` }).from(users).groupBy(users.tier), db.select({ visibility: recipes.visibility, n: sql`count(*)::int` }).from(recipes).groupBy(recipes.visibility), db .select({ month: userUsage.month, n: sql`coalesce(sum(${userUsage.aiCallsUsed}), 0)::int` }) .from(userUsage) .groupBy(userUsage.month), db.select({ status: supportTickets.status, n: sql`count(*)::int` }).from(supportTickets).groupBy(supportTickets.status), ]); for (const r of results) { if (r.status === "rejected") console.error("[admin/insights] query failed", r.reason); } const [signupRows, recipeRows, tierRows, visibilityRows, usageRows, ticketRows] = results.map((r) => r.status === "fulfilled" ? r.value : [] ) as [ { day: string; n: number }[], { day: string; aiGenerated: boolean; n: number }[], { tier: "free" | "pro" | "family"; n: number }[], { visibility: "private" | "unlisted" | "public" | "followers"; n: number }[], { month: string; n: number }[], { status: "open" | "triaged" | "closed"; n: number }[], ]; const signupByDay = new Map(signupRows.map((r) => [r.day, r.n])); const signupSeries = lastNDays(DAYS).map((day) => ({ date: day, value: signupByDay.get(day) ?? 0 })); const recipesByDay = new Map(); for (const r of recipeRows) { const entry = recipesByDay.get(r.day) ?? { manual: 0, ai: 0 }; if (r.aiGenerated) entry.ai += r.n; else entry.manual += r.n; recipesByDay.set(r.day, entry); } const recipesSeries = lastNDays(DAYS).map((day) => { const entry = recipesByDay.get(day) ?? { manual: 0, ai: 0 }; return { label: formatShortDate(day), values: [entry.manual, entry.ai] }; }); const TIER_ORDER = ["free", "pro", "family"] as const; const tierByKey = new Map(tierRows.map((r) => [r.tier, r.n])); const tierData = TIER_ORDER.map((tier) => ({ label: tier, values: [tierByKey.get(tier) ?? 0] })); const VISIBILITY_ORDER = ["private", "unlisted", "followers", "public"] as const; const visByKey = new Map(visibilityRows.map((r) => [r.visibility, r.n])); const visibilityData = VISIBILITY_ORDER.map((v) => ({ label: v, values: [visByKey.get(v) ?? 0] })); const usageByMonth = new Map(usageRows.map((r) => [r.month, r.n])); const usageSeries = lastNMonths(6).map((month) => ({ date: month, value: usageByMonth.get(month) ?? 0 })); const STATUS_ORDER = ["open", "triaged", "closed"] as const; const statusByKey = new Map(ticketRows.map((r) => [r.status, r.n])); const statusData = STATUS_ORDER.map((s) => ({ label: s, values: [statusByKey.get(s) ?? 0] })); return (

Insights

Trends and breakdowns across the last {DAYS} days (or 6 months for usage).

New signups Daily, last {DAYS} days Recipes created Daily, manual vs AI-generated Users by tier All-time Recipes by visibility All-time AI calls Monthly total across all users, last 6 months Support tickets by status All-time
); }