Files
ProjectE/apps/web/src/routes/_app/habits.tsx
T

266 lines
12 KiB
TypeScript

import { useState } from "react";
import { createRoute } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useRealtime } from "@/hooks/use-realtime";
import { Plus, Flame, Trash2, Check, Calendar, TrendingUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import type { Habit, HabitCompletion, PaginatedResponse } from "@/lib/types";
function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
const queryClient = useQueryClient();
const [name, setName] = useState(habit?.name || "");
const [description, setDescription] = useState(habit?.description || "");
const [frequency, setFrequency] = useState(habit?.frequency || "daily");
const [difficulty, setDifficulty] = useState(habit?.difficulty || "medium");
const [goalPerPeriod, setGoalPerPeriod] = useState(habit?.goalPerPeriod || 1);
const createMutation = useMutation({
mutationFn: (data: any) => api.post<Habit>("/habits", data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); onClose(); },
});
const updateMutation = useMutation({
mutationFn: (data: any) => api.patch<Habit>("/habits/" + habit!.id, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); onClose(); },
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
const data = { name: name.trim(), description: description || null, frequency, difficulty, goalPerPeriod };
if (habit) updateMutation.mutate(data);
else createMutation.mutate(data);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="name">Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Habit name" required />
</div>
<div>
<Label htmlFor="desc">Description</Label>
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={2} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="freq">Frequency</Label>
<Select value={frequency} onValueChange={setFrequency}>
<SelectTrigger id="freq"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="diff">Difficulty</Label>
<Select value={difficulty} onValueChange={setDifficulty}>
<SelectTrigger id="diff"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="easy">Easy</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="hard">Hard</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="goal">Goal per period</Label>
<Input id="goal" type="number" min={1} value={goalPerPeriod} onChange={(e) => setGoalPerPeriod(parseInt(e.target.value) || 1)} />
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
{habit ? "Update" : "Create"} Habit
</Button>
</div>
</form>
);
}
function MiniGrid({ completions, days = 7 }: { completions: HabitCompletion[]; days?: number }) {
const completionDates = new Set(completions.map((c) => new Date(c.date).toISOString().slice(0, 10)));
const cells = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const key = d.toISOString().slice(0, 10);
const done = completionDates.has(key);
cells.push(
<div
key={key}
className={cn("w-3 h-3 rounded-sm", done ? "bg-green-500" : "bg-muted")}
title={key + (done ? " ✓" : "")}
/>
);
}
return <div className="flex gap-0.5 items-center">{cells}</div>;
}
function HabitsPage() {
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [selectedHabit, setSelectedHabit] = useState<Habit | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const [detailTab, setDetailTab] = useState("overview");
useRealtime({ enabled: true });
const { data: habitsData, isLoading } = useApiQuery<PaginatedResponse<Habit>>(
["habits"],
"/habits?limit=200"
);
const habits = habitsData?.items || [];
const completeMutation = useMutation({
mutationFn: (id: string) => api.post("/habits/" + id + "/complete", {}),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); },
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/habits/" + id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); setPanelOpen(false); },
});
const openHabitDetail = async (habit: Habit) => {
try {
const detail = await api.get<Habit>("/habits/" + habit.id);
setSelectedHabit(detail);
} catch {
setSelectedHabit(habit);
}
setPanelOpen(true);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Habits</h1>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button aria-label="New habit"><Plus className="h-4 w-4 mr-2" />New Habit</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>New Habit</DialogTitle></DialogHeader>
<HabitForm onClose={() => setCreateOpen(false)} />
</DialogContent>
</Dialog>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading habits...</div>
) : habits.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">No habits yet. Create your first one!</div>
) : (
<div className="space-y-2">
{habits.map((habit) => (
<Card key={habit.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => openHabitDetail(habit)}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Flame className={cn("h-5 w-5 shrink-0", habit.streakCount > 0 ? "text-orange-500" : "text-muted-foreground")} />
<div className="min-w-0">
<p className="font-medium truncate">{habit.name}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-sm text-muted-foreground">
<Flame className="h-3 w-3 inline mr-0.5" />
{habit.streakCount} day streak
</span>
<Badge variant="secondary" className="text-[10px]">{habit.frequency}</Badge>
<Badge variant="outline" className="text-[10px]">{habit.difficulty}</Badge>
</div>
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<MiniGrid completions={habit.recentCompletions || []} />
<Button
size="sm"
variant="outline"
onClick={(e) => { e.stopPropagation(); completeMutation.mutate(habit.id); }}
aria-label={"Mark " + habit.name + " complete"}
>
<Check className="h-4 w-4 mr-1" />Complete
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedHabit?.name || "Habit Details"}>
{selectedHabit && (
<div className="space-y-4">
<Tabs value={detailTab} onValueChange={setDetailTab}>
<TabsList className="w-full">
<TabsTrigger value="overview" className="flex-1">Overview</TabsTrigger>
<TabsTrigger value="history" className="flex-1">History</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4 pt-4">
<HabitForm habit={selectedHabit} onClose={() => setPanelOpen(false)} />
<div className="pt-4 border-t">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm"><Trash2 className="h-4 w-4 mr-2" />Delete Habit</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Habit</AlertDialogTitle>
<AlertDialogDescription>Are you sure you want to delete "{selectedHabit.name}"?</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteMutation.mutate(selectedHabit.id)} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TabsContent>
<TabsContent value="history" className="pt-4">
<div className="space-y-2">
<h3 className="font-semibold text-sm">Completion History</h3>
{selectedHabit.recentCompletions?.length ? (
<div className="space-y-1">
{selectedHabit.recentCompletions.map((c) => (
<div key={c.id} className="flex items-center justify-between text-sm py-1 border-b last:border-0">
<span>{new Date(c.date).toLocaleDateString()}</span>
<Badge variant="secondary"><Check className="h-3 w-3 mr-1" />{c.value}x</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No completions yet.</p>
)}
</div>
</TabsContent>
</Tabs>
</div>
)}
</EntityDetailPanel>
</div>
);
}
export const Route = createRoute({
getParentRoute: () => appRoute,
path: "/habits",
component: HabitsPage,
});