"use client"; import { BikeIcon, BookOpenIcon, CarrotIcon, ChevronLeftIcon, DropletsIcon, DumbbellIcon, FlameIcon, Flower2Icon, FootprintsIcon, LanguagesIcon, type LucideIcon, MinusIcon, MoonIcon, MusicIcon, NotebookPenIcon, PersonStandingIcon, PlusIcon, SparklesIcon, SunIcon, XIcon, } from "lucide-react"; import { AnimatePresence, animate, motion, PresenceContext, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; import { type FormEvent, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; export type HabitIcon = | "water" | "journal" | "stretch" | "walk" | "read" | "sleep" | "meditate" | "veg" | "lift" | "music" | "sun" | "language" | "bike"; export type HabitTone = "sky" | "amber" | "violet" | "teal" | "orange" | "rose"; export type HabitTime = "morning" | "anytime" | "evening"; export interface Habit { id: string; name: string; icon: HabitIcon; tone: HabitTone; time: HabitTime; goal?: { count: number; unit: string }; carry: number; week: number[]; since?: number; } export interface HabitsProps { className?: string; habits?: Habit[]; weekStart?: string; todayIndex?: number; productName?: string; } const spring = { type: "spring", stiffness: 520, damping: 44, mass: 1 } as const; const easeOut = [0.23, 1, 0.32, 1] as const; const press = "transition-[color,background-color,box-shadow,scale,opacity] duration-150 ease-out active:scale-[0.97]"; const card = "rounded-2xl border bg-card shadow-xs/5"; const icons: Record = { water: { icon: DropletsIcon, label: "Water" }, journal: { icon: NotebookPenIcon, label: "Journal" }, stretch: { icon: PersonStandingIcon, label: "Stretch" }, walk: { icon: FootprintsIcon, label: "Walk" }, read: { icon: BookOpenIcon, label: "Read" }, sleep: { icon: MoonIcon, label: "Sleep" }, meditate: { icon: Flower2Icon, label: "Meditate" }, veg: { icon: CarrotIcon, label: "Eat well" }, lift: { icon: DumbbellIcon, label: "Strength" }, music: { icon: MusicIcon, label: "Practice" }, sun: { icon: SunIcon, label: "Outside" }, language: { icon: LanguagesIcon, label: "Language" }, bike: { icon: BikeIcon, label: "Ride" }, }; const tones: Record = { sky: { tile: "bg-sky-500/12 text-sky-700 dark:bg-sky-400/14 dark:text-sky-300", fill: "bg-sky-500 dark:bg-sky-400", soft: "bg-sky-500/30 dark:bg-sky-400/40", on: "text-white dark:text-sky-950", label: "Sky", }, amber: { tile: "bg-amber-500/14 text-amber-700 dark:bg-amber-400/14 dark:text-amber-300", fill: "bg-amber-500 dark:bg-amber-400", soft: "bg-amber-500/30 dark:bg-amber-400/40", on: "text-white dark:text-amber-950", label: "Amber", }, violet: { tile: "bg-violet-500/12 text-violet-700 dark:bg-violet-400/14 dark:text-violet-300", fill: "bg-violet-500 dark:bg-violet-400", soft: "bg-violet-500/30 dark:bg-violet-400/40", on: "text-white dark:text-violet-950", label: "Violet", }, teal: { tile: "bg-teal-500/12 text-teal-700 dark:bg-teal-400/14 dark:text-teal-300", fill: "bg-teal-500 dark:bg-teal-400", soft: "bg-teal-500/30 dark:bg-teal-400/40", on: "text-white dark:text-teal-950", label: "Teal", }, orange: { tile: "bg-orange-500/12 text-orange-700 dark:bg-orange-400/14 dark:text-orange-300", fill: "bg-orange-500 dark:bg-orange-400", soft: "bg-orange-500/30 dark:bg-orange-400/40", on: "text-white dark:text-orange-950", label: "Orange", }, rose: { tile: "bg-rose-500/12 text-rose-700 dark:bg-rose-400/14 dark:text-rose-300", fill: "bg-rose-500 dark:bg-rose-400", soft: "bg-rose-500/30 dark:bg-rose-400/40", on: "text-white dark:text-rose-950", label: "Rose", }, }; const times: { value: HabitTime; label: string }[] = [ { value: "morning", label: "Morning" }, { value: "anytime", label: "Anytime" }, { value: "evening", label: "Evening" }, ]; const defaultHabits: Habit[] = [ { id: "h1", name: "Morning pages", icon: "journal", tone: "amber", time: "morning", carry: 37, week: [1, 1, 1, 1, 1, 1, 0] }, { id: "h2", name: "Stretch for 10 minutes", icon: "stretch", tone: "violet", time: "morning", carry: 0, week: [1, 0, 1, 1, 1, 1, 0] }, { id: "h3", name: "Drink water", icon: "water", tone: "sky", time: "anytime", goal: { count: 8, unit: "glasses" }, carry: 9, week: [8, 8, 8, 8, 8, 5, 0] }, { id: "h4", name: "Walk 8,000 steps", icon: "walk", tone: "teal", time: "anytime", carry: 4, week: [1, 1, 1, 1, 1, 0, 0] }, { id: "h5", name: "Read 20 pages", icon: "read", tone: "orange", time: "evening", carry: 2, week: [1, 1, 0, 1, 1, 0, 0] }, { id: "h6", name: "Lights out by 11", icon: "sleep", tone: "rose", time: "evening", carry: 0, week: [1, 1, 1, 0, 1, 0, 0] }, ]; const suggestions: { name: string; icon: HabitIcon; tone: HabitTone; time: HabitTime; goal?: { count: number; unit: string } }[] = [ { name: "Meditate", icon: "meditate", tone: "violet", time: "morning" }, { name: "Eat a vegetable", icon: "veg", tone: "orange", time: "anytime", goal: { count: 3, unit: "servings" } }, { name: "Practice guitar", icon: "music", tone: "rose", time: "evening" }, { name: "Get outside", icon: "sun", tone: "amber", time: "anytime" }, ]; const isDone = (h: Habit, v: number) => (h.goal ? v >= h.goal.count : v > 0); function streakOf(h: Habit, today: number) { const since = h.since ?? 0; let i = isDone(h, h.week[today] ?? 0) ? today : today - 1; let s = 0; while (i >= since && isDone(h, h.week[i] ?? 0)) { s++; i--; } return i < 0 ? s + h.carry : s; } function Tween({ value, format = (n: number) => String(Math.round(n)) }: { value: number; format?: (n: number) => string }) { const reduce = useReducedMotion(); const mv = useMotionValue(value); const text = useTransform(mv, format); useEffect(() => { if (reduce) { mv.set(value); return; } const controls = animate(mv, value, { type: "spring", stiffness: 200, damping: 28 }); return () => controls.stop(); }, [value, reduce, mv]); return {text}; } function Ring({ value, size = 40, stroke = 4, className }: { value: number; size?: number; stroke?: number; className?: string }) { const reduce = useReducedMotion(); const r = (size - stroke) / 2; return ( 0 ? 1 : 0 }} className="stroke-primary" cx={size / 2} cy={size / 2} fill="none" initial={false} r={r} strokeLinecap="round" strokeWidth={stroke} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 180, damping: 26 }} /> ); } export function Habits({ className, habits: initialHabits = defaultHabits, weekStart = "2026-10-19", todayIndex = 5, productName = "Kindling", }: HabitsProps) { const [habits, setHabits] = useState(initialHabits); const [day, setDay] = useState(todayIndex); const [sheet, setSheet] = useState(false); const [freshId, setFreshId] = useState(null); const [wide, setWide] = useState(false); const reduce = useReducedMotion(); const uid = useId().replace(/:/g, ""); const rootRef = useRef(null); const addRef = useRef(null); const counter = useRef(0); useLayoutEffect(() => { const el = rootRef.current; if (!el) return; const ro = new ResizeObserver(([e]) => setWide(e.contentRect.width >= 768)); ro.observe(el); return () => ro.disconnect(); }, []); useEffect(() => { if (!freshId) return; const timer = setTimeout(() => setFreshId(null), 1600); return () => clearTimeout(timer); }, [freshId]); const dates = Array.from({ length: 7 }, (_, i) => { const d = new Date(`${weekStart}T12:00:00`); d.setDate(d.getDate() + i); return d; }); const dayName = (i: number, style: "long" | "short" | "narrow" = "long") => dates[i].toLocaleDateString("en-US", { weekday: style }); const setValue = (id: string, i: number, v: number) => setHabits((all) => all.map((h) => (h.id === id ? { ...h, week: h.week.map((x, j) => (j === i ? v : x)) } : h))); const activeOn = (i: number) => habits.filter((h) => (h.since ?? 0) <= i); const doneOn = (i: number) => activeOn(i).filter((h) => isDone(h, h.week[i] ?? 0)).length; const dayRatio = (i: number) => { const n = activeOn(i).length; return n ? doneOn(i) / n : 0; }; const doneToday = doneOn(day); const dayTotal = activeOn(day).length; const allDone = dayTotal > 0 && doneToday === dayTotal; const days = Array.from({ length: todayIndex + 1 }, (_, i) => i); const possible = days.reduce((s, i) => s + activeOn(i).length, 0); const ticked = days.reduce((s, i) => s + doneOn(i), 0); const weekPct = possible ? Math.round((ticked / possible) * 100) : 0; const perfect = days.filter((i) => activeOn(i).length > 0 && doneOn(i) === activeOn(i).length).length; const best = habits.reduce<{ h: Habit | null; s: number }>( (acc, h) => { const s = streakOf(h, todayIndex); return s > acc.s ? { h, s } : acc; }, { h: null, s: 0 }, ); const byTime = times .map((t) => { const list = habits.filter((h) => h.time === t.value); let slots = 0; let hit = 0; for (const h of list) for (const i of days) if ((h.since ?? 0) <= i) { slots++; if (isDone(h, h.week[i] ?? 0)) hit++; } return { ...t, slots, pct: slots ? Math.round((hit / slots) * 100) : 0 }; }) .filter((t) => t.slots > 0); const bestPct = Math.max(0, ...byTime.map((t) => t.pct)); const bestTime = byTime.length > 1 ? byTime.find((t) => t.pct === bestPct) : undefined; const addHabit = (h: Omit) => { counter.current += 1; const id = `new-${counter.current}`; setHabits((all) => [...all, { ...h, id, carry: 0, since: todayIndex, week: [0, 0, 0, 0, 0, 0, 0] }]); setFreshId(id); setDay(todayIndex); }; const isToday = day === todayIndex; return (
{productName}
{isToday ? ( Today ) : ( setDay(todayIndex)} transition={{ duration: 0.15 }} type="button" > Back to today )}

{dayName(day)}, {dates[day].toLocaleDateString("en-US", { month: "short", day: "numeric" })}

/{dayTotal} {doneToday} of {dayTotal} habits done
{dates.map((d, i) => { const future = i > todayIndex; const active = i === day; return ( ); })}
{allDone && (

A perfect {dayName(day)}.{" "} All {dayTotal} habits done.

)}
{habits.length === 0 ? (

No habits yet

Start with one small thing you want to do every day.

) : ( times.map((t) => { const list = habits.filter((h) => h.time === t.value && (h.since ?? 0) <= day); if (!list.length) return null; const doneHere = list.filter((h) => isDone(h, h.week[day] ?? 0)).length; return (

{t.label}

{doneHere} of {list.length}
    {list.map((h) => ( setValue(h.id, day, v)} streak={streakOf(h, todayIndex)} /> ))}
); }) )}
{ setSheet(false); addRef.current?.focus(); }} open={sheet} wide={wide} />
); } function Stat({ label, value, hint }: { label: string; value: React.ReactNode; hint?: string }) { return (
{label} {value} {hint ? {hint} : null}
); } function GridCell({ habit, value, future, selected, day, before, onToggle, }: { habit: Habit; value: number; future: boolean; before?: boolean; selected: boolean; day: string; onToggle: () => void; }) { const reduce = useReducedMotion(); if (before) return ; const done = isDone(habit, value); const partial = habit.goal && !done && value > 0 ? value / habit.goal.count : 0; const t = tones[habit.tone]; const state = future ? "upcoming" : done ? "done" : partial ? `${value} of ${habit.goal?.count}` : "missed"; return ( ); } function HabitRow({ habit, day, streak, fresh, onChange, }: { habit: Habit; day: number; streak: number; fresh: boolean; onChange: (v: number) => void; }) { const reduce = useReducedMotion(); const value = habit.week[day] ?? 0; const done = isDone(habit, value); const t = tones[habit.tone]; const Icon = icons[habit.icon].icon; const [burst, setBurst] = useState(0); const tick = () => { if (habit.goal) { if (value >= habit.goal.count) return; const next = value + 1; onChange(next); if (next === habit.goal.count && !reduce) setBurst((b) => b + 1); } else { onChange(done ? 0 : 1); if (!done && !reduce) setBurst((b) => b + 1); } }; const sub = habit.goal ? done ? `${habit.goal.count} ${habit.goal.unit} · done` : `${value} of ${habit.goal.count} ${habit.goal.unit}` : done ? "Done" : "Once a day"; return (
{habit.name} {sub} {streak > 0 && ( day streak )}
{habit.goal && value > 0 && !done && ( )} onChange(0)} tone={habit.tone} value={value} />
); } function TickButton({ done, value, goal, tone, label, burst, onTick, onUndo, }: { done: boolean; value: number; goal?: { count: number; unit: string }; tone: HabitTone; label: string; burst: number; onTick: () => void; onUndo: () => void; }) { const reduce = useReducedMotion(); const t = tones[tone]; const level = goal ? Math.min(value / goal.count, 1) : done ? 1 : 0; const quick = reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 34 } as const; // The row sits in an AnimatePresence with initial={false}; reset that here so the burst and wave still play. return ( {burst > 0 && ( {Array.from({ length: 8 }, (_, i) => { const a = (i / 8) * Math.PI * 2; return ( ); })} )} ); } const waveFill: Record = { sky: "fill-sky-500/30 dark:fill-sky-400/40", amber: "fill-amber-500/30 dark:fill-amber-400/40", violet: "fill-violet-500/30 dark:fill-violet-400/40", teal: "fill-teal-500/30 dark:fill-teal-400/40", orange: "fill-orange-500/30 dark:fill-orange-400/40", rose: "fill-rose-500/30 dark:fill-rose-400/40", }; function AddHabitSheet({ open, wide, onClose, onAdd, }: { open: boolean; wide: boolean; onClose: () => void; onAdd: (h: Omit) => void; }) { const reduce = useReducedMotion(); const uid = useId().replace(/:/g, ""); const nameRef = useRef(null); const [name, setName] = useState(""); const [icon, setIcon] = useState("meditate"); const [tone, setTone] = useState("teal"); const [time, setTime] = useState("anytime"); const [counted, setCounted] = useState(false); const [count, setCount] = useState(3); const [unit, setUnit] = useState("times"); const [tried, setTried] = useState(false); useEffect(() => { if (!open) return; setName(""); setIcon("meditate"); setTone("teal"); setTime("anytime"); setCounted(false); setCount(3); setUnit("times"); setTried(false); const f = setTimeout(() => nameRef.current?.focus(), 60); return () => clearTimeout(f); }, [open]); const invalid = !name.trim(); const submit = (e: FormEvent) => { e.preventDefault(); setTried(true); if (invalid) { nameRef.current?.focus(); return; } onAdd({ name: name.trim(), icon, tone, time, goal: counted ? { count, unit: unit.trim() || "times" } : undefined, }); onClose(); }; const Icon = icons[icon].icon; const t = tones[tone]; const motionIn = wide ? { x: 0 } : { y: 0 }; const motionOut = wide ? { x: "100%" } : { y: "100%" }; return ( {open && ( <> { if (e.key === "Escape") { e.stopPropagation(); onClose(); } }} onSubmit={submit} role="dialog" transition={reduce ? { duration: 0.15 } : { type: "spring", stiffness: 440, damping: 42 }} > {!wide && }

New habit

{name.trim() || "Your new habit"} {times.find((x) => x.value === time)?.label} · {counted ? `${count} ${unit.trim() || "times"} a day` : "Once a day"}
setName(e.target.value)} placeholder="e.g. Meditate for 5 minutes" ref={nameRef} value={name} /> {tried && invalid && ( Give it a name you’ll recognise at a glance. )}
{suggestions.map((s) => ( ))}
Icon
{(Object.keys(icons) as HabitIcon[]).map((k) => { const I = icons[k].icon; const on = k === icon; return ( ); })}
Colour
{(Object.keys(tones) as HabitTone[]).map((k) => ( ))}
setTime(v as HabitTime)} options={times} uid={`${uid}-time`} value={time} />
setCounted(v === "count")} options={[ { value: "once", label: "Once a day" }, { value: "count", label: "Count it" }, ]} uid={`${uid}-goal`} value={counted ? "count" : "once"} /> {counted && (
{count}
setUnit(e.target.value)} placeholder="glasses, pages…" value={unit} /> a day
)}
)}
); } function Choice({ label, value, options, onChange, uid, }: { label: string; value: string; options: { value: string; label: string }[]; onChange: (v: string) => void; uid: string; }) { const reduce = useReducedMotion(); return (
{label}
{options.map((o) => { const on = o.value === value; return ( ); })}
); }