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:
parent
7a60132bb2
commit
cbbe8f24ac
2 changed files with 109 additions and 82 deletions
|
|
@ -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}`} />
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue