learning_ai_common_plat/dashboards/admin-web/src/app/(dashboard)/page.tsx
saravanakumardb1 aa0e67d219 feat(admin-web): add @bytelyst/motion reveal/stagger on dashboard (UX-5)
- @bytelyst/motion added workspace:* (importer-only lockfile change;
  --frozen-lockfile clean).
- Dashboard overview only: KPI cards grid wrapped in StaggerList (from up,
  50ms stagger); the Model-Usage / Recent-Users table row wrapped in Reveal.
- Primitives honor prefers-reduced-motion and resolve to opacity 1, so no
  element is stranded transparent (no contrast/a11y regression); prefersReduced
  is SSR-safe. Motion is confined to the auth-gated dashboard, not the public
  e2e surfaces, per tracker-web's axe/opacity caution.
- vitest.config: inline @bytelyst/motion + react dedupe for the render test.

Tests: happy-dom asserts Reveal/StaggerList end visible and render all children.

Verify: typecheck+lint+build green (123 routes); vitest 21 files / 170 tests
(+2); format:check no new failures; e2e 11 passed / 80 failed (unchanged vs
UX-1 baseline — environmental).

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-29 14:19:28 -07:00

489 lines
16 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Users,
DollarSign,
Zap,
TrendingUp,
UserPlus,
Activity,
ArrowUpRight,
ArrowDownRight,
RefreshCw,
Cpu,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import {
mockSummaryStats,
formatNumber,
formatCurrency,
type DailyMetric,
type User,
type ModelUsage,
} from '@/lib/mock-data';
import {
apiGetDashboardStats,
apiGetUsage,
apiListUsers,
apiGetRevenueAnalytics,
type DashboardStats,
type ApiUsageRecord,
type RevenueAnalytics,
} from '@/lib/api';
import { PageHeader } from '@bytelyst/dashboard-components';
import { Reveal, StaggerList } from '@bytelyst/motion';
import { AreaChart, BarChart } from '@/components/charts';
import { seriesValues, dateBars } from '@/lib/chart-data';
function buildKpiCards(stats: typeof mockSummaryStats, revenue?: RevenueAnalytics | null) {
const fmt = (n: number) => (n >= 0 ? `+${n}%` : `${n}%`);
const mrrChange = revenue?.mrrChange ?? 0;
const churnRate = revenue?.churnRate ?? stats.churnRate;
return [
{
title: 'Total Users',
value: stats.totalUsers.toString(),
change: revenue
? fmt(
Math.round(
((revenue.newSubscriptions - revenue.canceledSubscriptions) /
(stats.totalUsers || 1)) *
100 *
10
) / 10
)
: '—',
trend: (revenue
? revenue.newSubscriptions >= revenue.canceledSubscriptions
? 'up'
: 'down'
: 'up') as 'up' | 'down',
icon: Users,
subtitle: `${stats.activeUsers} active`,
},
{
title: 'Monthly Revenue',
value: formatCurrency(revenue?.mrr ?? stats.monthlyRecurring),
change: revenue ? fmt(mrrChange) : '—',
trend: (mrrChange >= 0 ? 'up' : 'down') as 'up' | 'down',
icon: DollarSign,
subtitle: `${formatCurrency(revenue?.totalRevenue ?? stats.totalRevenue)} total`,
},
{
title: 'Tokens This Month',
value: formatNumber(stats.totalTokensThisMonth),
change: '—',
trend: 'up' as const,
icon: Zap,
subtitle: `${formatNumber(stats.avgTokensPerUser)} avg/user`,
},
{
title: 'New Users',
value: (revenue?.newSubscriptions ?? stats.newUsersThisMonth).toString(),
change: '—',
trend: 'up' as const,
icon: UserPlus,
subtitle: `${stats.conversionRate}% conversion`,
},
{
title: 'Requests This Month',
value: formatNumber(stats.totalRequestsThisMonth),
change: '—',
trend: 'up' as const,
icon: Activity,
subtitle: 'API calls',
},
{
title: 'Churn Rate',
value: `${churnRate}%`,
change: revenue ? `${revenue.churnCount} canceled` : '—',
trend: (churnRate <= 5 ? 'down' : 'up') as 'up' | 'down',
icon: TrendingUp,
subtitle: 'Month over month',
},
];
}
function mergeApiStats(
base: typeof mockSummaryStats,
api: DashboardStats
): typeof mockSummaryStats {
const totalUsers = api.users.total || base.totalUsers;
const totalTokens = api.usage.totalWords || base.totalTokensThisMonth;
return {
...base,
totalUsers,
activeUsers: totalUsers,
totalTokensThisMonth: totalTokens,
totalRequestsThisMonth: api.usage.totalDictations || base.totalRequestsThisMonth,
avgTokensPerUser: totalUsers > 0 ? Math.round(totalTokens / totalUsers) : 0,
};
}
function usageRecordsToDailyMetrics(records: ApiUsageRecord[]): DailyMetric[] {
const byDate = new Map<string, DailyMetric>();
for (const r of records) {
const existing = byDate.get(r.date);
if (existing) {
existing.totalTokens += r.tokensUsed;
existing.totalRequests += r.dictations;
existing.revenue += r.costUsd;
existing.activeUsers += 1;
} else {
byDate.set(r.date, {
date: r.date,
activeUsers: 1,
totalRequests: r.dictations,
totalTokens: r.tokensUsed,
revenue: r.costUsd,
});
}
}
return Array.from(byDate.values()).sort((a, b) => a.date.localeCompare(b.date));
}
function buildModelUsage(records: ApiUsageRecord[]): ModelUsage[] {
// Aggregate per-model from real usage records (each record now has optional model field)
const byModel: Record<string, { tokens: number; requests: number; cost: number }> = {};
for (const r of records) {
const model = (r as unknown as { model?: string }).model || 'gpt-4o-mini';
if (!byModel[model]) byModel[model] = { tokens: 0, requests: 0, cost: 0 };
byModel[model].tokens += r.tokensUsed;
byModel[model].requests += r.dictations;
byModel[model].cost += r.costUsd;
}
const totalTokens = Object.values(byModel).reduce((s, m) => s + m.tokens, 0);
if (totalTokens === 0) return [];
return Object.entries(byModel).map(([model, stats]) => ({
model,
tokens: stats.tokens,
requests: stats.requests,
cost: stats.cost,
percentage: Math.round((stats.tokens / totalTokens) * 100),
}));
}
function KpiSkeleton() {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4 rounded" />
</CardHeader>
<CardContent>
<Skeleton className="h-7 w-20 mb-2" />
<Skeleton className="h-4 w-32" />
</CardContent>
</Card>
);
}
function ChartSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-[280px] w-full" />
</CardContent>
</Card>
);
}
export default function DashboardPage() {
const [stats, setStats] = useState({
...mockSummaryStats,
totalUsers: 0,
activeUsers: 0,
totalRevenue: 0,
monthlyRecurring: 0,
totalTokensThisMonth: 0,
totalRequestsThisMonth: 0,
avgTokensPerUser: 0,
newUsersThisMonth: 0,
conversionRate: 0,
churnRate: 0,
});
const [dailyMetrics, setDailyMetrics] = useState<DailyMetric[]>([]);
const [recentUsers, setRecentUsers] = useState<User[]>([]);
const [modelUsage, setModelUsage] = useState<ModelUsage[]>([]);
const [revenue, setRevenue] = useState<RevenueAnalytics | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const fetchData = useCallback(async (isRefresh = false) => {
if (isRefresh) setRefreshing(true);
try {
const [statsRes, usageRes, usersRes, revenueRes] = await Promise.allSettled([
apiGetDashboardStats(),
apiGetUsage(30),
apiListUsers(10),
apiGetRevenueAnalytics(6),
]);
if (statsRes.status === 'fulfilled' && statsRes.value.data) {
setStats(prev => mergeApiStats(prev, statsRes.value.data!));
}
if (usageRes.status === 'fulfilled' && usageRes.value.data?.records?.length) {
const metrics = usageRecordsToDailyMetrics(usageRes.value.data.records);
setDailyMetrics(metrics);
setModelUsage(buildModelUsage(usageRes.value.data.records));
}
if (usersRes.status === 'fulfilled' && usersRes.value.data?.users?.length) {
setRecentUsers(
usersRes.value.data.users
.sort((a, b) => b.lastActive.localeCompare(a.lastActive))
.slice(0, 6)
.map(u => ({
id: u.id,
name: u.name,
email: u.email,
plan: u.plan as User['plan'],
status: u.status as User['status'],
createdAt: u.createdAt,
lastActive: u.lastActive,
totalTokensUsed: u.totalTokensUsed,
totalRequests: u.totalRequests,
monthlySpend: u.monthlySpend,
}))
);
}
if (revenueRes.status === 'fulfilled' && revenueRes.value.data) {
setRevenue(revenueRes.value.data);
}
setLastUpdated(new Date());
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(() => fetchData(), 60000);
return () => clearInterval(interval);
}, [fetchData]);
const kpiCards = buildKpiCards(stats, revenue);
return (
<div className="space-y-8">
{/* Header */}
<div>
<PageHeader
title="Dashboard"
className="!mb-2"
actions={
<Button
variant="outline"
size="sm"
onClick={() => fetchData(true)}
disabled={refreshing}
>
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
}
/>
<p className="text-muted-foreground">
Platform overview and key metrics
{lastUpdated && (
<span className="ml-2 text-xs">
&middot; Updated {lastUpdated.toLocaleTimeString()}
</span>
)}
</p>
</div>
{/* KPI Cards */}
{loading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<KpiSkeleton key={i} />
))}
</div>
) : (
<StaggerList
as="div"
from="up"
stagger={50}
className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"
>
{kpiCards.map(card => (
<Card key={card.title} className="transition-shadow hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{card.title}
</CardTitle>
<card.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{card.value}</div>
<div className="flex items-center gap-2 mt-1">
<Badge
variant={card.title === 'Churn Rate' ? 'default' : 'secondary'}
className={`text-xs ${
card.trend === 'up' && card.title !== 'Churn Rate'
? 'text-emerald-600 bg-emerald-50'
: card.title === 'Churn Rate'
? 'text-emerald-600 bg-emerald-50'
: 'text-red-600 bg-red-50'
}`}
>
{card.trend === 'up' && card.title !== 'Churn Rate' ? (
<ArrowUpRight className="h-3 w-3 mr-0.5" />
) : (
<ArrowDownRight className="h-3 w-3 mr-0.5" />
)}
{card.change}
</Badge>
<span className="text-xs text-muted-foreground">{card.subtitle}</span>
</div>
</CardContent>
</Card>
))}
</StaggerList>
)}
{/* Charts Row */}
{loading ? (
<div className="grid gap-6 lg:grid-cols-2">
<ChartSkeleton />
<ChartSkeleton />
</div>
) : (
<div className="grid gap-6 lg:grid-cols-2">
{/* Active Users Chart */}
<Card>
<CardHeader>
<CardTitle className="text-base">Daily Active Users (30 days)</CardTitle>
</CardHeader>
<CardContent>
<AreaChart
values={seriesValues(dailyMetrics, 'activeUsers')}
width={640}
height={280}
className="h-auto w-full"
ariaLabel="Daily active users over the last 30 days"
/>
</CardContent>
</Card>
{/* Revenue Chart */}
<Card>
<CardHeader>
<CardTitle className="text-base">Daily Revenue (30 days)</CardTitle>
</CardHeader>
<CardContent>
<BarChart
data={dateBars(dailyMetrics, 'revenue')}
width={640}
height={280}
className="h-auto w-full"
ariaLabel="Daily revenue over the last 30 days"
/>
</CardContent>
</Card>
</div>
)}
{/* Bottom Row: Model Usage + Recent Users */}
<Reveal from="up" className="grid gap-6 lg:grid-cols-2">
{/* Model Usage */}
<Card>
<CardHeader>
<CardTitle className="text-base">Model Usage Breakdown</CardTitle>
</CardHeader>
<CardContent>
{modelUsage.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Cpu className="h-10 w-10 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">No model usage data yet</p>
<p className="text-xs text-muted-foreground mt-1">
Usage will appear once users start dictating
</p>
</div>
) : (
<div className="space-y-4">
{modelUsage.map(m => (
<div key={m.model} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{m.model}</span>
<span className="text-muted-foreground">
{formatNumber(m.tokens)} tokens &middot; {formatCurrency(m.cost)}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${m.percentage}%` }}
/>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Recent Users */}
<Card>
<CardHeader>
<CardTitle className="text-base">Recent Users</CardTitle>
</CardHeader>
<CardContent>
{recentUsers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Users className="h-10 w-10 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">No users yet</p>
</div>
) : (
<div className="space-y-3">
{recentUsers.map(user => (
<div
key={user.id}
className="flex items-center justify-between rounded-lg p-2 -mx-2 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
{user.name
.split(' ')
.map(n => n[0])
.join('')}
</div>
<div>
<p className="text-sm font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
</div>
<Badge
variant="secondary"
className={
user.plan === 'enterprise'
? 'bg-violet-50 text-violet-700'
: user.plan === 'pro'
? 'bg-blue-50 text-blue-700'
: ''
}
>
{user.plan}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</Reveal>
</div>
);
}