Fazekit

Code

"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;

More in Productivity

View all →