442 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			442 lines
		
	
	
		
			16 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 AuthData {
 | 
						|
  authenticated: boolean;
 | 
						|
  token?: string;
 | 
						|
  userId?: string;
 | 
						|
}
 | 
						|
 | 
						|
interface Habit {
 | 
						|
  id: number;
 | 
						|
  name: string;
 | 
						|
  type: 'positive' | 'neutral' | 'negative';
 | 
						|
  lastLoggedAt: string | null;
 | 
						|
  totalLogs: number;
 | 
						|
  logsLastWeek: number;
 | 
						|
  logsLastMonth: number;
 | 
						|
  createdAt: string;
 | 
						|
}
 | 
						|
 | 
						|
interface HabitsResponse {
 | 
						|
  habits: Habit[];
 | 
						|
}
 | 
						|
 | 
						|
interface LogResponse {
 | 
						|
  log: {
 | 
						|
    id: number;
 | 
						|
    habitId: number;
 | 
						|
    loggedAt: string;
 | 
						|
    note?: string;
 | 
						|
  };
 | 
						|
}
 | 
						|
 | 
						|
interface HabitResponse {
 | 
						|
  habit: Habit;
 | 
						|
}
 | 
						|
 | 
						|
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<AuthData>({
 | 
						|
    queryKey: ['auth'],
 | 
						|
    queryFn: async (): Promise<AuthData> => {
 | 
						|
      const res = await fetch('/api/auth');
 | 
						|
      const data = (await res.json()) as AuthData;
 | 
						|
      if (!data.authenticated) {
 | 
						|
        router.push('/');
 | 
						|
      }
 | 
						|
      return data;
 | 
						|
    },
 | 
						|
  });
 | 
						|
 | 
						|
  useEffect(() => {
 | 
						|
    if (authData?.token) {
 | 
						|
      setUserToken(authData.token);
 | 
						|
    }
 | 
						|
  }, [authData]);
 | 
						|
 | 
						|
  // Fetch habits
 | 
						|
  const { data: habitsData, isLoading: habitsLoading } = useQuery<HabitsResponse>({
 | 
						|
    queryKey: ['habits'],
 | 
						|
    queryFn: async (): Promise<HabitsResponse> => {
 | 
						|
      const res = await fetch('/api/habits');
 | 
						|
      if (!res.ok) throw new Error('Failed to fetch habits');
 | 
						|
      return res.json() as Promise<HabitsResponse>;
 | 
						|
    },
 | 
						|
    enabled: !!authData?.authenticated,
 | 
						|
  });
 | 
						|
 | 
						|
  // Log habit mutation
 | 
						|
  const logHabitMutation = useMutation<LogResponse, Error, number>({
 | 
						|
    mutationFn: async (habitId: number): Promise<LogResponse> => {
 | 
						|
      const res = await fetch(`/api/habits/${String(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() as Promise<LogResponse>;
 | 
						|
    },
 | 
						|
    onSuccess: () => {
 | 
						|
      void queryClient.invalidateQueries({ queryKey: ['habits'] });
 | 
						|
    },
 | 
						|
  });
 | 
						|
 | 
						|
  // Create habit mutation
 | 
						|
  const createHabitMutation = useMutation<HabitResponse, Error, { name: string; type: string }>({
 | 
						|
    mutationFn: async (data: { name: string; type: string }): Promise<HabitResponse> => {
 | 
						|
      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() as Promise<HabitResponse>;
 | 
						|
    },
 | 
						|
    onSuccess: () => {
 | 
						|
      void 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) {
 | 
						|
      void 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: 'positive' | 'neutral' | 'negative') => {
 | 
						|
                        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>
 | 
						|
  );
 | 
						|
}
 |