initial webapp
This commit is contained in:
398
app/dashboard/page.tsx
Normal file
398
app/dashboard/page.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
'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'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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user