import { useState } from "react"; import { createRoute, useParams, useNavigate } from "@tanstack/react-router"; import { Route as appRoute } from "../../_app"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Bell, Calendar, CalendarOff, CheckCircle2, Clock, Flame, RepeatIcon, Target, Trash2, } from "lucide-react"; import { format, parseISO } from "date-fns"; import { api, useApiQuery } from "@/lib/api"; import { useRealtime } from "@/hooks/use-realtime"; import { useOptimisticPatch } from "@/hooks/use-optimistic-patch"; import { EntityDetailPage } from "@/components/entities/detail-page"; import { InlineEdit, InlineSelect, InlineText, InlineTextarea, InlineToggle, type InlineSelectOption, } from "@/components/entities/inline-edit"; import { EntityActivity } from "@/components/entities/entity-activity"; import { EntityComments } from "@/components/entities/entity-comments"; import { TagManager } from "@/components/entities/tag-manager"; import { CalendarHeatmap } from "@/components/charts"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { LoadingState, ErrorState } from "@/components/state"; import type { Habit } from "@/lib/types"; const FREQUENCY_OPTIONS: InlineSelectOption[] = [ { value: "daily", label: "Daily" }, { value: "weekly", label: "Weekly" }, { value: "custom", label: "Custom" }, ]; const DIFFICULTY_OPTIONS: InlineSelectOption[] = [ { value: "easy", label: "Easy" }, { value: "medium", label: "Medium" }, { value: "hard", label: "Hard" }, ]; /** Difficulty badge text colors (no shared token exists for habit difficulty). */ const DIFFICULTY_COLOR: Record = { easy: "text-green-600", medium: "text-amber-600", hard: "text-red-600", }; const DAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; // Query keys to keep fresh after any habit-affecting mutation. Mirrors the // realtime hook's invalidation so list/analytics views never go stale. const LIST_KEYS: string[][] = [ ["habits"], ["habits-today"], ["streaks"], ["analytics-habits"], ]; type PatchFn = (vars: { id: string; data: Record }) => void; function errorMessage(err: unknown): string { return err instanceof Error ? err.message : "Something went wrong"; } function HabitDetail() { const { id } = useParams({ from: Route.id }); const navigate = useNavigate(); const queryClient = useQueryClient(); const [days, setDays] = useState(30); useRealtime({ enabled: true }); const { data: habit, isLoading, isError, error, refetch } = useApiQuery( ["habit", id, String(days)], `/habits/${id}?days=${days}` ); const { patch } = useOptimisticPatch({ entityKey: ["habit", id, String(days)], listKeys: LIST_KEYS, patchUrl: (hid) => `/habits/${hid}`, applyPatch: (current, data) => ({ ...current, ...data }), }); const logToday = useMutation({ mutationFn: () => api.post(`/habits/${id}/complete`, {}), onSuccess: () => { toast.success("Logged for today"); queryClient.invalidateQueries({ queryKey: ["habit", id] }); for (const key of LIST_KEYS) queryClient.invalidateQueries({ queryKey: key }); }, onError: (err) => toast.error(errorMessage(err)), }); const deleteMutation = useMutation({ mutationFn: () => api.delete(`/habits/${id}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); toast.success("Habit deleted"); navigate({ to: "/habits" }); }, onError: (err) => toast.error(errorMessage(err)), }); if (isLoading) return ; if (isError) { return refetch()} />; } if (!habit) return ; return ( patch({ id, data: { name } })} placeholder="Untitled habit" /> } icon={} badges={ <> patch({ id, data: { active } })} label={habit.active ? "Active" : "Inactive"} /> patch({ id, data: { frequency } })} /> } actions={ <> Delete Habit Are you sure you want to delete "{habit.name}"? This action cannot be undone. Cancel deleteMutation.mutate()} > Delete } tabs={[ { value: "overview", label: "Overview", content: }, { value: "stats", label: "Stats", content: , }, { value: "log", label: "Log", content: }, { value: "activity", label: "Activity", content: , }, { value: "comments", label: "Comments", content: , }, ]} /> ); } function Overview({ habit, patch }: { habit: Habit; patch: PatchFn }) { const completions = habit.recentCompletions || []; const distinctCompletionDays = new Set( completions.map((c) => c.date.slice(0, 10)) ).size; const skipDays = habit.skipDays || []; return (

Description

patch({ id: habit.id, data: { description: description || null } }) } placeholder="Add a description…" />
patch({ id: habit.id, data: { frequency } })} />
( {DIFFICULTY_OPTIONS.find((o) => o.value === v)?.label ?? v} )} onSave={(difficulty) => patch({ id: habit.id, data: { difficulty } })} />
patch({ id: habit.id, data: { goalPerPeriod: Number(goal) || 1 } }) } display={(goal) => ( {habit.unit ? `${goal} ${habit.unit} per period` : `${goal} per period`} )} renderEdit={(v, onChange, commit, cancel) => ( onChange(Number(e.target.value))} onKeyDown={(e) => { if (e.key === "Enter") commit(); else if (e.key === "Escape") cancel(); }} onBlur={() => commit()} autoFocus className="h-7 w-24 text-sm" /> )} />
patch({ id: habit.id, data: { reminderTime: time || null } }) } display={(time) => time ? ( Reminder {time} ) : ( No reminder ) } renderEdit={(v, onChange, commit, cancel) => ( onChange(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") commit(); else if (e.key === "Escape") cancel(); }} onBlur={() => commit()} autoFocus className="h-7 text-sm" /> )} />
{skipDays.length > 0 ? (
{skipDays.map((d) => ( {DAY_SHORT[d] ?? d} ))}
) : null}
patch({ id: habit.id, data: { moodTracking } })} label="Mood tracking" />

{habit.streakCount}

Current Streak

{habit.bestStreak}

Best Streak

{distinctCompletionDays}

Completion This Period

Created {format(parseISO(habit.createdAt), "MMM d, yyyy HH:mm")} Updated {format(parseISO(habit.updatedAt), "MMM d, yyyy HH:mm")}
); } function Stats({ habit, days, setDays, }: { habit: Habit; days: number; setDays: (d: number) => void; }) { const completions = habit.recentCompletions || []; const distinctCompletionDays = new Set( completions.map((c) => c.date.slice(0, 10)) ).size; const completionRate = Math.round((distinctCompletionDays / days) * 100); return (

{habit.streakCount}

Current Streak

{habit.bestStreak}

Best Streak

{completionRate}%

Completion Rate

Completion Heatmap

Last {days} days
{completions.length === 0 ? (

No completions in this period.

) : ( ({ date: c.date.slice(0, 10), count: c.value, }))} days={days} /> )}
); } function LogTab({ habit, days }: { habit: Habit; days: number }) { const completions = habit.recentCompletions || []; if (completions.length === 0) { return (

No completions in the last {days} days.

); } const sorted = [...completions].sort((a, b) => b.date.localeCompare(a.date)); return (
{sorted.map((c) => (
{format(parseISO(c.date), "EEE, MMM d, yyyy")} {c.value > 1 && {c.value}x} {c.mood != null && ( Mood {c.mood}/5 )} {c.notes ? ( {c.notes} ) : null}
))}
); } export const Route = createRoute({ getParentRoute: () => appRoute, path: "habits/$id", component: HabitDetail, });