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(" | ") } : {}),
+ });
}