- 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>
314 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|