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 accounts
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>
);
}

View File

@@ -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>
);
}

View 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 todays 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>
);
}

View 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>
);
}

View 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 habits schedule match. Habit: {scheduleLabel(habitSchedule)}.</p>
{!creating && <p className="ds-footnote">Changes start today. Earlier check-ins keep their original schedule.</p>}
</InlineItemForm>
);
}

View 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 couldnt 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 didnt confirm delivery. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"} before creating another card; this attempt wont be resent.</>}</p>}
</div>
</div>
</section>
);
}

View File

@@ -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>
);

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}