Files
minabot/src/pages/Home.tsx

638 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<PageLayout
header={
loading ? (
<span className="ds-library-label">Loading account…</span>
) : user ? (
<Button variant="text" disabled={busy || !online} onClick={() => void signOut()}>
{busy ? "Signing out…" : "Sign out"}
</Button>
) : (
!accountError && <DiscordSignInButton disabled={!online} variant="secondary" onClick={signIn} />
)
}
>
{error && !disconnected && (
<div className="ds-form-feedback" role="alert">
{error}
</div>
)}
{loading ? (
<section className="ds-section" aria-busy="true">
<p role="status">Loading account…</p>
</section>
) : accountError ? (
<section className="ds-section">
<h1 className="type-section">{disconnected ? "Connection unavailable" : "Account unavailable"}</h1>
<div className="ds-form-stack">
<p>{disconnected ? "Check your connection. We’ll retry automatically." : "Your account couldn’t be loaded."}</p>
<div className="ds-actions">
<Button onClick={retry}>Try again</Button>
</div>
</div>
</section>
) : user ? (
<CalendarCacheProvider key={`${user.id}:${user.timezone}`}><Dashboard user={user} onExpired={retry} /></CalendarCacheProvider>
) : (
<Welcome onSignIn={signIn} />
)}
</PageLayout>
);
}
function Welcome({ onSignIn }: { onSignIn: () => void }) {
const online = useOnline();
const [water, setWater] = useState(3);
const [read, setRead] = useState(false);
return (
<>
<section className="ds-section ds-landing-intro" aria-labelledby="welcome-title">
<h1 id="welcome-title" className="type-section">Track your habits</h1>
<p className="type-small">Sign in to save your habits and progress.</p>
<div className="ds-actions">
<DiscordSignInButton disabled={!online} onClick={onSignIn} />
</div>
</section>
<section className="ds-section" id="try-it" aria-labelledby="try-it-title">
<div className="ds-section-top">
<SectionHeading id="try-it-title" title="Try a check-in" />
<span className="ds-demo-note">Demo · changes are not saved</span>
</div>
<div className="ds-habit-chart-grid">
<HabitChart
headingLevel={3}
name="Drink water"
method="Count target"
value={water}
target={8}
unit="glasses"
color={HABIT_COLORS.water}
>
<Counter label="glasses of water" value={water} target={8} onChange={setWater} />
</HabitChart>
<HabitChart
headingLevel={3}
name="Read a little"
method="Simple check-in"
value={Number(read)}
target={1}
unit="reading session"
color={HABIT_COLORS.reading}
>
<Checkbox
label="Reading done"
checked={read}
onChange={(event) => setRead(event.target.checked)}
/>
</HabitChart>
</div>
</section>
</>
);
}
function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => void }) {
const [today, setToday] = useState<TodayResponse | null>(null);
const todaySnapshot = useRef(today);
todaySnapshot.current = today;
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
const [habitRevisions, setHabitRevisions] = useState<Record<string, number>>({});
const [pendingHabits, setPendingHabits] = useState<Set<string>>(new Set());
const progressSaves = useRef(new Set<string>());
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<TodayResponse>("/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<TodayHabit>(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<string, unknown> | 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<TodayResponse>("/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<typeof update>) => actions.current.update(...args), []);
const manageHabit = useCallback((...args: Parameters<typeof manage>) => actions.current.manage(...args), []);
const refreshHistory = useCallback(() => setAttempt(value => value + 1), []);
const expire = useCallback(() => expired.current(), []);
return (
<>
<WelcomePanel
compact
name={user.displayName || user.username}
avatarUrl={user.avatarUrl}
date={today?.date}
timezone={today?.timezone || user.timezone}
>
{today && (
<>
<div className="ds-welcome-progress" role="status">
{today.due > 0 ? (
<>
<span className="type-title">
{today.completed} / {today.due}
</span>
<span>
habits complete today
</span>
</>
) : (
<p>
{today.habits.length
? "Nothing scheduled today."
: "No habits yet."}
</p>
)}
</div>
<div className="ds-workspace-actions">
<div className="ds-action-row ds-primary-actions">
<Button
id="add-habit"
aria-haspopup="dialog"
disabled={adding || busy || loading || needsRefresh}
onClick={() => setAdding(true)}
aria-expanded={adding}
aria-controls={adding ? "new-habit" : undefined}
>
Add a habit +
</Button>
<Button id="create-overview" variant="secondary" disabled={busy || loading || needsRefresh} aria-haspopup="dialog" onClick={() => setAddingOverview(true)}>Create overview</Button>
{today.habits.length > 0 && (
<>
<Button id="open-sharing" aria-haspopup="dialog" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button>
</>
)}
</div>
<div className="ds-action-row ds-utility-row">
<Button variant="text" disabled={busy} aria-haspopup="dialog" aria-expanded={archive} aria-controls={archive ? "archived-habits" : undefined} onClick={() => setArchive(!archive)}><Archive size={16} aria-hidden="true" />Archived habits</Button>
<Button variant="text" disabled={busy} aria-haspopup="dialog" aria-expanded={settings} aria-controls={settings ? "account-settings" : undefined} onClick={() => setSettings(!settings)}><Settings2 size={16} aria-hidden="true" />Settings</Button>
</div>
</div>
</>
)}
</WelcomePanel>
{archive && today && <ArchivedHabits onClose={() => setArchive(false)} date={today.date} revision={revision} onExpired={onExpired} onChanged={() => setAttempt(value => value + 1)} />}
{settings && <AccountSettings user={user} onChanged={onExpired} onClose={() => setSettings(false)} />}
{sharing && today && <ShareProgress user={user} today={today} revision={revision} onClose={() => { setSharing(false); requestAnimationFrame(() => document.getElementById("open-sharing")?.focus()); }} />}
{error && (
<div className="ds-form-feedback" role="alert">
<p>{error}</p>
<Button
variant="secondary"
disabled={loading}
onClick={() => setAttempt((value) => value + 1)}
>
Try again
</Button>
</div>
)}
{!today && loading && (
<section className="ds-section">
<p role="status">Loading your habits…</p>
</section>
)}
{today && (
<>
{adding && (
<HabitForm
date={today.date}
onCancel={() => setAdding(false)}
onExpired={() => expired.current()}
onCreated={() => {
focusAddAfterRefresh.current = true;
setAdding(false);
setHabitView("all");
setAttempt((value) => value + 1);
}}
/>
)}
<section
className="ds-section ds-habits-section"
aria-labelledby="habits-title"
aria-busy={busy || loading}
>
<div className="ds-section-top">
<SectionHeading
id="habits-title"
title="Habits"
/>
<div className="ds-action-row ds-habit-filters" role="group" aria-label="Show habits">
<Button variant={habitView === "today" ? "primary" : "text"} aria-pressed={habitView === "today"} onClick={() => setHabitView("today")}>Today · {today.due}</Button>
<Button variant={habitView === "all" ? "primary" : "text"} aria-pressed={habitView === "all"} onClick={() => setHabitView("all")}>All habits · {today.habits.length}</Button>
</div>
</div>
{habitView === "today" && today.habits.length > 0 && today.due === 0 && <p className="ds-supporting-copy">Choose All habits to view or edit your routines.</p>}
<div className="ds-habit-chart-grid ds-habit-list">
{today.habits.filter(habit => habitView === "all" || habit.due).map((habit) => (
<SavedHabit
key={habit.habitId}
habit={habit}
date={today.date}
revision={habit.calendarRevision ?? habitRevisions[habit.habitId] ?? 0}
disabled={busy || pendingHabits.has(habit.habitId) || loading || needsRefresh}
onHistorySaved={refreshHistory}
onUpdate={updateHabit}
onManage={manageHabit}
onExpired={expire}
/>
))}
<ProgressOverview creating={addingOverview} onCloseCreate={() => setAddingOverview(false)} date={today.date} revision={revision + attempt} disabled={busy || loading || needsRefresh || pendingHabits.size > 0} onExpired={expire} onHistorySaved={refreshHistory} />
</div>
</section>
</>
)}
</>
);
}
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<void>;
onExpired: () => void;
onHistorySaved: () => void;
onManage: (
habit: TodayHabit,
patch: Record<string, unknown> | null,
taskId?: string,
createTask?: boolean
) => Promise<void>;
}) {
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 (
<HabitHistory
habit={habit}
date={date}
revision={revision}
disabled={disabled}
onExpired={onExpired}
onHistorySaved={onHistorySaved}
onEdit={(color) => setEditing({ mode: "edit", color })}
onDelete={() => setEditing({ mode: "delete", color: "#196127" })}
editor={
editing && habit.requirements ? (
<InlineHabitEditor
key={editing.mode}
config={habit.requirements}
date={date}
color={editing.color}
mode={editing.mode}
disabled={disabled}
onClose={() => setEditing(null)}
onSave={(patch) => onManage(habit, patch)}
onDelete={() => onManage(habit, null)}
/>
) : undefined
}
tasks={
habit.requirements?.method === "tasks" ? (
<>
<ul className="ds-routine-items">
{habit.requirements.tasks.map((config) => {
const occurrence = habit.tasks.find((task) => task.taskId === config.id);
return (
<SavedTask
key={config.id}
task={{ taskId: config.id, name: config.name, done: occurrence?.done ?? false }}
disabled={disabled}
blocked={blocked || !occurrence}
scheduled={!!occurrence}
schedule={config.schedule}
habitSchedule={habit.requirements!.schedule}
date={date}
onCheck={(done) => void onUpdate(habit, { done }, config.id)}
onSave={(patch) => onManage(habit, patch, config.id)}
onDelete={() => onManage(habit, null, config.id)}
/>
);
})}
</ul>
{addingTask && (
<InlineTaskEditor
name=""
schedule={{ type: "daily" }}
habitSchedule={habit.requirements.schedule}
date={date}
mode="edit"
creating
disabled={disabled}
onClose={() => setAddingTask(false)}
onSave={(patch) => onManage(habit, patch, undefined, true)}
onDelete={async () => {}}
/>
)}
<div className="ds-task-add-action">
<Button
variant="text"
aria-haspopup="dialog"
aria-expanded={addingTask}
disabled={disabled || habit.requirements.tasks.length >= 100}
onClick={() => setAddingTask(true)}
>
<Plus size={20} aria-hidden="true" /><span>Add task</span>
</Button>
</div>
</>
) : undefined
}
>
{method === "count" ? (
<Counter
key={date}
label={habit.name ?? "habit count"}
value={habit.value}
target={habit.target ?? 0}
disabled={blocked}
onChange={(count) => void onUpdate(habit, { count })}
/>
) : method === "manual" ? (
<Checkbox
id={`habit-check-${habit.habitId}`}
hideLabel
label={`${habit.name} done`}
checked={habit.complete}
disabled={blocked}
onChange={(event) => void onUpdate(habit, { done: event.target.checked })}
/>
) : undefined}
</HabitHistory>
);
});
function SavedTask({
task,
schedule,
habitSchedule,
date,
disabled,
blocked,
scheduled,
onCheck,
onSave,
onDelete,
}: {
task: Pick<TodayHabit["tasks"][number], "taskId" | "name" | "done">;
disabled: boolean;
blocked: boolean;
scheduled: boolean;
schedule: Schedule;
habitSchedule: Schedule;
date: string;
onCheck: (done: boolean) => void;
onSave: (patch: { name?: string; schedule?: Schedule }) => Promise<void>;
onDelete: () => Promise<void>;
}) {
const [mode, setMode] = useState<ItemMode | null>(null);
return (
<li className="ds-editable-task">
<div className="ds-editable-task-row">
<Checkbox
label={task.name}
description={!scheduled ? "Not scheduled today" : schedule.type === "daily" ? undefined : scheduleLabel(schedule)}
checked={task.done}
disabled={blocked || !!mode}
onChange={(event) => onCheck(event.target.checked)}
/>
<ItemActions
name={task.name}
kind="task"
disabled={disabled || !!mode}
onEdit={() => setMode("edit")}
onDelete={() => setMode("delete")}
/>
</div>
{mode && (
<InlineTaskEditor
name={task.name}
schedule={schedule}
habitSchedule={habitSchedule}
date={date}
mode={mode}
disabled={disabled}
onSave={onSave}
onDelete={onDelete}
onClose={() => setMode(null)}
/>
)}
</li>
);
}