Files
ProjectE/apps/web/app/(dashboard)/analytics/page.tsx
T
mbatchelder 8f55626e03 refactor: migrate to monorepo structure with Docker, PocketBase, and e2e tests
- Reorganize into apps/, packages/, docs/, e2e/, pocketbase/ directories
- Add Dockerfiles for web, worker, and PocketBase services
- Add docker-compose.yml for local orchestration
- Add turbo.json for monorepo task management
- Add Playwright e2e test infrastructure
- Add PocketBase backend with migrations
- Remove Vite/Next.js/ESLint/PostCSS config files
- Update package.json with workspace dependencies
- Add .env.example and .dockerignore
2026-07-16 06:19:58 -04:00

284 lines
8.5 KiB
TypeScript

'use client';
import { useEffect, useState, Suspense } from 'react';
import {
TrendingUp,
Target,
Clock,
Flame,
BarChart3,
PieChart as PieChartIcon,
} from 'lucide-react';
import dynamic from 'next/dynamic';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs';
// Lazy load recharts (~180KB)
const AnalyticsCharts = dynamic(
() => import('@/components/analytics/analytics-charts').then((m) => m.AnalyticsCharts),
{
ssr: false,
loading: () => (
<div className="grid grid-cols-1 gap-6">
{[1, 2].map((i) => (
<div key={i} className="h-[350px] animate-pulse rounded-lg border bg-muted/30 p-6">
<div className="mb-4 h-5 w-40 rounded bg-muted/50" />
<div className="h-full rounded bg-muted/30" />
</div>
))}
</div>
),
}
);
interface AnalyticsData {
taskCompletionRate: number;
habitConsistency: number;
totalTimeMinutes: number;
activeStreaks: number;
bestStreak: number;
period: number;
}
interface TimeData {
date: string;
tasks: number;
habits: number;
time: number;
}
interface DomainData {
name: string;
value: number;
color: string;
}
interface HabitData {
name: string;
streak: number;
score: number;
consistency: number;
}
export default function AnalyticsPage() {
const [analytics, setAnalytics] = useState<AnalyticsData | null>(null);
const [timeData, setTimeData] = useState<TimeData[]>([]);
const [domainData, setDomainData] = useState<DomainData[]>([]);
const [habitData, setHabitData] = useState<HabitData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchAnalytics();
}, []);
async function fetchAnalytics() {
try {
// Fetch overall analytics
const analyticsResponse = await fetch('/api/analytics?period=30');
if (analyticsResponse.ok) {
const analyticsData = await analyticsResponse.json();
setAnalytics(analyticsData);
}
// Fetch time summary
const timeResponse = await fetch('/api/time-summary?period=30');
if (timeResponse.ok) {
const timeSummary = await timeResponse.json();
// Transform to domain data for pie chart
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4'];
const domains: DomainData[] = Object.entries(
timeSummary.byDomain || {}
).map(([name, value], index) => ({
name,
value: value as number,
color: COLORS[index % COLORS.length],
}));
setDomainData(domains);
}
// Fetch habit streaks
const habitsResponse = await fetch('/api/habits/streaks');
if (habitsResponse.ok) {
const habitsData = await habitsResponse.json();
const habits: HabitData[] = (habitsData.streaks || []).map(
(s: {
habit: { name: string; score?: number };
current_streak: number;
best_streak: number;
}) => ({
name: s.habit.name,
streak: s.current_streak,
score: s.habit.score || 0,
consistency: 0, // Would need to calculate from logs
})
);
setHabitData(habits);
}
// Generate sample time data (would come from API in production)
const sampleTimeData: TimeData[] = Array.from({ length: 30 }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - (29 - i));
return {
date: date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
}),
tasks: Math.floor(Math.random() * 10) + 2,
habits: Math.floor(Math.random() * 5) + 1,
time: Math.floor(Math.random() * 180) + 30,
};
});
setTimeData(sampleTimeData);
} catch (error) {
console.error('Failed to fetch analytics:', error);
} finally {
setLoading(false);
}
}
if (loading || !analytics) {
return <p className="text-muted-foreground">Loading analytics...</p>;
}
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold">Analytics</h1>
<p className="mt-1 text-muted-foreground">Patterns behind your progress.</p>
</div>
{/* Summary cards */}
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
Task Completion
</CardTitle>
<Target className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{analytics.taskCompletionRate}%
</div>
<p className="text-xs text-muted-foreground">
Last {analytics.period} days
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
Habit Consistency
</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{analytics.habitConsistency}%
</div>
<p className="text-xs text-muted-foreground">
Last {analytics.period} days
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Time Tracked</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{Math.round(analytics.totalTimeMinutes / 60)}h
</div>
<p className="text-xs text-muted-foreground">
Last {analytics.period} days
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
Active Streaks
</CardTitle>
<Flame className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{analytics.activeStreaks}</div>
<p className="text-xs text-muted-foreground">
Best: {analytics.bestStreak} days
</p>
</CardContent>
</Card>
</div>
{/* Charts */}
<Tabs defaultValue="trends">
<TabsList>
<TabsTrigger value="trends">Trends</TabsTrigger>
<TabsTrigger value="habits">Habits</TabsTrigger>
<TabsTrigger value="time">Time</TabsTrigger>
</TabsList>
<TabsContent value="trends" className="mt-6">
<Suspense
fallback={
<div className="grid grid-cols-1 gap-6">
{[1, 2].map((i) => (
<div key={i} className="h-[350px] animate-pulse rounded-lg border bg-muted/30 p-6" />
))}
</div>
}
>
<AnalyticsCharts
timeData={timeData}
domainData={domainData}
habitData={habitData}
activeTab="trends"
/>
</Suspense>
</TabsContent>
<TabsContent value="habits" className="mt-6">
<Suspense
fallback={
<div className="h-[350px] animate-pulse rounded-lg border bg-muted/30 p-6" />
}
>
<AnalyticsCharts
timeData={timeData}
domainData={domainData}
habitData={habitData}
activeTab="habits"
/>
</Suspense>
</TabsContent>
<TabsContent value="time" className="mt-6">
<Suspense
fallback={
<div className="h-[350px] animate-pulse rounded-lg border bg-muted/30 p-6" />
}
>
<AnalyticsCharts
timeData={timeData}
domainData={domainData}
habitData={habitData}
activeTab="time"
/>
</Suspense>
</TabsContent>
</Tabs>
</div>
);
}