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 };
|
||||
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() {
|
|||
</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 */}
|
||||
<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}`} />
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
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 {
|
||||
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 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);
|
||||
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)}`);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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<string, unknown>) => {
|
||||
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,18 +129,21 @@ 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
|
||||
// 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
|
||||
|
|
@ -138,9 +161,12 @@ export async function GET(request: Request) {
|
|||
totalCostPaise: Number(row.total_cost_paise) || 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)
|
||||
try {
|
||||
const dailyRows = await sql`
|
||||
SELECT day::date as date, SUM(cnt)::int as count
|
||||
FROM (
|
||||
|
|
@ -152,14 +178,19 @@ 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<string, unknown>) => ({
|
||||
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({
|
||||
totalFamilies: total,
|
||||
|
|
@ -168,19 +199,6 @@ export async function GET(request: Request) {
|
|||
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),
|
||||
...(errors.length ? { error: errors.join(" | ") } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue