import { ProgressOverview } from "../components/ProgressOverview"; import { deferred } from "../components/design-system/deferred"; import { useOnline } from "../pwa/client"; import { CalendarCacheProvider } from "../components/CalendarCache"; import { Archive, Plus, Settings2 } from "lucide-react"; const ArchivedHabits = deferred(() => import("../components/ArchivedHabits").then(module => module.ArchivedHabits)); const AccountSettings = deferred(() => import("../components/AccountSettings").then(module => module.AccountSettings)); import { memo, useCallback, useEffect, useRef, useState } from "react"; import { useAuth } from "../components/AuthProvider"; import { DiscordSignInButton } from "../components/DiscordSignInButton"; import { PageLayout } from "../components/design-system/PageLayout"; import { Button, Checkbox, Counter, SectionHeading, } from "../components/design-system/primitives"; import { HabitChart } from "../components/design-system/HabitChart"; import { HABIT_COLORS } from "../components/design-system/calendar-model"; const HabitForm = deferred(() => import("../components/HabitForm").then(module => module.HabitForm)); import { habitRequest, reconcileToday, scheduleLabel, type TodayHabit, type TodayResponse } from "../lib/dashboard"; import { HabitHistory } from "../components/HabitHistory"; import { WelcomePanel } from "../components/design-system/WelcomePanel"; import type { PublicUser } from "../shared/user"; import { ItemActions } from "../components/design-system/ItemActions"; import { type ItemMode } from "../components/InlineItemForm"; const InlineHabitEditor = deferred(() => import("../components/InlineHabitEditor").then(module => module.InlineHabitEditor)); const InlineTaskEditor = deferred(() => import("../components/InlineTaskEditor").then(module => module.InlineTaskEditor)); import type { Schedule } from "../habits/contracts"; const ShareProgress = deferred(() => import("../components/ShareProgress").then(module => module.ShareProgress)); export function Home() { const online = useOnline(); const { user, loading, busy, error, accountError, connectionError, signIn, signOut, retry } = useAuth(); const disconnected = !online || connectionError; return ( Loading account… ) : user ? ( ) : ( !accountError && ) } > {error && !disconnected && (
{error}
)} {loading ? (

Loading account…

) : accountError ? (

{disconnected ? "Connection unavailable" : "Account unavailable"}

{disconnected ? "Check your connection. We’ll retry automatically." : "Your account couldn’t be loaded."}

) : user ? ( ) : ( )}
); } function Welcome({ onSignIn }: { onSignIn: () => void }) { const online = useOnline(); const [water, setWater] = useState(3); const [read, setRead] = useState(false); return ( <>

Track your habits

Sign in to save your habits and progress.

Demo · changes are not saved
setRead(event.target.checked)} />
); } function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => void }) { const [today, setToday] = useState(null); const todaySnapshot = useRef(today); todaySnapshot.current = today; const [error, setError] = useState(""); const [revision, setRevision] = useState(0); const [habitRevisions, setHabitRevisions] = useState>({}); const [pendingHabits, setPendingHabits] = useState>(new Set()); const progressSaves = useRef(new Set()); const [attempt, setAttempt] = useState(0); const [loading, setLoading] = useState(true); const [habitView, setHabitView] = useState<"today" | "all">("today"); const [adding, setAdding] = useState(false); const [addingOverview, setAddingOverview] = useState(false); const [sharing, setSharing] = useState(false); const [settings, setSettings] = useState(false); const [archive, setArchive] = useState(false); const [busy, setBusy] = useState(false); const [needsRefresh, setNeedsRefresh] = useState(false); const focusAddAfterRefresh = useRef(false); const saving = useRef(false); const mounted = useRef(true); const expired = useRef(onExpired); expired.current = onExpired; useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const reportError = useCallback((error: unknown) => { if (error instanceof Error && error.message.includes("session has expired")) expired.current(); else setError( error instanceof Error ? error.message : "Could not load your habits. Please try again." ); }, []); useEffect(() => { const controller = new AbortController(); setLoading(true); setError(""); habitRequest("/today", { signal: controller.signal }) .then((data) => { if (!controller.signal.aborted) { const next = reconcileToday(todaySnapshot.current, data); setToday(next); setNeedsRefresh(false); if (next !== todaySnapshot.current) setRevision((value) => value + 1); } }) .catch((error) => { if (!controller.signal.aborted) reportError(error); }) .finally(() => { if (!controller.signal.aborted) { setLoading(false); if (focusAddAfterRefresh.current) { focusAddAfterRefresh.current = false; window.requestAnimationFrame(() => document.getElementById("add-habit")?.focus({ preventScroll: true })); } } }); return () => controller.abort(); }, [attempt, reportError]); // Refresh after returning to the page and across the account's local midnight. useEffect(() => { const refresh = () => { if (navigator.onLine !== false && !saving.current && progressSaves.current.size === 0 && document.visibilityState === "visible") setAttempt((value) => value + 1); }; const timer = window.setInterval(refresh, 60_000); window.addEventListener("focus", refresh); window.addEventListener("online", refresh); return () => { window.clearInterval(timer); window.removeEventListener("focus", refresh); window.removeEventListener("online", refresh); }; }, []); async function update( habit: TodayHabit, body: { count: number } | { done: boolean }, taskId?: string ) { if (saving.current || progressSaves.current.has(habit.habitId) || loading || needsRefresh || !today) return; progressSaves.current.add(habit.habitId); setPendingHabits(new Set(progressSaves.current)); setError(""); try { const path = `/habits/${habit.habitId}/days/${today.date}/${taskId ? `tasks/${taskId}` : "progress"}`; const updated = await habitRequest(path, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!mounted.current) return; setToday((current) => { if (!current) return current; const habits = current.habits.map((item) => item.habitId === updated.habitId ? updated : item ); return { ...current, habits, due: habits.filter((item) => item.due).length, completed: habits.filter((item) => item.complete).length, }; }); setRevision((value) => value + 1); setHabitRevisions(current => ({ ...current, [habit.habitId]: (current[habit.habitId] ?? 0) + 1 })); } catch (error) { if (mounted.current) reportError(error); } finally { progressSaves.current.delete(habit.habitId); if (mounted.current) setPendingHabits(new Set(progressSaves.current)); } } async function manage( habit: TodayHabit, patch: Record | null, taskId?: string, createTask = false ) { if (saving.current || progressSaves.current.size > 0 || loading || needsRefresh) throw new Error("Please wait for the dashboard to refresh."); saving.current = true; setBusy(true); setError(""); try { await habitRequest( `/habits/${habit.habitId}${createTask ? "/tasks" : taskId ? `/tasks/${taskId}` : ""}`, { method: createTask ? "POST" : patch ? "PATCH" : "DELETE", ...(patch ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch) } : {}), } ); // A refresh failure must never turn a successful delete into a retryable delete. try { const refreshed = await habitRequest("/today"); if (mounted.current) { setToday(current => reconcileToday(current, refreshed)); // Keep a habit reachable when an edit makes it unscheduled (including an empty routine). if (refreshed.habits.some(item => item.habitId === habit.habitId && !item.due)) setHabitView("all"); setNeedsRefresh(false); setRevision((value) => value + 1); setHabitRevisions(current => ({ ...current, [habit.habitId]: (current[habit.habitId] ?? 0) + 1 })); } } catch (refreshError) { if (refreshError instanceof Error && refreshError.message.includes("session has expired")) expired.current(); if (mounted.current) { setNeedsRefresh(true); setError( "Your change was saved, but the dashboard could not refresh. Try again to load the latest data." ); } } if (mounted.current) { if (!patch) window.requestAnimationFrame(() => { ( document.getElementById("habits-title") ?? document.getElementById("add-habit") )?.focus(); }); } } catch (error) { if (error instanceof Error && error.message.includes("session has expired")) expired.current(); throw error; } finally { saving.current = false; if (mounted.current) setBusy(false); } } const actions = useRef({ update, manage }); actions.current = { update, manage }; const updateHabit = useCallback((...args: Parameters) => actions.current.update(...args), []); const manageHabit = useCallback((...args: Parameters) => actions.current.manage(...args), []); const refreshHistory = useCallback(() => setAttempt(value => value + 1), []); const expire = useCallback(() => expired.current(), []); return ( <> {today && ( <>
{today.due > 0 ? ( <> {today.completed} / {today.due} habits complete today ) : (

{today.habits.length ? "Nothing scheduled today." : "No habits yet."}

)}
{today.habits.length > 0 && ( <> )}
)}
{archive && today && setArchive(false)} date={today.date} revision={revision} onExpired={onExpired} onChanged={() => setAttempt(value => value + 1)} />} {settings && setSettings(false)} />} {sharing && today && { setSharing(false); requestAnimationFrame(() => document.getElementById("open-sharing")?.focus()); }} />} {error && (

{error}

)} {!today && loading && (

Loading your habits…

)} {today && ( <> {adding && ( setAdding(false)} onExpired={() => expired.current()} onCreated={() => { focusAddAfterRefresh.current = true; setAdding(false); setHabitView("all"); setAttempt((value) => value + 1); }} /> )}
{habitView === "today" && today.habits.length > 0 && today.due === 0 &&

Choose All habits to view or edit your routines.

}
{today.habits.filter(habit => habitView === "all" || habit.due).map((habit) => ( ))} setAddingOverview(false)} date={today.date} revision={revision + attempt} disabled={busy || loading || needsRefresh || pendingHabits.size > 0} onExpired={expire} onHistorySaved={refreshHistory} />
)} ); } const SavedHabit = memo(function SavedHabit({ habit, date, revision, disabled, onUpdate, onManage, onHistorySaved, onExpired, }: { habit: TodayHabit; date: string; revision: number; disabled: boolean; onUpdate: ( habit: TodayHabit, body: { count: number } | { done: boolean }, taskId?: string ) => Promise; onExpired: () => void; onHistorySaved: () => void; onManage: ( habit: TodayHabit, patch: Record | null, taskId?: string, createTask?: boolean ) => Promise; }) { const [editing, setEditing] = useState<{ mode: ItemMode; color: string } | null>(null); const [addingTask, setAddingTask] = useState(false); const blocked = disabled || !habit.due; const method = habit.requirements?.method ?? habit.method; return ( setEditing({ mode: "edit", color })} onDelete={() => setEditing({ mode: "delete", color: "#196127" })} editor={ editing && habit.requirements ? ( setEditing(null)} onSave={(patch) => onManage(habit, patch)} onDelete={() => onManage(habit, null)} /> ) : undefined } tasks={ habit.requirements?.method === "tasks" ? ( <>
    {habit.requirements.tasks.map((config) => { const occurrence = habit.tasks.find((task) => task.taskId === config.id); return ( void onUpdate(habit, { done }, config.id)} onSave={(patch) => onManage(habit, patch, config.id)} onDelete={() => onManage(habit, null, config.id)} /> ); })}
{addingTask && ( setAddingTask(false)} onSave={(patch) => onManage(habit, patch, undefined, true)} onDelete={async () => {}} /> )}
) : undefined } > {method === "count" ? ( void onUpdate(habit, { count })} /> ) : method === "manual" ? ( void onUpdate(habit, { done: event.target.checked })} /> ) : undefined}
); }); function SavedTask({ task, schedule, habitSchedule, date, disabled, blocked, scheduled, onCheck, onSave, onDelete, }: { task: Pick; disabled: boolean; blocked: boolean; scheduled: boolean; schedule: Schedule; habitSchedule: Schedule; date: string; onCheck: (done: boolean) => void; onSave: (patch: { name?: string; schedule?: Schedule }) => Promise; onDelete: () => Promise; }) { const [mode, setMode] = useState(null); return (
  • onCheck(event.target.checked)} /> setMode("edit")} onDelete={() => setMode("delete")} />
    {mode && ( setMode(null)} /> )}
  • ); }