"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { BarChart3, CalendarDays, CalendarRange, Dumbbell, Flame, NotebookPen, Plus, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Confetti } from "./celebrate";
import { DEFAULT_HABITS, MILESTONES, SEED_TODAY, buildSeedJournal, buildSeedLog, buildSeedWorkouts, currentStreak, dayScore, isPerfect } from "./data";
import { localTodayKey } from "./dates";
import { HabitEditor } from "./habit-editor";
import { JournalView } from "./journal-view";
import { StatsView } from "./stats-view";
import { TodayView } from "./today-view";
import { Ring, focusRing } from "./ui";
import { WeekView } from "./week-view";
import { WorkoutsView } from "./workouts-view";
import type { Habit, HabitLog, JournalEntry, View, Workout } from "./types";
export type { Habit, HabitLog, JournalEntry, Workout, WorkoutExercise, WorkoutSet, Mood, HabitColor, HabitIcon } from "./types";
export interface HabitTrackerAppProps {
appName?: string;
userName?: string;
habits?: Habit[];
/** Check-in history. When omitted, ~5 months of believable history is generated. */
initialLog?: HabitLog;
journal?: JournalEntry[];
workouts?: Workout[];
/** Weight unit shown in the workout log. */
unit?: "kg" | "lb";
defaultView?: View;
onCheckIn?: (habit: Habit, date: string, count: number) => void;
onHabitsChange?: (habits: Habit[]) => void;
onJournalSave?: (entry: JournalEntry) => void;
onWorkoutSave?: (workout: Workout) => void;
onMilestone?: (habit: Habit, streak: number) => void;
className?: string;
}
const NAV: { id: View; label: string; Icon: LucideIcon }[] = [
{ id: "today", label: "Today", Icon: CalendarDays },
{ id: "week", label: "Week", Icon: CalendarRange },
{ id: "stats", label: "Stats", Icon: BarChart3 },
{ id: "journal", label: "Journal", Icon: NotebookPen },
{ id: "workouts", label: "Workouts", Icon: Dumbbell },
];
export function HabitTrackerApp({
appName = "Ritual",
userName = "Sam",
habits: habitsProp = DEFAULT_HABITS,
initialLog,
journal: journalProp,
workouts: workoutsProp,
unit = "kg",
defaultView = "today",
onCheckIn,
onHabitsChange,
onJournalSave,
onWorkoutSave,
onMilestone,
className,
}: HabitTrackerAppProps) {
const reduce = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const mainRef = React.useRef<HTMLElement>(null);
const [today, setToday] = React.useState(SEED_TODAY);
const [date, setDate] = React.useState(SEED_TODAY);
const [habits, setHabits] = React.useState<Habit[]>(habitsProp);
const [log, setLog] = React.useState<HabitLog>(() => initialLog ?? buildSeedLog(habitsProp, SEED_TODAY));
const [journal, setJournal] = React.useState<JournalEntry[]>(() => journalProp ?? buildSeedJournal(SEED_TODAY));
const [workouts, setWorkouts] = React.useState<Workout[]>(() => workoutsProp ?? buildSeedWorkouts(SEED_TODAY));
const [view, setView] = React.useState<View>(defaultView);
const [editor, setEditor] = React.useState<{ habit: Habit | null } | null>(null);
const [burst, setBurst] = React.useState<{ habitId: string; streak: number; n: number } | null>(null);
const [confetti, setConfetti] = React.useState(0);
const [toast, setToast] = React.useState<{ id: number; text: string } | null>(null);
const seq = React.useRef(0);
const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);
// Re-anchor seeded history onto the real calendar after mount (hydration safe).
React.useEffect(() => {
const t = setTimeout(() => {
const real = localTodayKey();
if (real === SEED_TODAY) return;
setToday(real);
setDate(real);
if (!initialLog) setLog(buildSeedLog(habitsProp, real));
if (!journalProp) setJournal(buildSeedJournal(real));
if (!workoutsProp) setWorkouts(buildSeedWorkouts(real));
}, 0);
const list = timers.current;
return () => {
clearTimeout(t);
list.forEach(clearTimeout);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const later = (fn: () => void, ms: number) => timers.current.push(setTimeout(fn, ms));
const say = (text: string) => {
const id = ++seq.current;
setToast({ id, text });
later(() => setToast((t) => (t?.id === id ? null : t)), 2800);
};
const setCount = (h: Habit, d: string, count: number) => {
const c = Math.max(0, Math.min(h.target, count));
const next: HabitLog = { ...log, [h.id]: { ...(log[h.id] ?? {}), [d]: c } };
if (!c) delete next[h.id][d];
const wasDone = (log[h.id]?.[d] ?? 0) >= h.target;
const nowDone = c >= h.target;
const wasPerfect = isPerfect(habits, log, d);
setLog(next);
onCheckIn?.(h, d, c);
if (!wasDone && nowDone && d === today) {
const streak = currentStreak(h, next, today);
if (MILESTONES.includes(streak)) {
const n = ++seq.current;
setBurst({ habitId: h.id, streak, n });
later(() => setBurst((b) => (b?.n === n ? null : b)), 1700);
say(`${streak}-day streak on “${h.name}”. Keep it burning!`);
onMilestone?.(h, streak);
}
}
if (!wasPerfect && isPerfect(habits, next, d) && d === today) {
const n = ++seq.current;
setConfetti(n);
later(() => setConfetti((c2) => (c2 === n ? 0 : c2)), 4200);
say("Perfect day! Every habit checked.");
}
};
const saveHabit = (h: Habit) => {
const exists = habits.some((x) => x.id === h.id);
const next = exists ? habits.map((x) => (x.id === h.id ? h : x)) : [...habits, h];
setHabits(next);
onHabitsChange?.(next);
setEditor(null);
say(exists ? "Habit updated" : `“${h.name}” added — first check-in awaits`);
};
const deleteHabit = (h: Habit) => {
const next = habits.filter((x) => x.id !== h.id);
setHabits(next);
onHabitsChange?.(next);
setEditor(null);
say(`Deleted “${h.name}”`);
};
const go = (v: View) => {
setView(v);
mainRef.current?.scrollTo({ top: 0 });
};
// Keyboard: 1–5 switch views, N new habit, T jump to today.
const keyRef = React.useRef({ go, open: () => setEditor({ habit: null }), jump: () => setDate(today), editing: !!editor });
React.useEffect(() => {
keyRef.current = { go, open: () => setEditor({ habit: null }), jump: () => setDate(today), editing: !!editor };
});
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
if (e.metaKey || e.ctrlKey || e.altKey || keyRef.current.editing) return;
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
const a = document.activeElement;
if (a && a !== document.body && !rootRef.current?.contains(a)) return;
const i = Number(e.key);
if (i >= 1 && i <= NAV.length) {
e.preventDefault();
keyRef.current.go(NAV[i - 1].id);
} else if (e.key.toLowerCase() === "n") {
e.preventDefault();
keyRef.current.open();
} else if (e.key.toLowerCase() === "t") {
keyRef.current.go("today");
keyRef.current.jump();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const todayScore = dayScore(habits, log, today) ?? 0;
const topStreak = Math.max(0, ...habits.map((h) => currentStreak(h, log, today)));
return (
<div ref={rootRef} className={cn("relative flex h-[760px] w-full overflow-hidden bg-background text-foreground", className)}>
{/* Sidebar (desktop) */}
<aside className="hidden w-60 shrink-0 flex-col border-r bg-muted/30 p-3 lg:flex">
<div className="flex items-center gap-2 px-2 py-2">
<span className="grid size-8 place-items-center rounded-xl bg-gradient-to-br from-orange-500 to-rose-500 text-white shadow-md shadow-orange-500/30" aria-hidden>
<Flame className="size-4 fill-amber-300" />
</span>
<span className="text-base font-semibold tracking-tight">{appName}</span>
</div>
<nav aria-label="Sections" className="mt-4 space-y-0.5">
{NAV.map((n, i) => (
<button
key={n.id}
type="button"
onClick={() => go(n.id)}
aria-current={view === n.id ? "page" : undefined}
className={cn("relative flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors", view === n.id ? "text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground", focusRing)}
>
{view === n.id && <motion.span layoutId="ht-nav" className="absolute inset-0 rounded-lg bg-background shadow-sm ring-1 ring-border" transition={{ type: "spring", stiffness: 500, damping: 40 }} />}
<n.Icon className="relative size-4" aria-hidden />
<span className="relative flex-1 text-left">{n.label}</span>
<kbd className="relative rounded border bg-muted px-1 font-mono text-[10px] text-muted-foreground">{i + 1}</kbd>
</button>
))}
</nav>
<button type="button" onClick={() => setEditor({ habit: null })} className={cn("mt-4 inline-flex h-9 items-center justify-center gap-1.5 rounded-lg bg-primary text-sm font-semibold text-primary-foreground shadow-sm hover:opacity-90", focusRing)}>
<Plus className="size-4" aria-hidden /> New habit
<kbd className="ml-1 rounded bg-primary-foreground/15 px-1 font-mono text-[10px]">N</kbd>
</button>
<div className="mt-auto rounded-2xl border bg-card p-3">
<div className="flex items-center gap-3">
<Ring value={todayScore} color="#f97316" size={44} stroke={4}>
<span className="text-[11px] font-bold tabular-nums">{Math.round(todayScore * 100)}</span>
</Ring>
<div className="min-w-0">
<p className="text-xs text-muted-foreground">Today</p>
<p className="flex items-center gap-1 text-sm font-semibold">
<Flame className="size-3.5 fill-orange-400/60 text-orange-500" aria-hidden /> {topStreak}-day best run
</p>
</div>
</div>
<p className="mt-3 text-[11px] leading-relaxed text-muted-foreground">“We are what we repeatedly do.” Small steps, every day.</p>
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
{/* Mobile header */}
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4 lg:hidden">
<span className="grid size-8 place-items-center rounded-xl bg-gradient-to-br from-orange-500 to-rose-500 text-white" aria-hidden>
<Flame className="size-4 fill-amber-300" />
</span>
<span className="flex-1 font-semibold tracking-tight">{appName}</span>
<button type="button" onClick={() => setEditor({ habit: null })} aria-label="New habit" className={cn("grid size-9 place-items-center rounded-full bg-primary text-primary-foreground shadow-sm", focusRing)}>
<Plus className="size-4" />
</button>
</header>
<main ref={mainRef} className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<AnimatePresence mode="wait" initial={false}>
<motion.div key={view} initial={reduce ? false : { opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }} transition={{ duration: 0.18 }} className="mx-auto max-w-4xl p-4 pb-8 sm:p-6">
{view === "today" && <TodayView habits={habits} log={log} today={today} date={date} onDate={setDate} onSet={setCount} onEdit={(h) => setEditor({ habit: h })} onNew={() => setEditor({ habit: null })} burst={burst} userName={userName} />}
{view === "week" && <WeekView habits={habits} log={log} today={today} onSet={setCount} />}
{view === "stats" && <StatsView habits={habits} log={log} today={today} />}
{view === "journal" && (
<JournalView
key={today}
entries={journal}
today={today}
onSave={(e) => {
setJournal((j) => [e, ...j.filter((x) => x.date !== e.date)]);
onJournalSave?.(e);
}}
/>
)}
{view === "workouts" && (
<WorkoutsView
workouts={workouts}
today={today}
unit={unit}
onSave={(w) => {
setWorkouts((ws) => [...ws, w]);
onWorkoutSave?.(w);
say("Workout logged. Nice work!");
}}
/>
)}
</motion.div>
</AnimatePresence>
</main>
{/* Mobile tab bar */}
<nav aria-label="Sections" className="grid h-16 shrink-0 grid-cols-5 border-t bg-background/90 backdrop-blur lg:hidden">
{NAV.map((n) => (
<button key={n.id} type="button" onClick={() => go(n.id)} aria-current={view === n.id ? "page" : undefined} className={cn("relative flex flex-col items-center justify-center gap-0.5 text-[10px] font-medium", view === n.id ? "text-foreground" : "text-muted-foreground", "focus-visible:bg-muted focus-visible:outline-none")}>
{view === n.id && <motion.span layoutId="ht-tab" className="absolute top-0 h-0.5 w-8 rounded-full bg-orange-500" />}
<n.Icon className={cn("size-5", view === n.id && "text-orange-500")} aria-hidden />
{n.label}
</button>
))}
</nav>
</div>
<AnimatePresence>{editor && <HabitEditor key={editor.habit?.id ?? "new"} habit={editor.habit} onClose={() => setEditor(null)} onSave={saveHabit} onDelete={deleteHabit} />}</AnimatePresence>
{confetti > 0 && <Confetti key={confetti} />}
<div className="pointer-events-none absolute inset-x-0 bottom-20 z-50 flex justify-center px-4 lg:bottom-6" role="status" aria-live="polite">
<AnimatePresence>
{toast && (
<motion.div key={toast.id} initial={{ opacity: 0, y: 14, scale: 0.96 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 8 }} className="max-w-full truncate rounded-full bg-foreground px-4 py-2 text-sm font-medium text-background shadow-xl">
{toast.text}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
export default HabitTrackerApp;