401 lines
15 KiB
TypeScript
401 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,
|
|
Trophy,
|
|
HeartCrack,
|
|
} from 'lucide-react';
|
|
import { Card, CardContent, 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 <Trophy className="h-5 w-5 text-emerald-500" />;
|
|
case 'negative':
|
|
return <HeartCrack 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'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'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>
|
|
);
|
|
}
|