tia/src/app/admin/analytics/page.tsx
Mannu 23a365309b feat: admin orphaned family management + user journey analytics
- Orphaned families (0 members) now visible with amber badge in /admin/families
  with filter tab and explicit "Delete Family + Data" cascade delete button
- Delete confirmation shows exact counts: children, logs, memories
- Delete child button added to /admin/children with count confirmation
- New DELETE /api/admin/families/[id] — full cascade delete (children, feeds,
  diapers_logs, sleeps, vaccinations, growth, memories, chat, etc.)
- New GET/DELETE /api/admin/children/[id] — cascade child delete with counts
- Extended families GET to include logCount + memoryCount per family
- New /api/admin/engagement — feature adoption %, per-family engagement table,
  AI usage stats (30d), daily activity chart using correct table names
- /admin/analytics fully redesigned: adoption funnel bars, per-family engagement
  table (sortable, filterable by activity), AI cost tab with INR breakdown
- Fixes wrong table names in old analytics (activity_logs, growth_records → real tables)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 09:38:34 +05:30

314 lines
14 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
interface FeatureAdoption {
name: string;
count: number;
pct: number;
}
interface FamilyEngagement {
id: string;
name: string;
tier: string;
createdAt: string | null;
lastActivity: string | null;
activeStatus: "7d" | "30d" | "inactive" | "never";
feedCount: number;
diaperCount: number;
sleepCount: number;
vaccinationCount: number;
growthCount: number;
memoryCount: number;
chatCount: number;
totalLogs: number;
}
interface EngagementData {
totalFamilies: number;
featureAdoption: FeatureAdoption[];
families: FamilyEngagement[];
activitySummary: { active7d: number; active30d: number; neverActive: number; total: number };
aiUsage: { totalCalls: number; totalTokens: number; totalCostPaise: number; familiesUsingAI: number };
logsByDay: { date: string; count: number }[];
}
type Tab = "overview" | "families" | "ai";
type SortField = "lastActivity" | "totalLogs" | "name";
type ActivityFilter = "all" | "7d" | "30d" | "inactive" | "never";
export default function AdminAnalytics() {
const [data, setData] = useState<EngagementData | null>(null);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>("overview");
const [sortField, setSortField] = useState<SortField>("lastActivity");
const [sortAsc, setSortAsc] = useState(false);
const [activityFilter, setActivityFilter] = useState<ActivityFilter>("all");
useEffect(() => {
fetch("/api/admin/engagement", { credentials: "include" })
.then(r => r.json())
.then(d => { setData(d); setLoading(false); })
.catch(() => setLoading(false));
}, []);
if (loading) return <div className="p-6 text-white">Loading...</div>;
if (!data) return <div className="p-6 text-red-400">Failed to load analytics</div>;
const handleSort = (field: SortField) => {
if (sortField === field) setSortAsc(a => !a);
else { setSortField(field); setSortAsc(false); }
};
const sortedFamilies = [...data.families]
.filter(f => activityFilter === "all" || f.activeStatus === activityFilter)
.sort((a, b) => {
let diff = 0;
if (sortField === "lastActivity") {
const ta = a.lastActivity ? new Date(a.lastActivity).getTime() : 0;
const tb = b.lastActivity ? new Date(b.lastActivity).getTime() : 0;
diff = tb - ta;
} else if (sortField === "totalLogs") {
diff = b.totalLogs - a.totalLogs;
} else {
diff = a.name.localeCompare(b.name);
}
return sortAsc ? -diff : diff;
});
const maxLog = Math.max(...data.logsByDay.map(d => d.count), 1);
const { aiUsage, activitySummary } = data;
const costINR = (aiUsage.totalCostPaise / 100).toFixed(2);
const costPerFamily = aiUsage.familiesUsingAI > 0
? (aiUsage.totalCostPaise / 100 / aiUsage.familiesUsingAI).toFixed(2)
: "0.00";
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-start">
<div>
<h1 className="text-2xl font-bold">Analytics</h1>
<p className="text-gray-400">User journey and feature engagement</p>
</div>
<div className="flex gap-1 bg-gray-800 p-1 rounded-lg">
{(["overview", "families", "ai"] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-3 py-1.5 rounded-md text-sm font-medium capitalize transition-colors ${tab === t ? "bg-gray-600 text-white" : "text-gray-400 hover:text-white"}`}
>
{t === "ai" ? "AI Usage" : t}
</button>
))}
</div>
</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}`} />
<SummaryCard label="Active 30d" value={activitySummary.active30d} color="text-blue-400" sub={`of ${activitySummary.total}`} />
<SummaryCard label="Inactive" value={activitySummary.total - activitySummary.active7d - activitySummary.active30d - activitySummary.neverActive} color="text-amber-400" sub="no activity >30d" />
<SummaryCard label="Never Active" value={activitySummary.neverActive} color="text-gray-500" sub="0 logs ever" />
</div>
{tab === "overview" && (
<>
{/* Feature Adoption Funnel */}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-5">Feature Adoption</h3>
<div className="space-y-3">
{data.featureAdoption.map(f => (
<div key={f.name} className="flex items-center gap-4">
<div className="w-32 text-sm text-gray-300 shrink-0">{f.name}</div>
<div className="flex-1 bg-gray-700 rounded-full h-5 relative overflow-hidden">
<div
className="h-full bg-rose-500 rounded-full transition-all"
style={{ width: `${f.pct}%` }}
/>
</div>
<div className="w-24 text-right text-sm">
<span className="font-bold text-white">{f.pct}%</span>
<span className="text-gray-500 ml-1">({f.count})</span>
</div>
</div>
))}
{data.featureAdoption.length === 0 && (
<div className="text-gray-500 text-sm">No data yet</div>
)}
</div>
</div>
{/* Daily Activity Chart */}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Daily Activity Last 30 Days</h3>
<div className="h-40 flex items-end gap-1">
{data.logsByDay.slice(-30).map((d, i) => (
<div key={i} title={`${d.date}: ${d.count}`} className="flex-1 flex flex-col items-center gap-1 group">
<div
className="w-full bg-rose-500 rounded-t group-hover:bg-rose-400 transition-colors"
style={{
height: `${Math.min((d.count / maxLog) * 100, 100)}%`,
minHeight: d.count > 0 ? "4px" : "0"
}}
/>
<div className="text-[8px] text-gray-500">{d.date?.slice(5) || ""}</div>
</div>
))}
{data.logsByDay.length === 0 && (
<div className="w-full text-center text-gray-500 text-sm self-center">No activity in the last 30 days</div>
)}
</div>
</div>
</>
)}
{tab === "families" && (
<div className="bg-gray-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-700 flex gap-2 flex-wrap">
{(["all", "7d", "30d", "inactive", "never"] as ActivityFilter[]).map(f => (
<button
key={f}
onClick={() => setActivityFilter(f)}
className={`px-3 py-1 rounded-lg text-sm transition-colors ${activityFilter === f ? "bg-rose-500 text-white" : "bg-gray-700 text-gray-300 hover:bg-gray-600"}`}
>
{f === "all" ? "All" : f === "7d" ? "Active 7d" : f === "30d" ? "Active 30d" : f === "inactive" ? "Inactive >30d" : "Never Active"}
{f !== "all" && (
<span className="ml-1.5 text-xs opacity-70">
{data.families.filter(x => x.activeStatus === f).length}
</span>
)}
</button>
))}
<span className="text-gray-500 text-sm self-center ml-auto">{sortedFamilies.length} families</span>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">Family</th>
<th className="px-4 py-3 text-left text-sm font-medium">Tier</th>
<SortHeader label="Last Active" field="lastActivity" current={sortField} asc={sortAsc} onSort={handleSort} />
<SortHeader label="Total Logs" field="totalLogs" current={sortField} asc={sortAsc} onSort={handleSort} />
<th className="px-4 py-3 text-left text-sm font-medium">Features Used</th>
<th className="px-4 py-3 text-left text-sm font-medium">AI Chats</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{sortedFamilies.map(f => (
<tr key={f.id} className="hover:bg-gray-750">
<td className="px-4 py-3">
<div className="font-medium">{f.name}</div>
<div className="text-xs text-gray-500">{f.createdAt?.slice(0, 10)}</div>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded font-medium ${f.tier === "pro" ? "bg-rose-500/20 text-rose-400" : "bg-gray-700 text-gray-400"}`}>
{f.tier}
</span>
</td>
<td className="px-4 py-3 text-sm">
{f.lastActivity ? (
<span className={f.activeStatus === "7d" ? "text-emerald-400" : f.activeStatus === "30d" ? "text-blue-400" : "text-gray-500"}>
{f.lastActivity.slice(0, 10)}
</span>
) : (
<span className="text-gray-600">Never</span>
)}
</td>
<td className="px-4 py-3 text-sm font-medium">{f.totalLogs}</td>
<td className="px-4 py-3">
<div className="flex gap-1 flex-wrap">
{f.feedCount > 0 && <FeatureBadge emoji="🍼" label="Feed" />}
{f.sleepCount > 0 && <FeatureBadge emoji="💤" label="Sleep" />}
{f.diaperCount > 0 && <FeatureBadge emoji="🚼" label="Diaper" />}
{f.vaccinationCount > 0 && <FeatureBadge emoji="💉" label="Vacc" />}
{f.growthCount > 0 && <FeatureBadge emoji="📏" label="Growth" />}
{f.memoryCount > 0 && <FeatureBadge emoji="📸" label="Memory" />}
{f.chatCount > 0 && <FeatureBadge emoji="💬" label="AI" />}
{f.totalLogs === 0 && f.memoryCount === 0 && f.chatCount === 0 && (
<span className="text-gray-600 text-xs">None</span>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-300">{f.chatCount || "—"}</td>
</tr>
))}
</tbody>
</table>
{sortedFamilies.length === 0 && (
<div className="p-8 text-center text-gray-500">No families match this filter</div>
)}
</div>
</div>
)}
{tab === "ai" && (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<SummaryCard label="API Calls (30d)" value={aiUsage.totalCalls} color="text-blue-400" />
<SummaryCard label="Total Tokens" value={aiUsage.totalTokens.toLocaleString()} color="text-purple-400" />
<SummaryCard label="Cost (30d)" value={`${costINR}`} color="text-amber-400" />
<SummaryCard label="Families Using AI" value={aiUsage.familiesUsingAI} color="text-emerald-400" />
</div>
<div className="bg-gray-800 p-6 rounded-xl space-y-3">
<h3 className="text-lg font-semibold">AI Cost Breakdown</h3>
<div className="flex justify-between text-sm py-2 border-b border-gray-700">
<span className="text-gray-400">Total cost (30 days)</span>
<span className="font-bold">{costINR}</span>
</div>
<div className="flex justify-between text-sm py-2 border-b border-gray-700">
<span className="text-gray-400">Avg cost per AI-using family</span>
<span className="font-bold">{costPerFamily}</span>
</div>
<div className="flex justify-between text-sm py-2 border-b border-gray-700">
<span className="text-gray-400">Total tokens used</span>
<span className="font-bold">{aiUsage.totalTokens.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm py-2">
<span className="text-gray-400">Families using AI</span>
<span className="font-bold">
{aiUsage.familiesUsingAI} / {data.totalFamilies}
<span className="text-gray-500 ml-1 text-xs">
({data.totalFamilies > 0 ? Math.round(aiUsage.familiesUsingAI / data.totalFamilies * 100) : 0}%)
</span>
</span>
</div>
</div>
</div>
)}
</div>
);
}
function SummaryCard({ label, value, color, sub }: { label: string; value: string | number; color: string; sub?: string }) {
return (
<div className="bg-gray-800 p-4 rounded-xl">
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-sm text-gray-300 mt-0.5">{label}</div>
{sub && <div className="text-xs text-gray-500 mt-0.5">{sub}</div>}
</div>
);
}
function FeatureBadge({ emoji, label }: { emoji: string; label: string }) {
return (
<span title={label} className="text-xs bg-gray-700 px-1.5 py-0.5 rounded">
{emoji}
</span>
);
}
function SortHeader({ label, field, current, asc, onSort }: {
label: string; field: SortField; current: SortField; asc: boolean; onSort: (f: SortField) => void;
}) {
return (
<th
className="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:text-white select-none"
onClick={() => onSort(field)}
>
{label} {current === field ? (asc ? "↑" : "↓") : "↕"}
</th>
);
}