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.
This commit is contained in:
2025-11-24 22:12:25 +01:00
parent 55950e9473
commit 65f1fcb7bb
4 changed files with 396 additions and 25 deletions

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -33,25 +33,21 @@ 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,

View File

@@ -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<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());
@@ -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<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> => {
@@ -148,6 +171,38 @@ export default function Dashboard() {
},
});
// 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({
@@ -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) => (
<Card
key={habit.id}
className={`transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass(
className={`group relative transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass(
habit.type,
)} ${logHabitMutation.isPending ? 'opacity-75' : ''}`}
onClick={() => {
@@ -365,13 +444,24 @@ export default function Dashboard() {
<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)}
>
<MoreVertical className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Last logged */}
<div className="flex items-center gap-2 text-sm">
<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
@@ -379,6 +469,29 @@ export default function Dashboard() {
: '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" />
@@ -439,6 +552,105 @@ export default function Dashboard() {
</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>
);