Some checks failed
Lint / Lint and Check (push) Failing after 48s
Integrates Playwright for end-to-end browser testing with automated web server setup, example smoke tests, and CI-compatible configuration. Introduces Vitest, Testing Library, and related utilities for fast component and unit testing. Updates scripts, development dependencies, and lockfile to support both test suites. Establishes unified testing commands for local and CI workflows, laying groundwork for comprehensive automated UI and integration coverage.
659 lines
24 KiB
TypeScript
659 lines
24 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,
|
|
MoreVertical,
|
|
Trash2,
|
|
Save,
|
|
RotateCcw,
|
|
} 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();
|
|
|
|
// State
|
|
const [showNewHabitDialog, setShowNewHabitDialog] = useState(false);
|
|
const [newHabitName, setNewHabitName] = useState('');
|
|
const [newHabitType, setNewHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
|
|
|
|
const [editingHabit, setEditingHabit] = useState<Habit | null>(null);
|
|
const [editHabitName, setEditHabitName] = useState('');
|
|
const [editHabitType, setEditHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
|
|
const [copiedToken, setCopiedToken] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
|
|
|
// 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;
|
|
},
|
|
});
|
|
|
|
// Update current time periodically
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
setCurrentTime(Date.now());
|
|
}, 60000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
// 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'] });
|
|
},
|
|
});
|
|
|
|
// Undo log mutation
|
|
const undoLogMutation = useMutation<void, Error, number>({
|
|
mutationFn: async (habitId: number): Promise<void> => {
|
|
const res = await fetch(`/api/habits/${habitId}/log`, {
|
|
method: 'DELETE',
|
|
});
|
|
if (!res.ok) throw new Error('Failed to undo log');
|
|
},
|
|
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');
|
|
},
|
|
});
|
|
|
|
// Update habit mutation
|
|
const updateHabitMutation = useMutation<HabitResponse, Error, { id: number; name: string; type: string }>({
|
|
mutationFn: async ({ id, name, type }): Promise<HabitResponse> => {
|
|
const res = await fetch(`/api/habits/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, type }),
|
|
});
|
|
if (!res.ok) throw new Error('Failed to update habit');
|
|
return res.json() as Promise<HabitResponse>;
|
|
},
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: ['habits'] });
|
|
setEditingHabit(null);
|
|
},
|
|
});
|
|
|
|
// Delete habit mutation
|
|
const deleteHabitMutation = useMutation<void, Error, number>({
|
|
mutationFn: async (id: number): Promise<void> => {
|
|
const res = await fetch(`/api/habits/${id}`, {
|
|
method: 'DELETE',
|
|
});
|
|
if (!res.ok) throw new Error('Failed to delete habit');
|
|
},
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: ['habits'] });
|
|
setEditingHabit(null);
|
|
setShowDeleteConfirm(false);
|
|
},
|
|
});
|
|
|
|
const handleCreateHabit = () => {
|
|
if (newHabitName.trim()) {
|
|
createHabitMutation.mutate({
|
|
name: newHabitName.trim(),
|
|
type: newHabitType,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleUpdateHabit = () => {
|
|
if (editingHabit && editHabitName.trim()) {
|
|
updateHabitMutation.mutate({
|
|
id: editingHabit.id,
|
|
name: editHabitName.trim(),
|
|
type: editHabitType,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDeleteHabit = () => {
|
|
if (editingHabit) {
|
|
deleteHabitMutation.mutate(editingHabit.id);
|
|
}
|
|
};
|
|
|
|
const openEditDialog = (e: React.MouseEvent, habit: Habit) => {
|
|
e.stopPropagation();
|
|
setEditingHabit(habit);
|
|
setEditHabitName(habit.name);
|
|
setEditHabitType(habit.type);
|
|
setShowDeleteConfirm(false);
|
|
};
|
|
|
|
const copyToken = () => {
|
|
if (authData?.token) {
|
|
void navigator.clipboard.writeText(authData.token);
|
|
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((currentTime - 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 */}
|
|
{authData?.token && (
|
|
<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">
|
|
{authData.token}
|
|
</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={`group relative 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>
|
|
<div className="flex items-center gap-2">
|
|
{getHabitIcon(habit.type)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/20"
|
|
onClick={(e) => openEditDialog(e, habit)}
|
|
data-testid={`edit-habit-${habit.id}`}
|
|
>
|
|
<MoreVertical className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
{/* Last logged */}
|
|
<div className="flex items-center justify-between text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<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>
|
|
{habit.lastLoggedAt && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 text-zinc-500 hover:text-red-400 hover:bg-red-950/30"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
undoLogMutation.mutate(habit.id);
|
|
}}
|
|
>
|
|
<RotateCcw className="h-3 w-3" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Undo last log</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
</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>
|
|
|
|
{/* Edit Habit Dialog */}
|
|
<Dialog open={!!editingHabit} onOpenChange={(open) => !open && setEditingHabit(null)}>
|
|
<DialogContent className="border-zinc-800 bg-zinc-950">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Habit</DialogTitle>
|
|
<DialogDescription>
|
|
Modify your habit details or archive it if you no longer want to track it.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="edit-name">Habit Name</Label>
|
|
<Input
|
|
id="edit-name"
|
|
value={editHabitName}
|
|
onChange={(e) => setEditHabitName(e.target.value)}
|
|
className="border-zinc-800 bg-zinc-900"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="edit-type">Habit Type</Label>
|
|
<Select
|
|
value={editHabitType}
|
|
onValueChange={(value: 'positive' | 'neutral' | 'negative') => {
|
|
setEditHabitType(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 className="flex-col items-stretch gap-2 sm:flex-row sm:justify-between">
|
|
<div className="flex flex-1 justify-start">
|
|
{showDeleteConfirm ? (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleDeleteHabit}
|
|
disabled={deleteHabitMutation.isPending}
|
|
>
|
|
{deleteHabitMutation.isPending ? 'Deleting...' : 'Confirm Delete'}
|
|
</Button>
|
|
<Button variant="ghost" onClick={() => setShowDeleteConfirm(false)}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
variant="outline"
|
|
className="border-red-900 text-red-500 hover:bg-red-950 hover:text-red-400"
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
>
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
Archive Habit
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 justify-end">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setEditingHabit(null)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleUpdateHabit}
|
|
disabled={!editHabitName.trim() || updateHabitMutation.isPending}
|
|
className="bg-emerald-600 hover:bg-emerald-700"
|
|
>
|
|
<Save className="mr-2 h-4 w-4" />
|
|
{updateHabitMutation.isPending ? 'Saving...' : 'Save Changes'}
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|