diff --git a/app/api/habits/[id]/log/route.ts b/app/api/habits/[id]/log/route.ts index 3db92d8..0496b62 100644 --- a/app/api/habits/[id]/log/route.ts +++ b/app/api/habits/[id]/log/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db, habits, users, habitLogs } from '@/lib/db'; import { getTokenCookie } from '@/lib/auth/cookies'; -import { eq, and } from 'drizzle-orm'; +import { eq, and, desc } from 'drizzle-orm'; async function getUserFromToken() { const token = await getTokenCookie(); @@ -58,3 +58,49 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } + +export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const habitId = parseInt(id); + + if (isNaN(habitId)) { + return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 }); + } + + const user = await getUserFromToken(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Verify habit belongs to user + const habitRows = await db + .select() + .from(habits) + .where(and(eq(habits.id, habitId), eq(habits.userId, user.id))); + + if (habitRows.length === 0) { + return NextResponse.json({ error: 'Habit not found' }, { status: 404 }); + } + + // Find latest log + const latestLog = await db + .select() + .from(habitLogs) + .where(eq(habitLogs.habitId, habitId)) + .orderBy(desc(habitLogs.loggedAt)) + .limit(1); + + if (latestLog.length === 0) { + return NextResponse.json({ error: 'No logs to undo' }, { status: 404 }); + } + + // Delete latest log + await db.delete(habitLogs).where(eq(habitLogs.id, latestLog[0].id)); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Undo log error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/habits/[id]/route.ts b/app/api/habits/[id]/route.ts new file mode 100644 index 0000000..06ad34e --- /dev/null +++ b/app/api/habits/[id]/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db, habits, users } from '@/lib/db'; +import { getTokenCookie } from '@/lib/auth/cookies'; +import { eq, and } from 'drizzle-orm'; + +async function getUserFromToken() { + const token = await getTokenCookie(); + if (!token) return null; + + const userRows = await db.select().from(users).where(eq(users.token, token)); + return userRows.length > 0 ? userRows[0] : null; +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const habitId = parseInt(id); + + if (isNaN(habitId)) { + return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 }); + } + + const user = await getUserFromToken(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Verify habit belongs to user + const habitRows = await db + .select() + .from(habits) + .where(and(eq(habits.id, habitId), eq(habits.userId, user.id))); + + if (habitRows.length === 0) { + return NextResponse.json({ error: 'Habit not found' }, { status: 404 }); + } + + const body = (await request.json()) as { + name?: string; + type?: string; + color?: string; + icon?: string; + targetFrequency?: { value: number; period: 'day' | 'week' | 'month' }; + }; + + const { name, type, color, icon, targetFrequency } = body; + + // Validate type if provided + if (type && !['positive', 'neutral', 'negative'].includes(type)) { + return NextResponse.json( + { error: 'Type must be one of: positive, neutral, negative' }, + { status: 400 } + ); + } + + const updatedHabitRows = await db + .update(habits) + .set({ + ...(name && { name }), + ...(type && { type: type as 'positive' | 'neutral' | 'negative' }), + ...(color && { color }), + ...(icon && { icon }), + ...(targetFrequency && { targetFrequency }), + }) + .where(and(eq(habits.id, habitId), eq(habits.userId, user.id))) + .returning(); + + return NextResponse.json({ habit: updatedHabitRows[0] }); + } catch (error) { + console.error('Update habit error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const habitId = parseInt(id); + + if (isNaN(habitId)) { + return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 }); + } + + const user = await getUserFromToken(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Verify habit belongs to user + const habitRows = await db + .select() + .from(habits) + .where(and(eq(habits.id, habitId), eq(habits.userId, user.id))); + + if (habitRows.length === 0) { + return NextResponse.json({ error: 'Habit not found' }, { status: 404 }); + } + + // Soft delete (archive) + await db + .update(habits) + .set({ isArchived: true, archivedAt: new Date() }) + .where(and(eq(habits.id, habitId), eq(habits.userId, user.id))); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Delete habit error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + diff --git a/app/api/habits/route.ts b/app/api/habits/route.ts index f047716..27bbfe6 100644 --- a/app/api/habits/route.ts +++ b/app/api/habits/route.ts @@ -33,26 +33,22 @@ export async function GET() { // Then get aggregated log data for each habit const habitsWithStats = await Promise.all( userHabitsBase.map(async (habit) => { - // Get all logs for this habit + // Get all logs for this habit, ordered by date desc const logs = await db .select({ + id: habitLogs.id, loggedAt: habitLogs.loggedAt, }) .from(habitLogs) - .where(eq(habitLogs.habitId, habit.id)); + .where(eq(habitLogs.habitId, habit.id)) + .orderBy(desc(habitLogs.loggedAt)); // Calculate statistics const totalLogs = logs.length; const logsLastWeek = logs.filter((log) => log.loggedAt >= sevenDaysAgo).length; const logsLastMonth = logs.filter((log) => log.loggedAt >= thirtyDaysAgo).length; - const lastLoggedAt = - logs.length > 0 - ? logs.reduce( - (latest, log) => (log.loggedAt > latest ? log.loggedAt : latest), - logs[0].loggedAt, - ) - : null; - + const lastLoggedAt = logs.length > 0 ? logs[0].loggedAt : null; + return { id: habit.id, name: habit.name, diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index b5ee8b6..d66ad68 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -16,6 +16,10 @@ import { 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'; @@ -73,9 +77,17 @@ interface HabitResponse { 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(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()); @@ -92,14 +104,12 @@ export default function Dashboard() { }, }); - // Update current time periodically to avoid impure Date.now() calls during render + // Update current time periodically useEffect(() => { const interval = setInterval(() => { setCurrentTime(Date.now()); - }, 60000); // Update every minute - return () => { - clearInterval(interval); - }; + }, 60000); + return () => clearInterval(interval); }, []); // Fetch habits @@ -129,6 +139,19 @@ export default function Dashboard() { }, }); + // Undo log mutation + const undoLogMutation = useMutation({ + mutationFn: async (habitId: number): Promise => { + 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({ mutationFn: async (data: { name: string; type: string }): Promise => { @@ -148,6 +171,38 @@ export default function Dashboard() { }, }); + // Update habit mutation + const updateHabitMutation = useMutation({ + mutationFn: async ({ id, name, type }): Promise => { + 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; + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['habits'] }); + setEditingHabit(null); + }, + }); + + // Delete habit mutation + const deleteHabitMutation = useMutation({ + mutationFn: async (id: number): Promise => { + 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({ @@ -157,6 +212,30 @@ export default function Dashboard() { } }; + 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); @@ -355,7 +434,7 @@ export default function Dashboard() { {habits.map((habit: Habit) => ( { @@ -365,19 +444,53 @@ export default function Dashboard() {
{habit.name} - {getHabitIcon(habit.type)} +
+ {getHabitIcon(habit.type)} + +
{/* Last logged */} -
- - - {habit.lastLoggedAt - ? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true }) - : 'Never logged'} - +
+
+ + + {habit.lastLoggedAt + ? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true }) + : 'Never logged'} + +
+ {habit.lastLoggedAt && ( + + + + + + +

Undo last log

+
+
+
+ )}
@@ -439,6 +552,105 @@ export default function Dashboard() { )}
+ + {/* Edit Habit Dialog */} + !open && setEditingHabit(null)}> + + + Edit Habit + + Modify your habit details or archive it if you no longer want to track it. + + +
+
+ + setEditHabitName(e.target.value)} + className="border-zinc-800 bg-zinc-900" + /> +
+
+ + +
+
+ +
+ {showDeleteConfirm ? ( +
+ + +
+ ) : ( + + )} +
+
+ + +
+
+
+
);