Make admin engagement feed resilient + self-diagnosing

Analytics showed blank despite real data because one failing sub-query made
the whole route fall back to an empty shape, and the page dropped the error.

- Each sub-query (adoption, families, AI, daily) now runs in its own
  try/catch; failures are collected and returned in `error` while the other
  sections still render (partial data instead of all-or-nothing).
- Cast all MAX() timestamps to timestamptz inside GREATEST() so mixed
  timestamp/timestamptz columns can't error.
- Dedicated COUNT(*) for total families (robust denominator/summary total).
- Analytics page now surfaces the `error` string in a banner instead of
  silently rendering empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-30 00:37:53 +05:30
parent 7a60132bb2
commit cbbe8f24ac
2 changed files with 109 additions and 82 deletions

View file

@ -32,6 +32,7 @@ interface EngagementData {
activitySummary: { active7d: number; active30d: number; neverActive: number; total: number }; activitySummary: { active7d: number; active30d: number; neverActive: number; total: number };
aiUsage: { totalCalls: number; totalTokens: number; totalCostPaise: number; familiesUsingAI: number }; aiUsage: { totalCalls: number; totalTokens: number; totalCostPaise: number; familiesUsingAI: number };
logsByDay: { date: string; count: number }[]; logsByDay: { date: string; count: number }[];
error?: string;
} }
type Tab = "overview" | "families" | "ai"; type Tab = "overview" | "families" | "ai";
@ -59,6 +60,7 @@ export default function AdminAnalytics() {
activitySummary: { active7d: 0, active30d: 0, neverActive: 0, total: 0, ...(d?.activitySummary || {}) }, activitySummary: { active7d: 0, active30d: 0, neverActive: 0, total: 0, ...(d?.activitySummary || {}) },
aiUsage: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0, ...(d?.aiUsage || {}) }, aiUsage: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0, ...(d?.aiUsage || {}) },
logsByDay: Array.isArray(d?.logsByDay) ? d.logsByDay : [], logsByDay: Array.isArray(d?.logsByDay) ? d.logsByDay : [],
error: typeof d?.error === "string" ? d.error : undefined,
}); });
setLoading(false); setLoading(false);
}) })
@ -116,6 +118,13 @@ export default function AdminAnalytics() {
</div> </div>
</div> </div>
{data.error && (
<div className="bg-rose-500/10 border border-rose-500/30 text-rose-300 text-sm p-3 rounded-lg">
<span className="font-semibold">Some analytics queries failed:</span>
<span className="ml-1 font-mono break-words">{data.error}</span>
</div>
)}
{/* Activity Summary Cards — always visible */} {/* Activity Summary Cards — always visible */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<SummaryCard label="Active 7d" value={activitySummary.active7d} color="text-emerald-400" sub={`of ${activitySummary.total}`} /> <SummaryCard label="Active 7d" value={activitySummary.active7d} color="text-emerald-400" sub={`of ${activitySummary.total}`} />

View file

@ -2,15 +2,34 @@ import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth"; import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db"; import { sql } from "@/db";
// Engagement / analytics feed for /admin/analytics.
//
// Each section runs in its own try/catch and pushes any failure into `errors`,
// so a single broken query degrades gracefully (partial data still renders)
// AND the real error message is surfaced on the page instead of a silent blank.
export async function GET(request: Request) { export async function GET(request: Request) {
const auth = await requireAdmin(request); const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const errors: string[] = [];
let total = 0;
let featureAdoption: { name: string; count: number; pct: number }[] = [];
let families: Array<Record<string, unknown>> = [];
let aiUsage = { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0 };
let logsByDay: { date: string; count: number }[] = [];
// 0. Total families (cheap, robust — used for denominators + summary total)
try { try {
const rows = await sql`SELECT COUNT(*)::int AS count FROM families`;
total = Number(rows[0]?.count) || 0;
} catch (e) {
errors.push(`families_count: ${String(e)}`);
}
// A. Feature adoption — % of families that have used each feature at least once // A. Feature adoption — % of families that have used each feature at least once
try {
const adoptionRows = await sql` const adoptionRows = await sql`
SELECT SELECT
COUNT(DISTINCT f.id)::int as total_families,
COUNT(DISTINCT f.id) FILTER (WHERE fd.id IS NOT NULL)::int as families_feeding, COUNT(DISTINCT f.id) FILTER (WHERE fd.id IS NOT NULL)::int as families_feeding,
COUNT(DISTINCT f.id) FILTER (WHERE dl.id IS NOT NULL)::int as families_diapers, COUNT(DISTINCT f.id) FILTER (WHERE dl.id IS NOT NULL)::int as families_diapers,
COUNT(DISTINCT f.id) FILTER (WHERE sl.id IS NOT NULL)::int as families_sleeping, COUNT(DISTINCT f.id) FILTER (WHERE sl.id IS NOT NULL)::int as families_sleeping,
@ -28,28 +47,29 @@ export async function GET(request: Request) {
LEFT JOIN LATERAL (SELECT id FROM memories WHERE family_id = f.id LIMIT 1) mem ON true LEFT JOIN LATERAL (SELECT id FROM memories WHERE family_id = f.id LIMIT 1) mem ON true
LEFT JOIN LATERAL (SELECT id FROM chat_sessions WHERE child_id = c.id LIMIT 1) cs ON true LEFT JOIN LATERAL (SELECT id FROM chat_sessions WHERE child_id = c.id LIMIT 1) cs ON true
`; `;
const a = adoptionRows[0] || {};
const adoption = adoptionRows[0] || {}; const denom = total || 1;
const total = Number(adoption.total_families) || 1; const pct = (n: unknown) => Math.round((Number(n) || 0) / denom * 100);
featureAdoption = [
const featureAdoption = [ { name: "Feed Logs", count: Number(a.families_feeding) || 0, pct: pct(a.families_feeding) },
{ name: "Feed Logs", count: Number(adoption.families_feeding) || 0, pct: Math.round((Number(adoption.families_feeding) || 0) / total * 100) }, { name: "Diaper Logs", count: Number(a.families_diapers) || 0, pct: pct(a.families_diapers) },
{ name: "Diaper Logs", count: Number(adoption.families_diapers) || 0, pct: Math.round((Number(adoption.families_diapers) || 0) / total * 100) }, { name: "Sleep Logs", count: Number(a.families_sleeping) || 0, pct: pct(a.families_sleeping) },
{ name: "Sleep Logs", count: Number(adoption.families_sleeping) || 0, pct: Math.round((Number(adoption.families_sleeping) || 0) / total * 100) }, { name: "Vaccinations", count: Number(a.families_vaccinating) || 0, pct: pct(a.families_vaccinating) },
{ name: "Vaccinations", count: Number(adoption.families_vaccinating) || 0, pct: Math.round((Number(adoption.families_vaccinating) || 0) / total * 100) }, { name: "Growth Tracking", count: Number(a.families_growth) || 0, pct: pct(a.families_growth) },
{ name: "Growth Tracking", count: Number(adoption.families_growth) || 0, pct: Math.round((Number(adoption.families_growth) || 0) / total * 100) }, { name: "Memories", count: Number(a.families_memories) || 0, pct: pct(a.families_memories) },
{ name: "Memories", count: Number(adoption.families_memories) || 0, pct: Math.round((Number(adoption.families_memories) || 0) / total * 100) }, { name: "AI Chat", count: Number(a.families_ai) || 0, pct: pct(a.families_ai) },
{ name: "AI Chat", count: Number(adoption.families_ai) || 0, pct: Math.round((Number(adoption.families_ai) || 0) / total * 100) }, ].sort((x, y) => y.pct - x.pct);
].sort((a, b) => b.pct - a.pct); } catch (e) {
errors.push(`feature_adoption: ${String(e)}`);
}
// B. Per-family engagement table. // B. Per-family engagement table.
// //
// IMPORTANT: do NOT LEFT JOIN all the raw log tables directly — joining // Pre-aggregate each log table to one row per child BEFORE joining — joining
// feeds × diapers_logs × sleeps × vaccinations × growth × chat_sessions in // the raw tables together produces a cartesian product that times out in
// one query produces a cartesian product (millions of intermediate rows per // production. Memories are per-family. All MAX(*) timestamps are cast to
// active family) that times out / OOMs in production and 500s this route. // timestamptz so GREATEST() can't choke on mixed timestamp/timestamptz types.
// Instead pre-aggregate each table to one row per child, then join those try {
// small aggregates. Memories are per-family, so aggregate by family_id.
const familyRows = await sql` const familyRows = await sql`
WITH feed_agg AS (SELECT child_id, COUNT(*)::int AS cnt, MAX(logged_at) AS last_at FROM feeds GROUP BY child_id), WITH feed_agg AS (SELECT child_id, COUNT(*)::int AS cnt, MAX(logged_at) AS last_at FROM feeds GROUP BY child_id),
diaper_agg AS (SELECT child_id, COUNT(*)::int AS cnt, MAX(logged_at) AS last_at FROM diapers_logs GROUP BY child_id), diaper_agg AS (SELECT child_id, COUNT(*)::int AS cnt, MAX(logged_at) AS last_at FROM diapers_logs GROUP BY child_id),
@ -64,10 +84,10 @@ export async function GET(request: Request) {
f.tier, f.tier,
f.created_at, f.created_at,
GREATEST( GREATEST(
MAX(fa.last_at), MAX(fa.last_at)::timestamptz,
MAX(da.last_at), MAX(da.last_at)::timestamptz,
MAX(sa.last_at), MAX(sa.last_at)::timestamptz,
MAX(ma.last_at) MAX(ma.last_at)::timestamptz
) as last_activity, ) as last_activity,
COALESCE(SUM(fa.cnt), 0)::int as feed_count, COALESCE(SUM(fa.cnt), 0)::int as feed_count,
COALESCE(SUM(da.cnt), 0)::int as diaper_count, COALESCE(SUM(da.cnt), 0)::int as diaper_count,
@ -90,15 +110,15 @@ export async function GET(request: Request) {
`; `;
const now = Date.now(); const now = Date.now();
const families = familyRows.map((f: any) => { families = familyRows.map((f: Record<string, unknown>) => {
const lastActivity = f.last_activity ? new Date(f.last_activity).toISOString() : null; const lastActivity = f.last_activity ? new Date(f.last_activity as string).toISOString() : null;
const msSince = lastActivity ? now - new Date(lastActivity).getTime() : Infinity; const msSince = lastActivity ? now - new Date(lastActivity).getTime() : Infinity;
const activeStatus = !lastActivity ? "never" : msSince < 7 * 86400_000 ? "7d" : msSince < 30 * 86400_000 ? "30d" : "inactive"; const activeStatus = !lastActivity ? "never" : msSince < 7 * 86400_000 ? "7d" : msSince < 30 * 86400_000 ? "30d" : "inactive";
return { return {
id: f.id, id: f.id,
name: f.name, name: f.name,
tier: f.tier || "free", tier: f.tier || "free",
createdAt: f.created_at ? new Date(f.created_at).toISOString() : null, createdAt: f.created_at ? new Date(f.created_at as string).toISOString() : null,
lastActivity, lastActivity,
activeStatus, activeStatus,
feedCount: Number(f.feed_count) || 0, feedCount: Number(f.feed_count) || 0,
@ -109,18 +129,21 @@ export async function GET(request: Request) {
memoryCount: Number(f.memory_count) || 0, memoryCount: Number(f.memory_count) || 0,
chatCount: Number(f.chat_count) || 0, chatCount: Number(f.chat_count) || 0,
totalLogs: totalLogs:
Number(f.feed_count) + Number(f.diaper_count) + Number(f.sleep_count) + (Number(f.feed_count) || 0) + (Number(f.diaper_count) || 0) + (Number(f.sleep_count) || 0) +
Number(f.vaccination_count) + Number(f.growth_count), (Number(f.vaccination_count) || 0) + (Number(f.growth_count) || 0),
}; };
}); });
if (!total) total = families.length;
} catch (e) {
errors.push(`families: ${String(e)}`);
}
// C. Activity summary counts // C. Activity summary counts (derived from families)
const active7d = families.filter(f => f.activeStatus === "7d").length; const active7d = families.filter(f => f.activeStatus === "7d").length;
const active30d = families.filter(f => f.activeStatus === "30d").length; const active30d = families.filter(f => f.activeStatus === "30d").length;
const neverActive = families.filter(f => f.activeStatus === "never").length; const neverActive = families.filter(f => f.activeStatus === "never").length;
// D. AI usage last 30 days // D. AI usage last 30 days
let aiUsage = { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0 };
try { try {
const aiRows = await sql` const aiRows = await sql`
SELECT SELECT
@ -138,9 +161,12 @@ export async function GET(request: Request) {
totalCostPaise: Number(row.total_cost_paise) || 0, totalCostPaise: Number(row.total_cost_paise) || 0,
familiesUsingAI: Number(row.families_using_ai) || 0, familiesUsingAI: Number(row.families_using_ai) || 0,
}; };
} catch {} } catch (e) {
errors.push(`ai_usage: ${String(e)}`);
}
// E. Daily activity for chart (last 30 days, all log types combined) // E. Daily activity for chart (last 30 days, all log types combined)
try {
const dailyRows = await sql` const dailyRows = await sql`
SELECT day::date as date, SUM(cnt)::int as count SELECT day::date as date, SUM(cnt)::int as count
FROM ( FROM (
@ -152,14 +178,19 @@ export async function GET(request: Request) {
UNION ALL UNION ALL
SELECT DATE(created_at) as day, COUNT(*) as cnt FROM memories WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY day SELECT DATE(created_at) as day, COUNT(*) as cnt FROM memories WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY day
) sub ) sub
WHERE day IS NOT NULL
GROUP BY day GROUP BY day
ORDER BY day ORDER BY day
`; `;
logsByDay = dailyRows.map((r: Record<string, unknown>) => ({
const logsByDay = dailyRows.map((r: any) => ({
date: r.date instanceof Date ? r.date.toISOString().split("T")[0] : String(r.date).split("T")[0], date: r.date instanceof Date ? r.date.toISOString().split("T")[0] : String(r.date).split("T")[0],
count: Number(r.count), count: Number(r.count) || 0,
})); }));
} catch (e) {
errors.push(`logs_by_day: ${String(e)}`);
}
if (errors.length) console.error("Admin engagement partial errors:", errors);
return NextResponse.json({ return NextResponse.json({
totalFamilies: total, totalFamilies: total,
@ -168,19 +199,6 @@ export async function GET(request: Request) {
activitySummary: { active7d, active30d, neverActive, total }, activitySummary: { active7d, active30d, neverActive, total },
aiUsage, aiUsage,
logsByDay, logsByDay,
...(errors.length ? { error: errors.join(" | ") } : {}),
}); });
} catch (error) {
console.error("Admin engagement error:", error);
// Return the full shape (with safe empties) instead of a bare {error} so the
// analytics page renders an empty state rather than crashing on data.families.
return NextResponse.json({
totalFamilies: 0,
featureAdoption: [],
families: [],
activitySummary: { active7d: 0, active30d: 0, neverActive: 0, total: 0 },
aiUsage: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0 },
logsByDay: [],
error: String(error),
});
}
} }