- @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>
489 lines
16 KiB
TypeScript
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">
|
|
· 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 · {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>
|
|
);
|
|
}
|