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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import type { CalendarResponse } from "../shared/calendar";
|
||||
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
||||
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
|
||||
@@ -7,6 +7,7 @@ import { Button } from "./design-system/primitives";
|
||||
import { HabitChart } from "./design-system/HabitChart";
|
||||
import { CalendarLegend } from "./design-system/CalendarLegend";
|
||||
import { shade } from "../habits/calendar";
|
||||
import { ItemActions } from "./design-system/ItemActions";
|
||||
|
||||
export function HabitHistory({
|
||||
habit,
|
||||
@@ -15,17 +16,27 @@ export function HabitHistory({
|
||||
onEdit,
|
||||
onDelete,
|
||||
disabled = false,
|
||||
children,
|
||||
tasks,
|
||||
editor,
|
||||
onExpired,
|
||||
}: {
|
||||
habit: TodayHabit;
|
||||
date: string;
|
||||
revision: number;
|
||||
onEdit: (color: string) => void;
|
||||
onDelete: () => void;
|
||||
onEdit?: (color: string) => void;
|
||||
onDelete?: () => void;
|
||||
disabled?: boolean;
|
||||
children?: ReactNode;
|
||||
tasks?: ReactNode;
|
||||
editor?: ReactNode;
|
||||
onExpired?: () => void;
|
||||
}) {
|
||||
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const expired = useRef(onExpired);
|
||||
expired.current = onExpired;
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setError("");
|
||||
@@ -38,7 +49,10 @@ export function HabitHistory({
|
||||
if (!controller.signal.aborted) setCalendar(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) setError(error.message);
|
||||
if (controller.signal.aborted) return;
|
||||
if (error.message.includes("session has expired") && expired.current)
|
||||
expired.current();
|
||||
else setError(error.message);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [habit.habitId, date, revision, attempt]);
|
||||
@@ -78,7 +92,7 @@ export function HabitHistory({
|
||||
]
|
||||
: [];
|
||||
const chart = error ? (
|
||||
<div className="home-state">
|
||||
<div className="ds-form-feedback">
|
||||
<p role="alert">{error}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -88,7 +102,7 @@ export function HabitHistory({
|
||||
</Button>
|
||||
</div>
|
||||
) : !calendar ? (
|
||||
<p className="home-state" role="status">
|
||||
<p className="ds-form-feedback" role="status">
|
||||
Loading your habit history…
|
||||
</p>
|
||||
) : (
|
||||
@@ -128,29 +142,44 @@ export function HabitHistory({
|
||||
unit={habit.unit}
|
||||
color={calendar?.settings.mainColor ?? "#196127"}
|
||||
calendar={chart}
|
||||
tasks={tasks}
|
||||
tasksLabel="Tasks"
|
||||
headingLevel={3}
|
||||
editor={editor}
|
||||
headingActions={onEdit && onDelete ? (
|
||||
<ItemActions name={habit.name ?? "Habit"} kind="habit" disabled={disabled || !calendar}
|
||||
onEdit={() => onEdit(calendar!.settings.mainColor)} onDelete={onDelete} />
|
||||
) : undefined}
|
||||
>
|
||||
<div
|
||||
className="home-habit-actions"
|
||||
role="group"
|
||||
aria-label={`${habit.name} actions`}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label={`Edit ${habit.name}`}
|
||||
disabled={disabled || !calendar}
|
||||
onClick={() => onEdit(calendar!.settings.mainColor)}
|
||||
{children}
|
||||
{(onEdit || onDelete) && !(onEdit && onDelete) && (
|
||||
<div
|
||||
className="ds-actions"
|
||||
role="group"
|
||||
aria-label={`${habit.name} actions`}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label={`Delete ${habit.name}`}
|
||||
disabled={disabled}
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label={`Edit ${habit.name}`}
|
||||
disabled={disabled || !calendar}
|
||||
onClick={() => onEdit(calendar!.settings.mainColor)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label={`Delete ${habit.name}`}
|
||||
disabled={disabled}
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</HabitChart>
|
||||
);
|
||||
}
|
||||
|
||||
54
src/components/InlineHabitEditor.tsx
Normal file
54
src/components/InlineHabitEditor.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useState } from "react";
|
||||
import type { HabitConfig } from "../habits/contracts";
|
||||
import { habitPatch } from "../habits/contracts";
|
||||
import { Field } from "./design-system/Field";
|
||||
import { ScheduleEditor } from "./design-system/EditingWorkbench";
|
||||
import { HabitColorPicker } from "./design-system/HabitColorPicker";
|
||||
import { InlineItemForm, type ItemMode } from "./InlineItemForm";
|
||||
import { Checkbox } from "./design-system/primitives";
|
||||
|
||||
export function InlineHabitEditor({ config, color: initialColor, date, mode, disabled, onSave, onDelete, onClose }: {
|
||||
config: HabitConfig;
|
||||
color: string;
|
||||
date: string;
|
||||
mode: ItemMode;
|
||||
disabled: boolean;
|
||||
onSave: (patch: Record<string, unknown>) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [schedule, setSchedule] = useState(config.schedule);
|
||||
const [method, setMethod] = useState(config.method);
|
||||
const [carryPartialProgress, setCarryPartialProgress] = useState(config.method === "count" && config.carryPartialProgress);
|
||||
const [color, setColor] = useState(initialColor);
|
||||
const [target, setTarget] = useState(config.method === "count" ? config.target : 1);
|
||||
const [unit, setUnit] = useState(config.method === "count" ? config.unit : "times");
|
||||
return (
|
||||
<InlineItemForm name={config.name} kind="habit" mode={mode} disabled={disabled} onClose={onClose} onDelete={onDelete}
|
||||
onSave={async (name) => {
|
||||
const parsed = habitPatch.safeParse({ name, method, schedule, color, ...(method === "count" ? { target, unit, carryPartialProgress } : {}) });
|
||||
if (!parsed.success) throw new Error(parsed.error.issues.map((issue) => issue.message).join(" "));
|
||||
await onSave(parsed.data);
|
||||
}}>
|
||||
<Field label="How will you track it?">
|
||||
{(id) => <select id={id} value={method} onChange={(event) => setMethod(event.target.value as HabitConfig["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 !== config.method && <p className="ds-footnote">Changing the tracking method starts today’s progress over. Earlier history is kept.{method === "tasks" ? " After saving, add tasks below." : config.method === "tasks" ? " Existing tasks will leave this habit." : ""}</p>}
|
||||
{method === "count" && (<>
|
||||
<div className="ds-form-grid">
|
||||
<Field label="Daily target">{(id) => <input id={id} type="number" min={1} max={10000} step={1} required 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>
|
||||
<Checkbox label="Carry unfinished counts to the next scheduled day" checked={carryPartialProgress} onChange={(event) => setCarryPartialProgress(event.target.checked)} />
|
||||
<p className="ds-footnote">Completed counts reset. Explicitly recorded counts are kept.</p>
|
||||
</>)}
|
||||
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
|
||||
<HabitColorPicker value={color} onChange={setColor} mode="edit" />
|
||||
<p className="ds-footnote">Changes start today. Earlier targets and schedules stay as they were.</p>
|
||||
</InlineItemForm>
|
||||
);
|
||||
}
|
||||
78
src/components/InlineItemForm.tsx
Normal file
78
src/components/InlineItemForm.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Button } from "./design-system/primitives";
|
||||
import { Field } from "./design-system/Field";
|
||||
|
||||
export type ItemMode = "edit" | "delete";
|
||||
|
||||
/** In-place editing and confirmation, shared by habit headings and task rows. */
|
||||
export function InlineItemForm({ name, kind, mode, disabled, children, onSave, onDelete, onClose, submitLabel, formLabel }: {
|
||||
name: string;
|
||||
kind: "habit" | "task";
|
||||
mode: ItemMode;
|
||||
disabled?: boolean;
|
||||
children?: ReactNode;
|
||||
submitLabel?: string;
|
||||
formLabel?: string;
|
||||
onSave: (name: string) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const form = useRef<HTMLFormElement>(null);
|
||||
const saving = useRef(false);
|
||||
const [draft, setDraft] = useState(name);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
form.current?.querySelector<HTMLElement>(mode === "edit" ? "input" : "button")?.focus();
|
||||
return () => { if (previous?.isConnected) previous.focus(); };
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<form ref={form} className={`ds-inline-item-form ds-inline-item-form--${kind}`} aria-label={formLabel ?? `${mode === "edit" ? "Edit" : "Delete"} ${kind} ${name}`}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && !saving.current) { event.preventDefault(); onClose(); }
|
||||
}}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (saving.current || disabled) return;
|
||||
if (mode === "edit" && !draft.trim()) { setError("Enter a name."); return; }
|
||||
saving.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (mode === "delete") await onDelete();
|
||||
else await onSave(draft.trim());
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : "Could not save your change. Try again.");
|
||||
} finally {
|
||||
saving.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}}>
|
||||
{mode === "edit" ? (
|
||||
<fieldset className="ds-inline-item-fields" disabled={busy || disabled}>
|
||||
<Field label={kind === "habit" ? "Habit name" : "Task name"}>
|
||||
{(id) => <input id={id} value={draft} required maxLength={200} onChange={(event) => setDraft(event.target.value)} />}
|
||||
</Field>
|
||||
{children}
|
||||
</fieldset>
|
||||
) : (
|
||||
<p className="ds-inline-delete-copy">
|
||||
Delete <strong>{name}</strong>? {kind === "habit"
|
||||
? "This removes the habit from your dashboard. Earlier history is kept."
|
||||
: "This removes the task from today and future check-ins. Earlier history is kept."}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="ds-form-feedback" role="alert">{error}</p>}
|
||||
<div className="ds-inline-form-actions">
|
||||
{mode === "delete" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
|
||||
<Button type="submit" disabled={busy || disabled}>
|
||||
{busy ? "Saving…" : mode === "delete" ? `Delete ${kind}` : submitLabel ?? "Save changes"}
|
||||
</Button>
|
||||
{mode === "edit" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
33
src/components/InlineTaskEditor.tsx
Normal file
33
src/components/InlineTaskEditor.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState } from "react";
|
||||
import { taskPatch, type Schedule } from "../habits/contracts";
|
||||
import { scheduleLabel } from "../lib/dashboard";
|
||||
import { ScheduleEditor } from "./design-system/EditingWorkbench";
|
||||
import { InlineItemForm, type ItemMode } from "./InlineItemForm";
|
||||
|
||||
export function InlineTaskEditor({ name, schedule: savedSchedule, habitSchedule, date, mode, disabled, creating = false, onSave, onDelete, onClose }: {
|
||||
name: string;
|
||||
schedule: Schedule;
|
||||
habitSchedule: Schedule;
|
||||
date: string;
|
||||
mode: ItemMode;
|
||||
disabled: boolean;
|
||||
creating?: boolean;
|
||||
onSave: (patch: { name?: string; schedule?: Schedule }) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [schedule, setSchedule] = useState(savedSchedule);
|
||||
return (
|
||||
<InlineItemForm name={name} kind="task" mode={mode} disabled={disabled} onDelete={onDelete} onClose={onClose}
|
||||
formLabel={creating ? "Add task" : undefined} submitLabel={creating ? "Add task" : undefined}
|
||||
onSave={async (name) => {
|
||||
const parsed = taskPatch.safeParse({ name, schedule });
|
||||
if (!parsed.success) throw new Error(parsed.error.issues.map((issue) => issue.message).join(" "));
|
||||
await onSave(parsed.data);
|
||||
}}>
|
||||
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
|
||||
<p className="ds-footnote">This task is due when both its recurrence and the habit’s schedule match. Habit: {scheduleLabel(habitSchedule)}.</p>
|
||||
{!creating && <p className="ds-footnote">Changes start today. Earlier check-ins keep their original schedule.</p>}
|
||||
</InlineItemForm>
|
||||
);
|
||||
}
|
||||
108
src/components/ShareProgress.tsx
Normal file
108
src/components/ShareProgress.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, Send, X } from "lucide-react";
|
||||
import { Button, Checkbox } from "./design-system/primitives";
|
||||
import { Field } from "./design-system/Field";
|
||||
import { habitRequest, type TodayResponse } from "../lib/dashboard";
|
||||
import { addDays } from "../habits/calendar";
|
||||
import { renderProgressCard, type CardPrivacy } from "../lib/progress-card";
|
||||
import type { PublicUser } from "../shared/user";
|
||||
import type { Delivery, DiscordConnection, ShareData } from "../sharing/contracts";
|
||||
|
||||
export function ShareProgress({ user, today, revision, onClose }: { user: PublicUser; today: TodayResponse; revision: number; onClose: () => void }) {
|
||||
const [ids, setIds] = useState(() => today.habits.slice(0, 1).map(h => h.habitId));
|
||||
const [range, setRange] = useState("30");
|
||||
const [from, setFrom] = useState(addDays(today.date, -29));
|
||||
const [to, setTo] = useState(today.date);
|
||||
const [privacy, setPrivacy] = useState<CardPrivacy>({ name: true, avatar: true, habitNames: true, timezone: false });
|
||||
const [card, setCard] = useState<{ key: string; url: string; blob: Blob; alt: string; deliveryId: string; avatarMissing: boolean } | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [renderAttempt, setRenderAttempt] = useState(0);
|
||||
const [connection, setConnection] = useState<DiscordConnection | null>(null);
|
||||
const [connectionError, setConnectionError] = useState("");
|
||||
const [connectionAttempt, setConnectionAttempt] = useState(0);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [delivery, setDelivery] = useState<(Delivery & { id: string }) | null>(null);
|
||||
const [sendError, setSendError] = useState("");
|
||||
const lock = useRef(false);
|
||||
const lastImage = useRef<{ hash: string; deliveryId: string } | null>(null);
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
const key = JSON.stringify({ ids, from, to, privacy, revision, user });
|
||||
const ready = card?.key === key ? card : null;
|
||||
const sent = ready && delivery?.id === ready.deliveryId ? delivery : null;
|
||||
const busy = sending;
|
||||
useEffect(() => { heading.current?.focus(); }, []);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setConnectionError("");
|
||||
setConnection(null);
|
||||
habitRequest<DiscordConnection>("/sharing/discord", { signal: controller.signal }).then(value => {
|
||||
if (!controller.signal.aborted) setConnection(value);
|
||||
}).catch(e => { if (!controller.signal.aborted) setConnectionError(e.message); });
|
||||
return () => controller.abort();
|
||||
}, [connectionAttempt]);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let objectUrl: string | undefined;
|
||||
setError(""); setSendError("");
|
||||
const timer = setTimeout(async () => {
|
||||
if (!ids.length) { setError("Choose at least one habit for your card."); return; }
|
||||
try {
|
||||
const data = await habitRequest<ShareData>("/sharing/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ habitIds: ids, from, to }), signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
const image = await renderProgressCard(data, user, privacy);
|
||||
const hash = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await image.blob.arrayBuffer())), byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
if (controller.signal.aborted) return;
|
||||
objectUrl = URL.createObjectURL(image.blob);
|
||||
const deliveryId = lastImage.current?.hash === hash ? lastImage.current.deliveryId : crypto.randomUUID();
|
||||
lastImage.current = { hash, deliveryId };
|
||||
setCard({ ...image, key, url: objectUrl, deliveryId });
|
||||
} catch (e) { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : "Could not create your card."); }
|
||||
}, 200);
|
||||
return () => { clearTimeout(timer); controller.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
||||
}, [key, renderAttempt]);
|
||||
|
||||
async function send() {
|
||||
if (!ready || !connection?.connected || lock.current || sent) return;
|
||||
lock.current = true; setSending(true); setSendError("");
|
||||
const form = new FormData(); form.set("deliveryId", ready.deliveryId); form.set("image", ready.blob, "minabot-progress.png");
|
||||
try { const result = await habitRequest<Delivery>("/sharing/discord/send", { method: "POST", body: form }); setDelivery({ ...result, id: ready.deliveryId }); }
|
||||
catch (e) { setSendError(e instanceof Error ? e.message : "Could not send. Retrying this card will not post it twice."); }
|
||||
finally { lock.current = false; setSending(false); }
|
||||
}
|
||||
return (
|
||||
<section id="share-progress" className="ds-share-panel" aria-labelledby="share-progress-title">
|
||||
<header className="ds-share-heading">
|
||||
<div><p className="ds-eyebrow">A LITTLE PROGRESS, WORTH SHARING</p><h2 id="share-progress-title" ref={heading} tabIndex={-1} className="type-section">Your progress, in a picture.</h2></div>
|
||||
<Button variant="text" disabled={busy} onClick={onClose} aria-label="Close sharing"><X size={20} aria-hidden="true" /></Button>
|
||||
</header>
|
||||
<div className="ds-share-layout">
|
||||
<div className="ds-share-controls">
|
||||
<fieldset className="ds-share-fieldset" disabled={busy}>
|
||||
<legend className="type-ui-heading">Choose your habits</legend>
|
||||
<p className="ds-muted type-small">Up to six per card. Task names are never included.</p>
|
||||
<div className="ds-share-habits">{today.habits.map(habit => <Checkbox key={habit.habitId} label={habit.name ?? "Habit"} checked={ids.includes(habit.habitId)} disabled={!ids.includes(habit.habitId) && ids.length >= 6} onChange={e => setIds(values => e.target.checked ? [...values, habit.habitId] : values.filter(id => id !== habit.habitId))} />)}</div>
|
||||
<Field label="Date range">{id => <select id={id} value={range} onChange={e => { const value = e.target.value; setRange(value); if (value !== "custom") { setFrom(addDays(today.date, 1 - Number(value))); setTo(today.date); } }}><option value="7">Past 7 days</option><option value="30">Past 30 days</option><option value="365">Past year</option><option value="custom">Custom dates</option></select>}</Field>
|
||||
{range === "custom" && <div className="ds-share-dates"><Field label="From">{id => <input id={id} type="date" value={from} max={to || today.date} min="1970-01-01" onChange={e => setFrom(e.target.value)} />}</Field><Field label="To">{id => <input id={id} type="date" value={to} min={from} max={today.date} onChange={e => setTo(e.target.value)} />}</Field></div>}
|
||||
</fieldset>
|
||||
<fieldset className="ds-share-fieldset" disabled={busy}><legend className="type-ui-heading">Show on the card</legend>
|
||||
{([ ["name", "Display name"], ["avatar", "Discord avatar"], ["habitNames", "Habit names"], ["timezone", "Timezone"] ] as const).map(([key, label]) => <Checkbox key={key} label={label} checked={privacy[key]} onChange={e => setPrivacy(value => ({ ...value, [key]: e.target.checked }))} />)}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="ds-share-preview-column">
|
||||
<div className="ds-share-preview" aria-busy={!ready && !error}>
|
||||
{error ? <div role="alert"><p>{error}</p><Button variant="secondary" onClick={() => setRenderAttempt(n => n + 1)}>Retry preview</Button></div> : ready ? <img src={ready.url} alt={ready.alt} /> : <p role="status">Creating your card…</p>}
|
||||
</div>
|
||||
<p className="type-small ds-muted">This exact image will be downloaded or sent. Days off are excluded from the completion rate.</p>
|
||||
{ready?.avatarMissing && <p role="status" className="type-small ds-muted">Your avatar couldn’t load. The card uses a neutral profile icon.</p>}
|
||||
<div className="ds-share-actions">
|
||||
{ready ? <a className="ds-button ds-button--secondary" href={ready.url} download={`minabot-progress-${from}-${to}.png`}><Download size={17} aria-hidden="true" />Download PNG</a> : <Button variant="secondary" disabled>Download PNG</Button>}
|
||||
<Button disabled={!ready || !connection?.connected || busy || !!sent} onClick={() => void send()}><Send size={17} aria-hidden="true" />{sending ? "Sending…" : sent?.status === "sent" ? "Sent to Discord" : sent ? "Check Discord" : "Send to Discord"}</Button>
|
||||
</div>
|
||||
{connectionError ? <div role="alert" className="ds-share-feedback"><p>{connectionError}</p><Button variant="text" disabled={busy} onClick={() => setConnectionAttempt(n => n + 1)}>Retry Discord</Button></div> : connection?.connected ? <p className="type-small ds-muted">The bot will post this card to <a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a>.</p> : <p className="type-small ds-muted" role="status">{connection?.message ?? "Loading the Discord sharing channel…"}</p>}
|
||||
{sendError && <p role="alert">{sendError}</p>}
|
||||
{sent && <p role="status">{sent.status === "sent" ? <>Your card was sent. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord ↗</a>}</> : <>Discord didn’t confirm delivery. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"} before creating another card; this attempt won’t be resent.</>}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type Reac
|
||||
import { CalendarLegend } from "./CalendarLegend";
|
||||
import { Field } from "./Field";
|
||||
import { Button } from "./primitives";
|
||||
import { CalendarDays, Keyboard } from "lucide-react";
|
||||
import {
|
||||
describeDay,
|
||||
calendarTimeline,
|
||||
@@ -87,7 +88,7 @@ export function CalendarHeatmap({
|
||||
<div
|
||||
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
|
||||
data-months={months}
|
||||
style={{ "--calendar-weeks": timeline.weeks } as CSSProperties}
|
||||
style={{ "--calendar-weeks": timeline.weeks, "--calendar-color": color } as CSSProperties}
|
||||
>
|
||||
<div className="ds-calendar-range-toolbar">
|
||||
<span className="ds-calendar-range-label" aria-live="polite">
|
||||
@@ -273,20 +274,29 @@ export function CalendarHeatmap({
|
||||
(compact ? "Demo history" : "Illustrative history")}
|
||||
</span>
|
||||
</span>
|
||||
<span id={instructionsId} className="ds-muted">
|
||||
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
|
||||
</span>
|
||||
<details className="ds-calendar-help">
|
||||
<summary><Keyboard size={16} aria-hidden="true" /> Keyboard shortcuts</summary>
|
||||
<p id={instructionsId}>
|
||||
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<div id={inspectorId} className="ds-date-inspector" aria-live="polite">
|
||||
<span>
|
||||
{new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
</span>
|
||||
<span>{describeDay(selected, unit)}</span>
|
||||
<div className="ds-date-inspector-heading">
|
||||
<CalendarDays size={22} aria-hidden="true" />
|
||||
<div>
|
||||
<span className="ds-eyebrow">SELECTED DAY</span>
|
||||
<time dateTime={selected.date}>
|
||||
{new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
</time>
|
||||
<span className="ds-date-inspector-status">{describeDay(selected, unit)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useId, type ReactNode } from "react";
|
||||
import { useId, type CSSProperties, type ReactNode } from "react";
|
||||
import { ChevronDown, ListChecks } from "lucide-react";
|
||||
import { CalendarHeatmap } from "./CalendarHeatmap";
|
||||
import { demoCalendar } from "./calendar-model";
|
||||
|
||||
@@ -11,11 +12,15 @@ export function HabitChart({
|
||||
unit,
|
||||
color,
|
||||
children,
|
||||
headingActions,
|
||||
editor,
|
||||
tasks,
|
||||
tasksLabel = "Tasks for today",
|
||||
calendar,
|
||||
schedule = "Every day",
|
||||
due = true,
|
||||
id,
|
||||
headingLevel = 4,
|
||||
}: {
|
||||
name: string;
|
||||
method: string;
|
||||
@@ -24,27 +29,36 @@ export function HabitChart({
|
||||
unit: string;
|
||||
color: string;
|
||||
children?: ReactNode;
|
||||
headingActions?: ReactNode;
|
||||
editor?: ReactNode;
|
||||
tasks?: ReactNode;
|
||||
tasksLabel?: string;
|
||||
calendar?: ReactNode;
|
||||
schedule?: string;
|
||||
due?: boolean;
|
||||
id?: string;
|
||||
headingLevel?: 3 | 4;
|
||||
}) {
|
||||
const headingId = useId();
|
||||
const Heading = `h${headingLevel}` as const;
|
||||
return (
|
||||
<section className="ds-habit-chart" id={id} aria-labelledby={headingId}>
|
||||
<section className="ds-habit-chart" id={id} aria-labelledby={headingId} style={{ "--habit-color": color } as CSSProperties}>
|
||||
<header className="ds-habit-chart-heading">
|
||||
<div>
|
||||
<h4 id={headingId}>
|
||||
<span style={{ backgroundColor: color }} aria-hidden="true" />
|
||||
{name}
|
||||
</h4>
|
||||
<div className="ds-habit-title-row">
|
||||
<Heading id={headingId}>
|
||||
<span style={{ backgroundColor: color }} aria-hidden="true" />
|
||||
{name}
|
||||
</Heading>
|
||||
{headingActions}
|
||||
</div>
|
||||
<p>
|
||||
{method} · {schedule}
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
</header>
|
||||
{editor}
|
||||
<p className="ds-habit-chart-progress" aria-live="polite">
|
||||
<span>
|
||||
{due
|
||||
@@ -71,10 +85,13 @@ export function HabitChart({
|
||||
{tasks && (
|
||||
<details className="ds-task-accordion">
|
||||
<summary>
|
||||
Tasks for today{" "}
|
||||
<span>
|
||||
{value} / {target}
|
||||
<ListChecks size={20} aria-hidden="true" />
|
||||
<span className="ds-task-accordion-title">{tasksLabel}</span>
|
||||
<span className="ds-task-accordion-progress">
|
||||
<progress aria-label={`${name} tasks completed`} value={Math.max(0, Math.min(value, target))} max={Math.max(1, target)} />
|
||||
<span>{value} / {target}</span>
|
||||
</span>
|
||||
<ChevronDown className="ds-task-accordion-chevron" size={18} aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="ds-task-accordion-content">{tasks}</div>
|
||||
</details>
|
||||
|
||||
21
src/components/design-system/ItemActions.tsx
Normal file
21
src/components/design-system/ItemActions.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "./primitives";
|
||||
|
||||
export function ItemActions({ name, kind, disabled, onEdit, onDelete }: {
|
||||
name: string;
|
||||
kind: "habit" | "task";
|
||||
disabled?: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span className="ds-item-actions" role="group" aria-label={`${name} actions`}>
|
||||
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onEdit} aria-label={`Edit ${kind} ${name}`} title={`Edit ${kind}`}>
|
||||
<Pencil size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onDelete} aria-label={`Delete ${kind} ${name}`} title={`Delete ${kind}`}>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
50
src/components/design-system/PageLayout.tsx
Normal file
50
src/components/design-system/PageLayout.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Button } from "./primitives";
|
||||
|
||||
/** The existing design-system page shell, shared without page-specific styling. */
|
||||
export function PageLayout({
|
||||
children,
|
||||
header,
|
||||
mainId = "main",
|
||||
wordmarkTo = "/",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
header?: ReactNode;
|
||||
mainId?: string;
|
||||
wordmarkTo?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="ds-root" id="top">
|
||||
<a
|
||||
className="ds-skip-link"
|
||||
href={`#${mainId}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
document.getElementById(mainId)?.focus();
|
||||
}}
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<header className="ds-header">
|
||||
<Link className="ds-wordmark" to={wordmarkTo}>
|
||||
minabot<span aria-hidden="true">.</span>
|
||||
</Link>
|
||||
{header}
|
||||
</header>
|
||||
<main id={mainId} className="ds-main" tabIndex={-1}>
|
||||
{children}
|
||||
<footer className="ds-footer">
|
||||
<span className="ds-wordmark">minabot.</span>
|
||||
<span>A little, every day.</span>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "instant" })}
|
||||
>
|
||||
Back to top ↑
|
||||
</Button>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/design-system/WelcomePanel.tsx
Normal file
73
src/components/design-system/WelcomePanel.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Globe2 } from "lucide-react";
|
||||
import { formatTrackingDate } from "../../lib/dashboard";
|
||||
|
||||
export function WelcomePanel({
|
||||
name,
|
||||
avatarUrl,
|
||||
date,
|
||||
timezone,
|
||||
children,
|
||||
}: {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
date?: string;
|
||||
timezone: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [failedAvatar, setFailedAvatar] = useState<string | null>(null);
|
||||
// The API date already belongs to the account's timezone. Keep its date parts
|
||||
// intact instead of interpreting them in the browser's timezone.
|
||||
const day = date ? new Date(`${date}T12:00:00Z`) : null;
|
||||
|
||||
return (
|
||||
<section className="ds-welcome-panel" aria-labelledby="dashboard-title">
|
||||
<div className="ds-welcome-content">
|
||||
<div className="ds-welcome-identity">
|
||||
<div className="ds-avatar">
|
||||
{avatarUrl && failedAvatar !== avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${name}’s Discord avatar`}
|
||||
width={64}
|
||||
height={64}
|
||||
onError={() => setFailedAvatar(avatarUrl)}
|
||||
/>
|
||||
) : (
|
||||
<span aria-label={`${name}’s avatar`} role="img">
|
||||
{Array.from(name.trim())[0]?.toUpperCase() || "?"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="ds-eyebrow">YOUR DAILY CHECK-IN</p>
|
||||
</div>
|
||||
<h1 id="dashboard-title" className="type-display">
|
||||
Welcome back, <em>{name}.</em>
|
||||
</h1>
|
||||
<p className="ds-welcome-subtitle">Make time for today.</p>
|
||||
{children}
|
||||
</div>
|
||||
<div className="ds-welcome-calendar">
|
||||
{day && date ? (
|
||||
<time className="ds-date-sheet" dateTime={date} aria-label={formatTrackingDate(date)}>
|
||||
<span className="ds-date-month">
|
||||
{day.toLocaleDateString("en", { month: "long", timeZone: "UTC" })}
|
||||
<span className="ds-muted">{day.getUTCFullYear()}</span>
|
||||
</span>
|
||||
<span className="ds-date-number type-display">{day.getUTCDate()}</span>
|
||||
<span className="ds-date-weekday">
|
||||
{day.toLocaleDateString("en", { weekday: "long", timeZone: "UTC" })}
|
||||
</span>
|
||||
<span className="ds-date-caption ds-eyebrow">TODAY, AT YOUR OWN PACE</span>
|
||||
</time>
|
||||
) : (
|
||||
<p className="ds-muted" role="status">Your day is loading…</p>
|
||||
)}
|
||||
<p className="ds-welcome-timezone">
|
||||
<Globe2 size={16} aria-hidden="true" />
|
||||
<span>{timezone}</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
AnchorHTMLAttributes,
|
||||
@@ -46,7 +47,7 @@ export function SectionHeading({
|
||||
return (
|
||||
<header className="ds-section-heading">
|
||||
<span className="ds-eyebrow">{number}</span>
|
||||
<h2 id={id}>{title}</h2>
|
||||
<h2 id={id} tabIndex={-1}>{title}</h2>
|
||||
{children && <p>{children}</p>}
|
||||
</header>
|
||||
);
|
||||
@@ -54,13 +55,23 @@ export function SectionHeading({
|
||||
|
||||
export function Checkbox({
|
||||
label,
|
||||
description,
|
||||
className = "",
|
||||
...props
|
||||
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string }) {
|
||||
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string; description?: string }) {
|
||||
const descriptionId = useId();
|
||||
return (
|
||||
<label className={`ds-checkbox ${className}`}>
|
||||
<input type="checkbox" {...props} />
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={description ? label : undefined}
|
||||
{...props}
|
||||
aria-describedby={[props["aria-describedby"], description ? descriptionId : undefined].filter(Boolean).join(" ") || undefined}
|
||||
/>
|
||||
<span className="ds-checkbox-copy">
|
||||
<span className="ds-checkbox-label">{label}</span>
|
||||
{description && <span id={descriptionId} className="ds-checkbox-description">{description}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user