feat: add Home page with user dashboard and habit tracking features

- Implemented Home component with user authentication and loading states.
- Created Welcome component for unauthenticated users with a sign-in option.
- Developed Dashboard component to display user's habits and progress.
- Added functionality for habit management, including adding, updating, and deleting habits.
- Integrated HabitChart and HabitHistory components for visual representation of habits.
- Introduced sharing functionality for progress via Discord integration.

feat: establish Discord sharing configuration and routes

- Added DiscordSharingConfig type and readDiscordSharingConfig function for environment variable management.
- Created sharing contracts for input validation and data structure.
- Implemented sharing routes for previewing and sending progress images to Discord.
- Added tests for sharing routes to ensure authentication and proper error handling.
This commit is contained in:
syntaxbullet
2026-09-04 17:48:54 +02:00
parent dde5e77317
commit ecc2620a4c
34 changed files with 4017 additions and 140 deletions

632
src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,632 @@
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 [adding, setAdding] = useState(false);
const [sharing, setSharing] = useState(false);
const [busy, setBusy] = useState(false);
const [needsRefresh, setNeedsRefresh] = useState(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);
});
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);
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" : "deleted"}.`
);
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
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-actions">
<Button
id="add-habit"
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" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button>
<ButtonLink href="#habits-title">View your habits </ButtonLink>
</>
)}
</div>
</>
)}
</WelcomePanel>
{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) => {
setAdding(false);
setNotice(`${name} created.`);
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={
<>
One day <em>at a time.</em>
</>
}
/>
</div>
<div className="ds-habit-chart-grid">
{today.habits.map((habit) => (
<SavedHabit
key={habit.habitId}
habit={habit}
date={today.date}
revision={revision}
disabled={busy || loading || needsRefresh}
onUpdate={update}
onManage={manage}
onExpired={() => expired.current()}
/>
))}
</div>
</section>
)}
</>
)}
</>
);
}
function SavedHabit({
habit,
date,
revision,
disabled,
onUpdate,
onManage,
onExpired,
}: {
habit: TodayHabit;
date: string;
revision: number;
disabled: boolean;
onUpdate: (
habit: TodayHabit,
body: { count: number } | { done: boolean },
taskId?: string
) => Promise<void>;
onExpired: () => 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}
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"
disabled={disabled || habit.requirements.tasks.length >= 100}
onClick={() => setAddingTask(true)}
>
Add task +
</Button>
</div>
)}
</>
) : undefined
}
>
{habit.method === "count" ? (
<Counter
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>
);
}