Files
trackevery-day/app/dashboard/page.tsx
2025-07-15 18:58:21 +02:00

399 lines
15 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import {
Plus,
TrendingUp,
TrendingDown,
Activity,
Clock,
Calendar,
Target,
Copy,
Check,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface Habit {
id: number;
name: string;
type: 'positive' | 'neutral' | 'negative';
lastLoggedAt: string | null;
totalLogs: number;
logsLastWeek: number;
logsLastMonth: number;
createdAt: string;
}
export default function Dashboard() {
const router = useRouter();
const queryClient = useQueryClient();
const [userToken, setUserToken] = useState<string | null>(null);
const [showNewHabitDialog, setShowNewHabitDialog] = useState(false);
const [newHabitName, setNewHabitName] = useState('');
const [newHabitType, setNewHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
const [copiedToken, setCopiedToken] = useState(false);
// Check authentication
const { data: authData, isLoading: authLoading } = useQuery({
queryKey: ['auth'],
queryFn: async () => {
const res = await fetch('/api/auth');
const data = await res.json();
if (!data.authenticated) {
router.push('/');
}
return data;
},
});
useEffect(() => {
if (authData?.token) {
setUserToken(authData.token);
}
}, [authData]);
// Fetch habits
const { data: habitsData, isLoading: habitsLoading } = useQuery({
queryKey: ['habits'],
queryFn: async () => {
const res = await fetch('/api/habits');
if (!res.ok) throw new Error('Failed to fetch habits');
return res.json();
},
enabled: !!authData?.authenticated,
});
// Log habit mutation
const logHabitMutation = useMutation({
mutationFn: async (habitId: number) => {
const res = await fetch(`/api/habits/${habitId}/log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!res.ok) throw new Error('Failed to log habit');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['habits'] });
},
});
// Create habit mutation
const createHabitMutation = useMutation({
mutationFn: async (data: { name: string; type: string }) => {
const res = await fetch('/api/habits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Failed to create habit');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['habits'] });
setShowNewHabitDialog(false);
setNewHabitName('');
setNewHabitType('neutral');
},
});
const handleCreateHabit = () => {
if (newHabitName.trim()) {
createHabitMutation.mutate({
name: newHabitName.trim(),
type: newHabitType,
});
}
};
const copyToken = () => {
if (userToken) {
navigator.clipboard.writeText(userToken);
setCopiedToken(true);
setTimeout(() => setCopiedToken(false), 2000);
}
};
const getHabitCardClass = (type: string) => {
switch (type) {
case 'positive':
return 'border-emerald-600 bg-emerald-950/50 hover:bg-emerald-900/50 hover:border-emerald-500';
case 'negative':
return 'border-red-600 bg-red-950/50 hover:bg-red-900/50 hover:border-red-500';
default:
return 'border-zinc-700 bg-zinc-950/50 hover:bg-zinc-900/50 hover:border-zinc-600';
}
};
const getHabitIcon = (type: string) => {
switch (type) {
case 'positive':
return <TrendingUp className="h-5 w-5 text-emerald-500" />;
case 'negative':
return <TrendingDown className="h-5 w-5 text-red-500" />;
default:
return <Activity className="h-5 w-5 text-zinc-500" />;
}
};
const getHabitBadgeVariant = (type: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
switch (type) {
case 'positive':
return 'default';
case 'negative':
return 'destructive';
default:
return 'secondary';
}
};
const getAverageFrequency = (habit: Habit) => {
const daysSinceCreation = Math.max(
1,
Math.floor((Date.now() - new Date(habit.createdAt).getTime()) / (1000 * 60 * 60 * 24)),
);
if (daysSinceCreation <= 7) {
const avg = habit.totalLogs / daysSinceCreation;
return `${avg.toFixed(1)}/day`;
} else if (daysSinceCreation <= 30) {
const weeks = daysSinceCreation / 7;
const avg = habit.totalLogs / weeks;
return `${avg.toFixed(1)}/week`;
} else {
const months = daysSinceCreation / 30;
const avg = habit.totalLogs / months;
return `${avg.toFixed(1)}/month`;
}
};
if (authLoading || habitsLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-black">
<div className="text-zinc-400">Loading...</div>
</div>
);
}
const habits = habitsData?.habits || [];
return (
<div className="min-h-screen bg-black">
<div className="mx-auto max-w-7xl p-4 md:p-8">
{/* Header */}
<div className="mb-8">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="mb-2 text-4xl font-bold text-white">Track Every Day</h1>
<p className="text-zinc-400">Build better habits, one day at a time.</p>
</div>
<Dialog open={showNewHabitDialog} onOpenChange={setShowNewHabitDialog}>
<DialogTrigger asChild>
<Button size="lg" className="bg-emerald-600 hover:bg-emerald-700">
<Plus className="mr-2 h-5 w-5" />
New Habit
</Button>
</DialogTrigger>
<DialogContent className="border-zinc-800 bg-zinc-950">
<DialogHeader>
<DialogTitle>Create New Habit</DialogTitle>
<DialogDescription>
Add a new habit to track. Choose whether it&apos;s something you want to do more,
less, or just monitor.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Habit Name</Label>
<Input
id="name"
placeholder="e.g., Exercise, Read, Meditate..."
value={newHabitName}
onChange={(e) => setNewHabitName(e.target.value)}
className="border-zinc-800 bg-zinc-900"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="type">Habit Type</Label>
<Select value={newHabitType} onValueChange={(value: any) => setNewHabitType(value)}>
<SelectTrigger className="border-zinc-800 bg-zinc-900">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-zinc-800 bg-zinc-900">
<SelectItem value="positive">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-emerald-500" />
<span>Positive - Something to do more</span>
</div>
</SelectItem>
<SelectItem value="neutral">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-zinc-500" />
<span>Neutral - Just tracking</span>
</div>
</SelectItem>
<SelectItem value="negative">
<div className="flex items-center gap-2">
<TrendingDown className="h-4 w-4 text-red-500" />
<span>Negative - Something to reduce</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNewHabitDialog(false)}>
Cancel
</Button>
<Button
onClick={handleCreateHabit}
disabled={!newHabitName.trim() || createHabitMutation.isPending}
className="bg-emerald-600 hover:bg-emerald-700"
>
{createHabitMutation.isPending ? 'Creating...' : 'Create Habit'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Token Alert */}
{userToken && (
<Alert className="border-zinc-800 bg-zinc-950">
<AlertDescription className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm text-zinc-400">Your access token:</p>
<code className="rounded bg-zinc-900 px-2 py-1 font-mono text-sm text-white">
{userToken}
</code>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" onClick={copyToken} className="ml-4">
{copiedToken ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{copiedToken ? 'Copied!' : 'Copy token'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</AlertDescription>
</Alert>
)}
</div>
{/* Habits Grid */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{habits.map((habit: Habit) => (
<Card
key={habit.id}
className={`transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass(
habit.type,
)} ${logHabitMutation.isPending ? 'opacity-75' : ''}`}
onClick={() => logHabitMutation.mutate(habit.id)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<CardTitle className="text-lg">{habit.name}</CardTitle>
{getHabitIcon(habit.type)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Last logged */}
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-zinc-500" />
<span className="text-zinc-400">
{habit.lastLoggedAt
? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
: 'Never logged'}
</span>
</div>
<Separator className="bg-zinc-800" />
{/* Stats */}
<div className="grid grid-cols-2 gap-2">
<div className="text-center">
<p className="text-2xl font-bold">{habit.totalLogs}</p>
<p className="text-xs text-zinc-500">Total</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{habit.logsLastWeek}</p>
<p className="text-xs text-zinc-500">This week</p>
</div>
</div>
{/* Average frequency badge */}
<div className="flex justify-center pt-2">
<Badge variant={getHabitBadgeVariant(habit.type)} className="font-normal">
<Target className="mr-1 h-3 w-3" />
{getAverageFrequency(habit)}
</Badge>
</div>
{/* Motivational message */}
{habit.type === 'positive' && habit.totalLogs > 0 && (
<p className="pt-2 text-center text-xs text-emerald-400">
Keep up the great work! 💪
</p>
)}
{habit.type === 'negative' && habit.lastLoggedAt && (
<p className="pt-2 text-center text-xs text-red-400">
Stay mindful, you&apos;ve got this! 🎯
</p>
)}
</div>
</CardContent>
</Card>
))}
{/* Empty state */}
{habits.length === 0 && (
<Card className="border-dashed border-zinc-800 bg-zinc-950/50 md:col-span-2 lg:col-span-3 xl:col-span-4">
<CardContent className="flex flex-col items-center justify-center py-12">
<Calendar className="mb-4 h-12 w-12 text-zinc-600" />
<h3 className="mb-2 text-lg font-semibold">No habits yet</h3>
<p className="mb-4 text-sm text-zinc-500">Start building better habits today</p>
<Button
onClick={() => setShowNewHabitDialog(true)}
className="bg-emerald-600 hover:bg-emerald-700"
>
<Plus className="mr-2 h-4 w-4" />
Create Your First Habit
</Button>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}