Files
minabot/src/pages/Home.tsx

668 lines
23 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 { Archive, Settings2 } from "lucide-react";
import { ArchivedHabits } from "../components/ArchivedHabits";
import { AccountSettings } from "../components/AccountSettings";
import { 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,
ButtonLink,
Checkbox,
Counter,
SectionHeading,
} from "../components/design-system/primitives";
import { Card, CardGrid } from "../components/design-system/Card";
import { HabitChart } from "../components/design-system/HabitChart";
import { HABIT_COLORS } from "../components/design-system/calendar-model";
import { HabitForm } from "../components/HabitForm";
import { habitRequest, 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";
import { InlineHabitEditor } from "../components/InlineHabitEditor";
import { InlineTaskEditor } from "../components/InlineTaskEditor";
import type { Schedule } from "../habits/contracts";
import { ShareProgress } from "../components/ShareProgress";
export function Home() {
const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();
return (
<PageLayout
header={
loading ? (
<span className="ds-library-label">Loading account</span>
) : user ? (
<Button variant="text" disabled={busy} onClick={() => void signOut()}>
{busy ? "Signing out…" : "Sign out"}
</Button>
) : (
!accountError && <DiscordSignInButton variant="secondary" onClick={signIn} />
)
}
>
{error && (
<div className="ds-form-feedback" role="alert">
{error}
</div>
)}
{loading ? (
<section className="ds-section" aria-busy="true">
<p role="status">Getting things ready</p>
</section>
) : accountError ? (
<section className="ds-section">
<h1 className="type-section">Lets try that again.</h1>
<Card headingLevel={2} heading="Account unavailable">
<p>Your account couldnt be loaded.</p>
<div className="ds-actions">
<Button onClick={retry}>Try again</Button>
</div>
</Card>
</section>
) : user ? (
<Dashboard key={user.id} user={user} onExpired={retry} />
) : (
<Welcome onSignIn={signIn} />
)}
</PageLayout>
);
}
function Welcome({ onSignIn }: { onSignIn: () => void }) {
const [water, setWater] = useState(3);
const [read, setRead] = useState(false);
return (
<>
<section className="ds-section ds-split-section" aria-labelledby="welcome-title">
<div className="ds-section-heading">
<p className="ds-eyebrow">A LITTLE, EVERY DAY</p>
<h1 id="welcome-title" className="type-section">
Small steps.
<br />
<em>Lasting rhythm.</em>
</h1>
</div>
<Card headingLevel={2} heading="Make room for what matters.">
<p>
Check in, count a little more, or work through a few tasks. See your progress grow, one
day at a time.
</p>
<div className="ds-actions">
<DiscordSignInButton onClick={onSignIn} />
<ButtonLink href="#try-it">Try it below </ButtonLink>
</div>
<p className="type-small">Sign in with your Discord account to save your habits.</p>
</Card>
</section>
<section className="ds-section" id="try-it" aria-labelledby="try-it-title">
<div className="ds-section-top">
<SectionHeading
number="TRY A CHECK-IN"
id="try-it-title"
title={
<>
A little progress. <em>Made visible.</em>
</>
}
/>
<span className="ds-demo-note">Example · September 4, 2026 · 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>
<section className="ds-section" aria-label="Your own rhythm">
<CardGrid>
<Card heading="Your habits. Your pace." eyebrow="MAKE IT FIT">
<p>
Pick a check-in, a count target, or a task list. Repeat daily, on selected weekdays,
or at your own interval.
</p>
</Card>
<Card heading="See the days add up." eyebrow="KEEP PERSPECTIVE" variant="outlined">
<p>
Explore your calendar to see the progress behind each square. Days off stay distinct
from missed days.
</p>
</Card>
</CardGrid>
</section>
</>
);
}
function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => void }) {
const [today, setToday] = useState<TodayResponse | null>(null);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [revision, setRevision] = useState(0);
const [attempt, setAttempt] = useState(0);
const [loading, setLoading] = useState(true);
const [habitView, setHabitView] = useState<"today" | "all">("today");
const [adding, setAdding] = 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) {
setToday(data);
setNeedsRefresh(false);
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 (!saving.current && document.visibilityState === "visible")
setAttempt((value) => value + 1);
};
const timer = window.setInterval(refresh, 60_000);
window.addEventListener("focus", refresh);
return () => {
window.clearInterval(timer);
window.removeEventListener("focus", refresh);
};
}, []);
async function update(
habit: TodayHabit,
body: { count: number } | { done: boolean },
taskId?: string
) {
if (saving.current || loading || needsRefresh || !today) return;
saving.current = true;
setBusy(true);
setError("");
setNotice("");
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);
setNotice(`${habit.name} saved.`);
} catch (error) {
if (mounted.current) reportError(error);
} finally {
saving.current = false;
if (mounted.current) setBusy(false);
}
}
async function manage(
habit: TodayHabit,
patch: Record<string, unknown> | null,
taskId?: string,
createTask = false
) {
if (saving.current || loading || needsRefresh)
throw new Error("Please wait for the dashboard to refresh.");
saving.current = true;
setBusy(true);
setError("");
setNotice("");
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(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);
}
} 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) {
setNotice(
`${taskId || createTask ? "Task" : "Habit"} ${createTask ? "added" : patch ? "updated" : taskId ? "deleted" : "archived"}.`
);
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);
}
}
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
{today.completed === today.due && (
<span className="ds-welcome-complete">A little, all done.</span>
)}
</span>
</>
) : (
<p>
{today.habits.length
? "Nothing scheduled today. Enjoy a little breathing room."
: "Start with one habit. Your first small step starts here."}
</p>
)}
</div>
<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>
{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>
</>
)}
</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={(name) => {
focusAddAfterRefresh.current = true;
setAdding(false);
setNotice(`${name} created.`);
setHabitView("all");
setAttempt((value) => value + 1);
}}
/>
)}
<p className="ds-form-feedback" role="status">
{notice}
</p>
{today.habits.length > 0 && (
<section
className="ds-section"
aria-labelledby="habits-title"
aria-busy={busy || loading}
>
<div className="ds-section-top">
<SectionHeading
number="YOUR HABITS"
id="habits-title"
title={
<>
Today, <em>at your pace.</em>
</>
}
/>
</div>
<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>
{habitView === "today" && today.due === 0 && <p className="ds-supporting-copy">Youre all set for today. 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={revision}
disabled={busy || loading || needsRefresh}
onHistorySaved={() => setAttempt(value => value + 1)}
onUpdate={update}
onManage={manage}
onExpired={() => expired.current()}
/>
))}
</div>
</section>
)}
</>
)}
</>
);
}
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;
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" ? (
<>
{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)}
/>
);
})}
{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">
{!habit.requirements.tasks.length && (
<p className="ds-footnote">Add a task to start checking in.</p>
)}
<Button
variant="text"
aria-haspopup="dialog"
aria-expanded={addingTask}
disabled={disabled || habit.requirements.tasks.length >= 100}
onClick={() => setAddingTask(true)}
>
Add task +
</Button>
</div>
</>
) : undefined
}
>
{habit.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 })}
/>
) : habit.method === "manual" ? (
<Checkbox
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 (
<div className="ds-editable-task">
<div className="ds-editable-task-row">
<Checkbox
label={task.name}
description={`${scheduleLabel(schedule)}${scheduled ? "" : " · Not scheduled today"}`}
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)}
/>
)}
</div>
);
}