diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx index 92e413a..0f4c9ba 100644 --- a/src/app/admin/analytics/page.tsx +++ b/src/app/admin/analytics/page.tsx @@ -32,6 +32,7 @@ interface EngagementData { activitySummary: { active7d: number; active30d: number; neverActive: number; total: number }; aiUsage: { totalCalls: number; totalTokens: number; totalCostPaise: number; familiesUsingAI: number }; logsByDay: { date: string; count: number }[]; + error?: string; } type Tab = "overview" | "families" | "ai"; @@ -59,6 +60,7 @@ export default function AdminAnalytics() { activitySummary: { active7d: 0, active30d: 0, neverActive: 0, total: 0, ...(d?.activitySummary || {}) }, aiUsage: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0, ...(d?.aiUsage || {}) }, logsByDay: Array.isArray(d?.logsByDay) ? d.logsByDay : [], + error: typeof d?.error === "string" ? d.error : undefined, }); setLoading(false); }) @@ -116,6 +118,13 @@ export default function AdminAnalytics() { + {data.error && ( +
+ Some analytics queries failed: + {data.error} +
+ )} + {/* Activity Summary Cards — always visible */}
diff --git a/src/app/api/admin/engagement/route.ts b/src/app/api/admin/engagement/route.ts index b24566e..559611a 100644 --- a/src/app/api/admin/engagement/route.ts +++ b/src/app/api/admin/engagement/route.ts @@ -2,15 +2,34 @@ import { NextResponse } from "next/server"; import { requireAdmin } from "@/lib/admin-auth"; 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) { const auth = await requireAdmin(request); 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> = []; + 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 { + 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 try { - // A. Feature adoption — % of families that have used each feature at least once const adoptionRows = await sql` 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 dl.id IS NOT NULL)::int as families_diapers, 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 chat_sessions WHERE child_id = c.id LIMIT 1) cs ON true `; + const a = adoptionRows[0] || {}; + const denom = total || 1; + const pct = (n: unknown) => Math.round((Number(n) || 0) / denom * 100); + featureAdoption = [ + { name: "Feed Logs", count: Number(a.families_feeding) || 0, pct: pct(a.families_feeding) }, + { name: "Diaper Logs", count: Number(a.families_diapers) || 0, pct: pct(a.families_diapers) }, + { name: "Sleep Logs", count: Number(a.families_sleeping) || 0, pct: pct(a.families_sleeping) }, + { name: "Vaccinations", count: Number(a.families_vaccinating) || 0, pct: pct(a.families_vaccinating) }, + { name: "Growth Tracking", count: Number(a.families_growth) || 0, pct: pct(a.families_growth) }, + { name: "Memories", count: Number(a.families_memories) || 0, pct: pct(a.families_memories) }, + { name: "AI Chat", count: Number(a.families_ai) || 0, pct: pct(a.families_ai) }, + ].sort((x, y) => y.pct - x.pct); + } catch (e) { + errors.push(`feature_adoption: ${String(e)}`); + } - const adoption = adoptionRows[0] || {}; - const total = Number(adoption.total_families) || 1; - - const featureAdoption = [ - { name: "Feed Logs", count: Number(adoption.families_feeding) || 0, pct: Math.round((Number(adoption.families_feeding) || 0) / total * 100) }, - { name: "Diaper Logs", count: Number(adoption.families_diapers) || 0, pct: Math.round((Number(adoption.families_diapers) || 0) / total * 100) }, - { name: "Sleep Logs", count: Number(adoption.families_sleeping) || 0, pct: Math.round((Number(adoption.families_sleeping) || 0) / total * 100) }, - { name: "Vaccinations", count: Number(adoption.families_vaccinating) || 0, pct: Math.round((Number(adoption.families_vaccinating) || 0) / total * 100) }, - { name: "Growth Tracking", count: Number(adoption.families_growth) || 0, pct: Math.round((Number(adoption.families_growth) || 0) / total * 100) }, - { name: "Memories", count: Number(adoption.families_memories) || 0, pct: Math.round((Number(adoption.families_memories) || 0) / total * 100) }, - { name: "AI Chat", count: Number(adoption.families_ai) || 0, pct: Math.round((Number(adoption.families_ai) || 0) / total * 100) }, - ].sort((a, b) => b.pct - a.pct); - - // B. Per-family engagement table. - // - // IMPORTANT: do NOT LEFT JOIN all the raw log tables directly — joining - // feeds × diapers_logs × sleeps × vaccinations × growth × chat_sessions in - // one query produces a cartesian product (millions of intermediate rows per - // active family) that times out / OOMs in production and 500s this route. - // Instead pre-aggregate each table to one row per child, then join those - // small aggregates. Memories are per-family, so aggregate by family_id. + // B. Per-family engagement table. + // + // Pre-aggregate each log table to one row per child BEFORE joining — joining + // the raw tables together produces a cartesian product that times out in + // production. Memories are per-family. All MAX(*) timestamps are cast to + // timestamptz so GREATEST() can't choke on mixed timestamp/timestamptz types. + try { 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), 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.created_at, GREATEST( - MAX(fa.last_at), - MAX(da.last_at), - MAX(sa.last_at), - MAX(ma.last_at) + MAX(fa.last_at)::timestamptz, + MAX(da.last_at)::timestamptz, + MAX(sa.last_at)::timestamptz, + MAX(ma.last_at)::timestamptz ) as last_activity, COALESCE(SUM(fa.cnt), 0)::int as feed_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 families = familyRows.map((f: any) => { - const lastActivity = f.last_activity ? new Date(f.last_activity).toISOString() : null; + families = familyRows.map((f: Record) => { + const lastActivity = f.last_activity ? new Date(f.last_activity as string).toISOString() : null; const msSince = lastActivity ? now - new Date(lastActivity).getTime() : Infinity; const activeStatus = !lastActivity ? "never" : msSince < 7 * 86400_000 ? "7d" : msSince < 30 * 86400_000 ? "30d" : "inactive"; return { id: f.id, name: f.name, 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, activeStatus, feedCount: Number(f.feed_count) || 0, @@ -109,38 +129,44 @@ export async function GET(request: Request) { memoryCount: Number(f.memory_count) || 0, chatCount: Number(f.chat_count) || 0, totalLogs: - Number(f.feed_count) + Number(f.diaper_count) + Number(f.sleep_count) + - Number(f.vaccination_count) + Number(f.growth_count), + (Number(f.feed_count) || 0) + (Number(f.diaper_count) || 0) + (Number(f.sleep_count) || 0) + + (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 - const active7d = families.filter(f => f.activeStatus === "7d").length; - const active30d = families.filter(f => f.activeStatus === "30d").length; - const neverActive = families.filter(f => f.activeStatus === "never").length; + // C. Activity summary counts (derived from families) + const active7d = families.filter(f => f.activeStatus === "7d").length; + const active30d = families.filter(f => f.activeStatus === "30d").length; + const neverActive = families.filter(f => f.activeStatus === "never").length; - // D. AI usage last 30 days - let aiUsage = { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0 }; - try { - const aiRows = await sql` - SELECT - COUNT(*)::int as total_calls, - COALESCE(SUM(total_tokens), 0)::int as total_tokens, - COALESCE(SUM(cost_estimate_paise), 0)::numeric as total_cost_paise, - COUNT(DISTINCT family_id)::int as families_using_ai - FROM ai_usage - WHERE created_at > NOW() - INTERVAL '30 days' - `; - const row = aiRows[0] || {}; - aiUsage = { - totalCalls: Number(row.total_calls) || 0, - totalTokens: Number(row.total_tokens) || 0, - totalCostPaise: Number(row.total_cost_paise) || 0, - familiesUsingAI: Number(row.families_using_ai) || 0, - }; - } catch {} + // D. AI usage last 30 days + try { + const aiRows = await sql` + SELECT + COUNT(*)::int as total_calls, + COALESCE(SUM(total_tokens), 0)::int as total_tokens, + COALESCE(SUM(cost_estimate_paise), 0)::numeric as total_cost_paise, + COUNT(DISTINCT family_id)::int as families_using_ai + FROM ai_usage + WHERE created_at > NOW() - INTERVAL '30 days' + `; + const row = aiRows[0] || {}; + aiUsage = { + totalCalls: Number(row.total_calls) || 0, + totalTokens: Number(row.total_tokens) || 0, + totalCostPaise: Number(row.total_cost_paise) || 0, + familiesUsingAI: Number(row.families_using_ai) || 0, + }; + } 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` SELECT day::date as date, SUM(cnt)::int as count FROM ( @@ -152,35 +178,27 @@ export async function GET(request: Request) { UNION ALL SELECT DATE(created_at) as day, COUNT(*) as cnt FROM memories WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY day ) sub + WHERE day IS NOT NULL GROUP BY day ORDER BY day `; - - const logsByDay = dailyRows.map((r: any) => ({ + logsByDay = dailyRows.map((r: Record) => ({ 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, })); - - return NextResponse.json({ - totalFamilies: total, - featureAdoption, - families, - activitySummary: { active7d, active30d, neverActive, total }, - aiUsage, - logsByDay, - }); - } 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), - }); + } catch (e) { + errors.push(`logs_by_day: ${String(e)}`); } + + if (errors.length) console.error("Admin engagement partial errors:", errors); + + return NextResponse.json({ + totalFamilies: total, + featureAdoption, + families, + activitySummary: { active7d, active30d, neverActive, total }, + aiUsage, + logsByDay, + ...(errors.length ? { error: errors.join(" | ") } : {}), + }); }