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

View File

@@ -0,0 +1,228 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Button, SectionHeading } from "./design-system/primitives";
import { Field } from "./design-system/Field";
import { ScheduleEditor } from "./design-system/EditingWorkbench";
import { HabitColorPicker } from "./design-system/HabitColorPicker";
import { habitInput, type Schedule } from "../habits/contracts";
import { habitRequest } from "../lib/dashboard";
/** Account behavior composed from the design system's existing form components. */
export function HabitForm({
date,
onCancel,
onCreated,
onExpired,
}: {
date: string;
onCancel: () => void;
onCreated: (name: string) => void;
onExpired: () => void;
}) {
const form = useRef<HTMLFormElement>(null);
const saving = useRef(false);
const [name, setName] = useState("");
const [method, setMethod] = useState<"manual" | "count" | "tasks">("manual");
const [target, setTarget] = useState(8);
const [unit, setUnit] = useState("glasses");
const [tasks, setTasks] = useState([""]);
const [schedule, setSchedule] = useState<Schedule>({ type: "daily" });
const [color, setColor] = useState("#58765b");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
form.current?.querySelector<HTMLInputElement>("input")?.focus();
return () => previous?.focus();
}, []);
async function submit(event: FormEvent) {
event.preventDefault();
if (saving.current) return;
const parsed = habitInput.safeParse({
name,
method,
schedule,
color,
...(method === "count" ? { target, unit } : {}),
...(method === "tasks" ? { tasks: tasks.map((name) => ({ name })) } : {}),
});
if (!parsed.success) {
setError(parsed.error.issues.map((issue) => issue.message).join(" "));
return;
}
saving.current = true;
setBusy(true);
setError("");
try {
await habitRequest("/habits", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(parsed.data),
});
onCreated(parsed.data.name);
} catch (error) {
if (
error instanceof Error &&
error.message.includes("session has expired")
)
onExpired();
else
setError(
error instanceof Error
? error.message
: "Could not create your habit.",
);
} finally {
saving.current = false;
setBusy(false);
}
}
return (
<section
className="ds-section ds-split-section"
aria-labelledby="new-habit-title"
id="new-habit"
>
<SectionHeading
number="A NEW HABIT"
id="new-habit-title"
title={
<>
Make it <em>yours.</em>
</>
}
>
Choose what counts as complete. Your schedule follows your account’s
timezone.
</SectionHeading>
<form ref={form} className="ds-edit-content" onSubmit={submit}>
<fieldset className="ds-task-editor-fieldset" disabled={busy}>
<Field label="Habit name">
{(id) => (
<input
id={id}
required
maxLength={200}
placeholder="e.g. Read ten pages"
value={name}
onChange={(event) => setName(event.target.value)}
/>
)}
</Field>
<Field label="How will you track it?">
{(id) => (
<select
id={id}
value={method}
onChange={(event) =>
setMethod(event.target.value as typeof method)
}
>
<option value="manual">A simple check-in</option>
<option value="count">A count target</option>
<option value="tasks">A list of tasks</option>
</select>
)}
</Field>
{method === "count" && (
<div className="ds-form-grid">
<Field label="Daily target">
{(id) => (
<input
id={id}
type="number"
required
min={1}
max={10000}
step={1}
value={Number.isNaN(target) ? "" : target}
onChange={(event) => setTarget(event.target.valueAsNumber)}
/>
)}
</Field>
<Field label="Unit">
{(id) => (
<input
id={id}
required
maxLength={80}
value={unit}
onChange={(event) => setUnit(event.target.value)}
/>
)}
</Field>
</div>
)}
{method === "tasks" && (
<div className="ds-form-section">
{tasks.map((task, index) => (
<Field key={index} label={`Task ${index + 1}`}>
{(id) => (
<div className="ds-input-row">
<input
id={id}
required
maxLength={200}
value={task}
onChange={(event) =>
setTasks((current) =>
current.map((value, i) =>
i === index ? event.target.value : value,
),
)
}
/>
<Button
variant="text"
aria-label={`Remove task ${index + 1}`}
disabled={tasks.length === 1}
onClick={() =>
setTasks((current) =>
current.filter((_, i) => i !== index),
)
}
>
Remove
</Button>
</div>
)}
</Field>
))}
<Button
variant="secondary"
disabled={tasks.length >= 100}
onClick={() => setTasks((current) => [...current, ""])}
>
Add task +
</Button>
<p className="ds-form-feedback">
Tasks repeat on each scheduled day.
</p>
</div>
)}
<ScheduleEditor
value={schedule}
onChange={setSchedule}
anchorDate={date}
/>
<HabitColorPicker value={color} onChange={setColor} mode="create" />
</fieldset>
{error && (
<p className="ds-form-feedback" role="alert">
{error}
</p>
)}
<div className="ds-form-actions">
<Button type="submit" disabled={busy}>
{busy ? "Creating…" : "Create habit"}
</Button>
<Button variant="text" disabled={busy} onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</section>
);
}