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:
228
src/components/HabitForm.tsx
Normal file
228
src/components/HabitForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user