From 65f1fcb7bb16460dd274a7a87946822280beff15 Mon Sep 17 00:00:00 2001 From: Felix Schulze Date: Mon, 24 Nov 2025 22:12:25 +0100 Subject: [PATCH] Adds habit edit, archive, and undo log features Enables users to update or archive habits and to undo the latest habit log. Adds PATCH/DELETE API endpoints for habit edit and soft deletion. Introduces UI dialogs and controls for editing and archiving habits, as well as for undoing the most recent log entry directly from the dashboard. Improves log statistics handling by ordering and simplifies last log detection. --- app/api/habits/[id]/log/route.ts | 48 ++++++- app/api/habits/[id]/route.ts | 117 +++++++++++++++ app/api/habits/route.ts | 16 +-- app/dashboard/page.tsx | 240 +++++++++++++++++++++++++++++-- 4 files changed, 396 insertions(+), 25 deletions(-) create mode 100644 app/api/habits/[id]/route.ts 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 ? ( +
+ + +
+ ) : ( + + )} +
+
+ + +
+
+
+
);