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

@@ -14,6 +14,7 @@ import { MemoryRouter, useLocation, useNavigate } from "react-router";
import type { Root } from "react-dom/client";
import { App } from "./App";
import { CalendarHeatmap } from "./components/design-system/CalendarHeatmap";
import { fixture } from "./habits/test-fixture";
// Keep the design system independent of authentication and the local database.
const dom = new Window({ url: "http://localhost:3000/" });
@@ -22,6 +23,7 @@ let createRoot: typeof import("react-dom/client").createRoot;
let root: Root;
let container: HTMLDivElement;
let fetchMock: ReturnType<typeof spyOn<typeof globalThis, "fetch">>;
let apiFixture: ReturnType<typeof fixture> | undefined;
beforeAll(async () => {
for (const key of [
@@ -64,6 +66,8 @@ afterEach(async () => {
await act(async () => root.unmount());
container.remove();
fetchMock.mockRestore();
apiFixture?.close();
apiFixture = undefined;
});
afterAll(() => {
@@ -85,7 +89,7 @@ function LocationProbe() {
</>;
}
async function render(path = "/") {
async function render(path = "/design-system") {
await act(async () =>
root.render(
<MemoryRouter initialEntries={[path]}>
@@ -96,6 +100,317 @@ async function render(path = "/") {
);
}
function connectAccount() {
apiFixture = fixture();
const f = apiFixture;
fetchMock.mockImplementation((async (input, init) => {
const headers = new Headers(init?.headers);
headers.set("Cookie", `minabot_session=${"a".repeat(43)}`);
headers.set("Origin", f.origin);
return f.app.request(new Request(new URL(String(input), f.origin), { ...init, headers }));
}) as typeof fetch);
return f;
}
const buttonNamed = (name: string) => [...container.querySelectorAll<HTMLButtonElement>("button")]
.find(button => button.textContent === name || button.getAttribute("aria-label") === name)!;
describe("homepage", () => {
test("signed-out visitors can try progress without calling private habit APIs", async () => {
await render("/");
expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm.");
expect(fetchMock.mock.calls.map(call => call[0])).toEqual(["/api/me"]);
await act(async () => buttonNamed("Increase glasses of water").click());
expect(container.querySelector(".ds-counter output")?.textContent).toBe("4 / 8");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("OAuth errors are retained at the homepage and unknown routes go home", async () => {
await render("/?auth_error=denied");
expect(container.querySelector('[role="alert"]')?.textContent).toContain("cancelled");
// A new router is needed when changing initialEntries after mount.
await act(async () => root.unmount());
root = createRoot(container);
await render("/missing");
expect(container.querySelector('[data-testid="pathname"]')?.textContent).toBe("/");
expect(container.querySelector("#welcome-title")).not.toBeNull();
});
test("does not flash the signed-out page while the account is loading", async () => {
let resolve!: (response: Response) => void;
fetchMock.mockImplementation(Object.assign(() => new Promise<Response>(done => { resolve = done; }), { preconnect: fetch.preconnect }));
await render("/");
expect(container.textContent).toContain("Getting things ready");
expect(container.querySelector("#welcome-title")).toBeNull();
await act(async () => resolve(new Response(null, { status: 401 })));
expect(container.querySelector("#welcome-title")).not.toBeNull();
});
test("account failures offer retry without pretending the user is signed out", async () => {
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }));
await render("/");
expect(container.textContent).toContain("Account unavailable");
expect(container.querySelector("#welcome-title")).toBeNull();
await act(async () => buttonNamed("Try again").click());
expect(container.querySelector("#welcome-title")).not.toBeNull();
});
test("signed-in users create a habit from the empty state and sign out", async () => {
const f = connectAccount();
await render("/");
expect(container.textContent).toContain("Welcome back, alice.");
expect(container.textContent).toContain("Start with one habit.");
await act(async () => buttonNamed("Add a habit +").click());
const input = container.querySelector<HTMLInputElement>("form input")!;
expect(document.activeElement).toBe(input);
await act(async () => {
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "Read a little");
input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
});
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
expect((await f.json("/habits")).habits[0].name).toBe("Read a little");
expect(container.querySelector("form")).toBeNull();
expect(container.textContent).toContain("Read a little created.");
expect(container.querySelector(".ds-habit-chart")).not.toBeNull();
await act(async () => buttonNamed("Sign out").click());
expect(container.querySelector("#welcome-title")).not.toBeNull();
expect((await f.request("/me")).status).toBe(401);
});
test("count, manual, and task progress persist and update today's completion", async () => {
const f = connectAccount();
const water = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201);
await f.json(`/habits/${water.id}/days/2026-09-04/progress`, "PUT", { count: 7 });
await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await f.json("/habits", "POST", { name: "Evening", method: "tasks", tasks: [{ name: "Stretch" }] }, 201);
await f.json("/habits", "POST", { name: "Sunday walk", method: "manual", schedule: { type: "weekdays", days: [0] } }, 201);
await render("/");
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("0 / 3");
await act(async () => buttonNamed("Increase Water").click());
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3");
expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)?.textContent).toContain("8 of 8 glasses");
const checkbox = (name: string) => [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => (input.getAttribute("aria-label") || input.closest("label")?.textContent) === name)!;
expect(checkbox("Sunday walk done").disabled).toBe(true);
await act(async () => checkbox("Reading done").click());
await act(async () => checkbox("Stretch").click());
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("3 / 3");
expect((await f.json("/today")).completed).toBe(3);
await act(async () => buttonNamed("Decrease Water").click());
expect((await f.json("/today")).completed).toBe(2);
});
test("inline habit edits persist configuration and delete archives without losing earlier history", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
const habit = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201);
await f.json(`/habits/${habit.id}/days/2026-09-03/progress`, "PUT", { count: 5 });
f.setTime("2026-09-04T12:00:00Z");
await render("/");
await act(async () => buttonNamed("Edit habit Water").click());
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit habit Water"]')!;
expect(document.activeElement).toBe(form.querySelector("input"));
await act(async () => {
const name = form.querySelector<HTMLInputElement>("input")!;
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(name, "Daily water");
name.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
const target = form.querySelector<HTMLInputElement>('input[type="number"]')!;
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(target, "10");
target.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
});
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
const saved = await f.json(`/habits/${habit.id}`);
expect(saved.name).toBe("Daily water");
expect(saved.target).toBe(10);
expect(container.querySelector("form")).toBeNull();
expect(container.querySelector("h3")?.textContent).toContain("Daily water");
await act(async () => buttonNamed("Delete habit Daily water").click());
expect((await f.json("/habits")).habits).toHaveLength(1);
await act(async () => buttonNamed("Cancel").click());
expect((await f.json("/habits")).habits).toHaveLength(1);
await act(async () => buttonNamed("Delete habit Daily water").click());
await act(async () => buttonNamed("Delete habit").click());
expect((await f.json("/habits")).habits).toHaveLength(0);
expect(container.querySelector(".ds-habit-chart")).toBeNull();
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
expect(previous.name).toBe("Water");
expect(previous.target).toBe(8);
expect(previous.value).toBe(5);
});
test("inline task rename keeps its completion and schedule; deletion updates totals and preserves yesterday", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
const habit = await f.json("/habits", "POST", { name: "Evening", method: "tasks", tasks: [{ name: "Stretch", schedule: { type: "weekdays", days: [4, 5] } }, { name: "Clear desk" }] }, 201);
const task = habit.tasks[0];
await f.json(`/habits/${habit.id}/days/2026-09-03/tasks/${task.id}`, "PUT", { done: true });
f.setTime("2026-09-04T12:00:00Z");
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${task.id}`, "PUT", { done: true });
await render("/");
await act(async () => buttonNamed("Edit task Stretch").click());
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Stretch"]')!;
await act(async () => {
const input = form.querySelector("input")!;
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "Stretch gently");
input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
});
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
const saved = await f.json(`/habits/${habit.id}/tasks/${task.id}`);
expect(saved.name).toBe("Stretch gently");
expect(saved.schedule).toEqual({ type: "weekdays", days: [4, 5] });
expect((await f.json("/today")).habits[0].value).toBe(1);
await act(async () => buttonNamed("Delete task Stretch gently").click());
await act(async () => buttonNamed("Delete task").click());
const today = (await f.json("/today")).habits[0];
expect(today.target).toBe(1);
expect(today.value).toBe(0);
expect(container.textContent).not.toContain("Stretch gently");
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
expect(previous.tasks.find((item: { taskId: string }) => item.taskId === task.id).done).toBe(true);
expect(previous.tasks.find((item: { taskId: string }) => item.taskId === task.id).name).toBe("Stretch");
});
test("task edits retain failed drafts for retry and remain available on days off", async () => {
const f = connectAccount();
const habit = await f.json("/habits", "POST", { name: "Sunday", method: "tasks", schedule: { type: "weekdays", days: [0] }, tasks: [{ name: "Walk" }] }, 201);
await render("/");
expect(container.textContent).toContain("Not scheduled today");
expect(container.querySelector<HTMLInputElement>('.ds-editable-task input[type="checkbox"]')?.disabled).toBe(true);
await act(async () => buttonNamed("Edit task Walk").click());
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
await act(async () => {
const input = form.querySelector("input")!;
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "Long walk");
input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
});
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Please try again" }), { status: 500 }));
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
expect(form.querySelector("input")?.value).toBe("Long walk");
expect(form.querySelector('[role="alert"]')?.textContent).toBe("Please try again");
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks[0].name).toBe("Walk");
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks[0].name).toBe("Long walk");
expect(container.querySelector("form")).toBeNull();
});
test("task recurrence edits round-trip every schedule type and preserve earlier check-ins", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
const habit = await f.json("/habits", "POST", { name: "Routine", method: "tasks", tasks: [{ name: "Walk" }] }, 201);
const id = habit.tasks[0].id;
await f.json(`/habits/${habit.id}/days/2026-09-03/tasks/${id}`, "PUT", { done: true });
f.setTime("2026-09-04T12:00:00Z");
await render("/");
const inputValue = async (input: HTMLInputElement, value: string) => act(async () => {
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, value);
input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
});
const selectValue = async (select: HTMLSelectElement, value: string) => act(async () => {
select.value = value;
select.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event);
});
for (const schedule of [
{ type: "interval", every: 3, anchor: "2026-09-05" },
{ type: "weekly", every: 2, anchor: "2026-09-04", weekday: 5 },
{ type: "weekdays", days: [1, 2, 3, 4, 5] },
{ type: "daily" },
] as const) {
await act(async () => buttonNamed("Edit task Walk").click());
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
await selectValue(form.querySelector("select")!, schedule.type);
if (schedule.type === "interval" || schedule.type === "weekly") {
await inputValue(form.querySelector('input[type="number"]')!, String(schedule.every));
await inputValue(form.querySelector('input[type="date"]')!, schedule.anchor);
}
if (schedule.type === "weekly") await selectValue(form.querySelectorAll("select")[1]!, String(schedule.weekday));
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
expect((await f.json(`/habits/${habit.id}/tasks/${id}`)).schedule).toEqual(schedule);
expect(container.querySelector("form")).toBeNull();
await act(async () => buttonNamed("Edit task Walk").click());
const reopened = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
expect(reopened.querySelector("select")?.value).toBe(schedule.type);
if (schedule.type === "interval" || schedule.type === "weekly") {
expect(reopened.querySelector<HTMLInputElement>('input[type="number"]')?.value).toBe(String(schedule.every));
expect(reopened.querySelector<HTMLInputElement>('input[type="date"]')?.value).toBe(schedule.anchor);
}
await act(async () => buttonNamed("Cancel").click());
}
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
expect(previous.tasks[0].done).toBe(true);
expect(previous.requirements.tasks[0].schedule).toEqual({ type: "daily" });
});
test("habit method and carryover controls save, and task conversion supports adding recurring tasks", async () => {
const f = connectAccount();
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await render("/");
const selectMethod = async (method: string) => {
const select = container.querySelector<HTMLSelectElement>('form select')!;
await act(async () => {
select.value = method;
select.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event);
});
};
const submit = async () => act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
await act(async () => buttonNamed("Edit habit Reading").click());
await selectMethod("count");
await act(async () => container.querySelector<HTMLInputElement>('form input[type="checkbox"]')!.click());
await submit();
expect((await f.json(`/habits/${habit.id}`)).method).toBe("count");
expect((await f.json(`/habits/${habit.id}`)).carryPartialProgress).toBe(true);
await act(async () => buttonNamed("Edit habit Reading").click());
expect(container.querySelector<HTMLInputElement>('form input[type="checkbox"]')?.checked).toBe(true);
await selectMethod("tasks");
await submit();
expect((await f.json(`/habits/${habit.id}`)).method).toBe("tasks");
await act(async () => buttonNamed("Add task +").click());
const form = container.querySelector<HTMLFormElement>('form[aria-label="Add task"]')!;
await act(async () => {
const input = form.querySelector("input")!;
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "Read chapter");
input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
});
await submit();
const task = (await f.json(`/habits/${habit.id}/tasks`)).tasks[0];
expect(task.name).toBe("Read chapter");
expect(task.schedule).toEqual({ type: "daily" });
expect(buttonNamed("Edit task Read chapter")).toBeDefined();
expect((await f.json("/today")).habits[0].target).toBe(1);
});
test("a successful delete is not retried when the subsequent refresh fails", async () => {
const f = connectAccount();
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await render("/");
await act(async () => buttonNamed("Delete habit Reading").click());
fetchMock.mockImplementationOnce((async (input, init) => {
return f.request(String(input).replace("/api", ""), init?.method);
}) as typeof fetch);
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }));
await act(async () => buttonNamed("Delete habit").click());
expect(container.querySelector("form")).toBeNull();
expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh");
expect(buttonNamed("Delete habit Reading").disabled).toBe(true);
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
await act(async () => buttonNamed("Try again").click());
expect(container.querySelector(".ds-habit-chart")).toBeNull();
expect(fetchMock.mock.calls.filter(call => call[1]?.method === "DELETE")).toHaveLength(1);
});
test("failed saves retain recorded progress and allow another attempt", async () => {
const f = connectAccount();
await f.json("/habits", "POST", { name: "Water", method: "count", target: 8 }, 201);
await render("/");
fetchMock.mockResolvedValueOnce(Response.json({ error: "Save unavailable" }, { status: 500 }));
await act(async () => buttonNamed("Increase Water").click());
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save unavailable");
expect(container.querySelector(".ds-counter output")?.textContent).toBe("0 / 8");
expect(buttonNamed("Increase Water").disabled).toBe(false);
await act(async () => buttonNamed("Increase Water").click());
expect(container.querySelector(".ds-counter output")?.textContent).toBe("1 / 8");
expect((await f.json("/today")).habits[0].value).toBe(1);
});
});
test("unscheduled dates stay inspectable without a progress-square fill", async () => {
await act(async () =>
root.render(
@@ -163,8 +478,8 @@ test("unscheduled dates stay inspectable without a progress-square fill", async
).toBe("true");
});
describe("design-system-only routing", () => {
for (const path of ["/design-system", "/design-system/", "/", "/about", "/settings", "/missing", "/?auth_error=denied"]) {
describe("independent design-system routing", () => {
for (const path of ["/design-system", "/design-system/"]) {
test(`renders only the public design system at ${path}`, async () => {
await render(path);
expect(container.querySelector("#ds-title")).not.toBeNull();

View File

@@ -1,11 +1,21 @@
import { Navigate, Route, Routes } from "react-router";
import { DesignSystem } from "./pages/DesignSystem";
import { Home } from "./pages/Home";
import { AuthProvider } from "./components/AuthProvider";
export function App() {
return (
<Routes>
<Route path="/design-system" element={<DesignSystem />} />
<Route path="*" element={<Navigate to="/design-system" replace />} />
<Route
path="/"
element={
<AuthProvider>
<Home />
</AuthProvider>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@@ -5,8 +5,10 @@ import { sql } from "drizzle-orm";
import { createAuth, type AppDatabase, type AuthEnv } from "./auth";
import type { AuthConfig } from "./auth/config";
import type { FetchDiscord } from "./auth/discord";
import { createSharingRoutes, type DiscordFetch } from "./sharing/routes";
import type { DiscordSharingConfig } from "./sharing/config";
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number) {
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }) {
const app = new Hono<AuthEnv>();
const auth = createAuth(db, config, request, now);
app.get("/api/health", c => {
@@ -16,6 +18,7 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
app.route("/api/auth", auth.routes);
app.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
app.route("/api/sharing", createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest));
app.notFound(c => c.json({ error: "Not found" }, 404));
app.onError((_error, c) => {
c.header("Cache-Control", "no-store");

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

View File

@@ -21,6 +21,15 @@ export const sessions = sqliteTable("sessions", {
index("sessions_expires_at_idx").on(table.expiresAt),
]);
export const discordDeliveries = sqliteTable("discord_deliveries", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
imageHash: text("image_hash").notNull(),
status: text("status").notNull(),
messageUrl: text("message_url"),
createdAt: integer("created_at").notNull(),
});
// Revisions are append-only, including multiple edits on the same local date.
export const habits = sqliteTable('habits', {
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id),

View File

@@ -6,7 +6,7 @@ import { addDays, endOfDay, localDate, scheduled, schedulesOverlap, shade } from
import { calendarSettingsSchema, habitInput, type HabitConfig, type CalendarSettings } from './contracts';
export class ApiError extends Error {
constructor(public status: 400 | 404 | 409 | 422, message: string) { super(message); }
constructor(public status: 400 | 404 | 409 | 422 | 429 | 502, message: string) { super(message); }
}
const missing = () => new ApiError(404, 'Not found');
export type Habit = typeof habits.$inferSelect;

View File

@@ -1,9 +1,10 @@
import { db } from "./db";
import { createApi } from "./api";
import { readAuthConfig } from "./auth/config";
import { readDiscordSharingConfig } from "./sharing/config";
import index from "./index.html";
const app = createApi(db, readAuthConfig());
const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig());
const server = Bun.serve({
hostname: "127.0.0.1",

138
src/lib/progress-card.ts Normal file
View File

@@ -0,0 +1,138 @@
import type { ShareData } from "../sharing/contracts";
import type { PublicUser } from "../shared/user";
import { dayNumber } from "../habits/calendar";
export type CardPrivacy = { name: boolean; avatar: boolean; habitNames: boolean; timezone: boolean };
export const cardDate = (date: string) => new Date(`${date}T12:00:00Z`).toLocaleDateString("en", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
export const cardStats = (data: ShareData) => {
const completed = data.habits.reduce((n, h) => n + h.completed, 0);
const scheduled = data.habits.reduce((n, h) => n + h.scheduled, 0);
return { completed, scheduled, percent: scheduled ? Math.round(completed / scheduled * 100) : null };
};
async function loadAvatar(url: string): Promise<HTMLImageElement | null> {
return new Promise(resolve => {
const img = new Image();
const finish = (value: HTMLImageElement | null) => { clearTimeout(timer); img.onload = null; img.onerror = null; resolve(value); };
const timer = setTimeout(() => finish(null), 5000);
img.crossOrigin = "anonymous";
img.onload = () => finish(img);
img.onerror = () => finish(null);
img.src = url;
});
}
/** Render once: this exact PNG is previewed, downloaded, and sent to Discord. */
export async function renderProgressCard(data: ShareData, user: PublicUser, privacy: CardPrivacy): Promise<{ blob: Blob; alt: string; avatarMissing: boolean }> {
await document.fonts.load('76px "Instrument Serif"');
const [avatar, botAvatar] = await Promise.all([
privacy.avatar && user.avatarUrl ? loadAvatar(user.avatarUrl) : null,
data.botAvatarUrl ? loadAvatar(data.botAvatarUrl) : null,
]);
const canvas = document.createElement("canvas");
const longRange = data.habits[0]!.days.length > 42;
const titleGraphGap = 12;
const rowHeight = (longRange ? 280 : 250) + titleGraphGap;
canvas.width = 1200; canvas.height = 380 + data.habits.length * rowHeight + 76;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Your browser could not create the image. Try another browser.");
const left = 56;
const right = 1144;
const chartLeft = 80;
const chartWidth = right - chartLeft;
const legendColumns = [chartLeft, chartLeft + 266, chartLeft + 532, chartLeft + 798] as const;
const sans = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
const text = (value: string, x: number, y: number, size = 22, color = "#666", serif = false, maxWidth = 1088, align: CanvasTextAlign = "left") => {
ctx.font = `${size}px ${serif ? '"Instrument Serif", Georgia, serif' : sans}`;
ctx.fillStyle = color;
ctx.textAlign = align;
let fitted = value;
if (ctx.measureText(fitted).width > maxWidth) {
const chars = Array.from(value);
while (chars.length && ctx.measureText(`${chars.join("")}`).width > maxWidth) chars.pop();
fitted = `${chars.join("")}`;
}
ctx.fillText(fitted, x, y);
};
const rect = (x: number, y: number, w: number, h: number, color: string) => { ctx.fillStyle = color; ctx.fillRect(x, y, w, h); };
rect(0, 0, 1200, canvas.height, "#fff");
rect(0, 0, 1200, 8, data.habits[0]?.color ?? "#111");
let profileX = 56;
if (privacy.avatar) {
ctx.save(); ctx.beginPath(); ctx.arc(88, 84, 32, 0, Math.PI * 2); ctx.clip();
rect(56, 52, 64, 64, "#f1f1f1");
if (avatar) ctx.drawImage(avatar, 56, 52, 64, 64);
else { ctx.fillStyle = "#888"; ctx.beginPath(); ctx.arc(88, 77, 10, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(88, 109, 22, 0, Math.PI * 2); ctx.fill(); }
ctx.restore(); profileX = 138;
}
text(privacy.name ? user.displayName || user.username : "A little, every day.", profileX, 94, 26, "#111", false, 700);
if (botAvatar) {
ctx.font = '34px "Instrument Serif", Georgia, serif';
const avatarX = right - ctx.measureText("minabot.").width - 12 - 40;
ctx.save();
ctx.beginPath();
ctx.roundRect(avatarX, 64, 40, 40, 4);
ctx.clip();
ctx.drawImage(botAvatar, avatarX, 64, 40, 40);
ctx.restore();
}
text("minabot.", right, 94, 34, "#111", true, 112, "right");
text("Small steps, adding up.", left, 213, 76, "#111", true);
text(`${cardDate(data.from)}${cardDate(data.to)}`, left, 260, 23);
const stats = cardStats(data);
text(`${stats.completed} / ${stats.scheduled} scheduled check-ins complete`, left, 317, 24, "#111", false, 800);
text(stats.percent === null ? "No days scheduled" : `${stats.percent}% complete`, right, 317, 24, "#111", false, 240, "right");
data.habits.forEach((habit, index) => {
const top = 358 + index * rowHeight;
rect(left, top, right - left, 1, "#dedede");
rect(left, top + 27, 6, 24, habit.color);
text(privacy.habitNames ? habit.name : `Habit ${index + 1}`, chartLeft, top + 48, 24, "#111", false, 760);
text(`${habit.completed} / ${habit.scheduled} days`, right, top + 48, 21, "#666", false, 185, "right");
ctx.save();
ctx.translate(0, titleGraphGap);
if (habit.days.length <= 42) {
const step = (chartWidth + 5) / habit.days.length;
habit.days.forEach((day, i) => {
const x = chartLeft + i * step;
if (day.due) rect(x, top + 78, Math.max(8, step - 5), 62, day.color);
else { ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(x + (step - 5) / 2, top + 109, 3, 0, Math.PI * 2); ctx.fill(); }
if (habit.days.length <= 7 || i % 7 === 0) text(day.date.slice(8), x + (step - 5) / 2, top + 169, 17, "#666", false, step - 5, "center");
});
} else {
const offset = (new Date(`${data.from}T12:00:00Z`).getUTCDay() + 6) % 7;
const columns = Math.ceil((offset + habit.days.length) / 7);
const step = (chartWidth + 4) / columns;
const startX = chartLeft;
text("M", left, top + 95, 14); text("W", left, top + 135, 14); text("F", left, top + 175, 14);
let previousMonthX = -100;
habit.days.forEach((day, i) => {
const n = offset + i, x = startX + Math.floor(n / 7) * step, y = top + 82 + n % 7 * 20;
if ((i === 0 || day.date.endsWith("-01")) && x - previousMonthX > 45 && x < 1108) {
text(new Date(`${day.date}T12:00:00Z`).toLocaleDateString("en", { month: "short", timeZone: "UTC" }), x, top + 70, 14);
previousMonthX = x;
}
if (day.due) rect(x, y, step - 4, 16, day.color);
else { ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(x + (step - 4) / 2, y + 8, 2, 0, Math.PI * 2); ctx.fill(); }
});
}
const legendY = top + (longRange ? 241 : 209);
rect(legendColumns[0], legendY, 16, 16, habit.legend.empty);
text("No progress", legendColumns[0] + 26, legendY + 15, 18);
if (habit.legend.partial.length) {
habit.legend.partial.forEach((color, i) => rect(legendColumns[1] + i * 19, legendY, 16, 16, color));
text("Partial", legendColumns[1] + habit.legend.partial.length * 19 + 10, legendY + 15, 18);
}
rect(legendColumns[2], legendY, 16, 16, habit.legend.complete);
text("Complete", legendColumns[2] + 26, legendY + 15, 18);
ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(legendColumns[3] + 8, legendY + 8, 3, 0, Math.PI * 2); ctx.fill();
text("Not scheduled", legendColumns[3] + 26, legendY + 15, 18);
ctx.restore();
});
const footerY = canvas.height - 46;
text("Days off dont count against you.", left, footerY, 19);
text(privacy.timezone ? data.timezone : `${dayNumber(data.to) - dayNumber(data.from) + 1} days of small steps`, right, footerY, 19, "#666", false, 350, "right");
const blob = await new Promise<Blob>((resolve, reject) => canvas.toBlob(value => value ? resolve(value) : reject(new Error("Could not export the card.")), "image/png"));
const names = privacy.habitNames ? data.habits.map(h => h.name).join(", ") : `${data.habits.length} habits`;
return { blob, avatarMissing: privacy.avatar && !!user.avatarUrl && !avatar,
alt: `${privacy.name ? `${user.displayName || user.username}: ` : ""}${names}. ${cardDate(data.from)} to ${cardDate(data.to)}. ${stats.completed} of ${stats.scheduled} scheduled check-ins complete. Legend: empty squares mean no progress, intermediate shades mean partial progress, full color means complete, and dots mean not scheduled.${privacy.timezone ? ` ${data.timezone}.` : ""}` };
}

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router";
import { useNavigate } from "react-router";
import {
Button,
Checkbox,
@@ -14,6 +14,8 @@ import {
HABIT_COLORS,
} from "../components/design-system/calendar-model";
import { PageLayout } from "../components/design-system/PageLayout";
import { DesignSystemTabs } from "../components/design-system/DesignSystemTabs";
export function DesignSystem() {
@@ -23,18 +25,7 @@ export function DesignSystem() {
const [habitName, setHabitName] = useState("");
const [savedName, setSavedName] = useState("");
return (
<div className="ds-root" id="top">
<a className="ds-skip-link" href="#ds-main" onClick={(event) => {
event.preventDefault();
document.getElementById("ds-main")?.focus();
}}>Skip to content</a>
<header className="ds-header">
<Link className="ds-wordmark" to="/design-system">
minabot<span aria-hidden="true">.</span>
</Link>
<span className="ds-library-label">Interface library / 01</span>
</header>
<main id="ds-main" className="ds-main" tabIndex={-1}>
<PageLayout mainId="ds-main" wordmarkTo="/design-system" header={<span className="ds-library-label">Interface library / 01</span>}>
<header className="ds-library-intro">
<div>
<p className="ds-eyebrow">MINABOT INTERFACE LANGUAGE</p>
@@ -300,14 +291,6 @@ export function DesignSystem() {
</>
) },
]} />
<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>
</PageLayout>
);
}

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

9
src/sharing/config.ts Normal file
View File

@@ -0,0 +1,9 @@
export type DiscordSharingConfig = { token: string; channelId: string };
/** Read only from the server entrypoint; never include credentials in public config. */
export function readDiscordSharingConfig(env = process.env): DiscordSharingConfig {
return {
token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""),
channelId: (env.DISCORD_SHARING_CHANNEL_ID ?? "").trim(),
};
}

21
src/sharing/contracts.ts Normal file
View File

@@ -0,0 +1,21 @@
import { z } from "zod";
import { dateSchema } from "../habits/contracts";
import { dayNumber } from "../habits/calendar";
export const shareInput = z.object({
habitIds: z.array(z.string().uuid()).min(1).max(6).refine(ids => new Set(ids).size === ids.length),
from: dateSchema,
to: dateSchema,
}).strict().refine(v => v.from <= v.to && dayNumber(v.to) - dayNumber(v.from) < 366, "Choose up to 366 days in chronological order");
export type ShareData = {
from: string; to: string; timezone: string;
botAvatarUrl?: string | null;
habits: {
name: string; color: string; completed: number; scheduled: number;
legend: { empty: string; partial: string[]; complete: string };
days: { date: string; color: string; due: boolean; complete: boolean; ratio: number | null }[];
}[];
};
export type DiscordConnection = { connected: boolean; name?: string; channelUrl?: string; message?: string };
export type Delivery = { status: "sent" | "uncertain"; messageUrl?: string };

105
src/sharing/routes.test.ts Normal file
View File

@@ -0,0 +1,105 @@
import { afterEach, expect, test } from "bun:test";
import { createApi } from "../api";
import { fixture } from "../habits/test-fixture";
import type { DiscordFetch } from "./routes";
import type { DiscordSharingConfig } from "./config";
import { cardStats } from "../lib/progress-card";
const fixtures: ReturnType<typeof fixture>[] = [];
afterEach(() => { for (const f of fixtures.splice(0)) f.close(); });
const botConfig = { token: "test-bot-token-do-not-expose", channelId: "223456789012345678" };
const channel = { type: 0, id: botConfig.channelId, guild_id: "323456789012345678", name: "progress" };
function setup(request: DiscordFetch = async () => Response.json(channel), config: DiscordSharingConfig = botConfig) {
const f = fixture(); fixtures.push(f);
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-with-at-least-32-characters" }, undefined, () => Date.parse("2026-09-04T12:00:00Z"), request, config);
const call = (path: string, method = "GET", body?: unknown, user = "a", origin = f.origin) => app.request(`${f.origin}/api/sharing${path}`, { method, headers: { Cookie: `minabot_session=${user.repeat(43)}`, Origin: origin, ...(body instanceof FormData ? {} : { "Content-Type": "application/json" }) }, body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body) });
return { f, call, connect: () => call("/discord") };
}
// A small real PNG is not 1200px wide; use a header fixture for transport validation.
function upload(id = crypto.randomUUID()) {
const header = Buffer.alloc(40); Buffer.from("89504e470d0a1a0a", "hex").copy(header); header.write("IHDR", 12); header.writeUInt32BE(1200, 16); header.writeUInt32BE(940, 20);
const data = new FormData(); data.set("image", new Blob([header], { type: "image/png" }), "progress.png"); data.set("deliveryId", id); return data;
}
test("sharing requires authentication and same-origin mutations", async () => {
const { call } = setup();
expect((await call("/discord", "GET", undefined, "x")).status).toBe(401);
expect((await call("/discord/send", "POST", upload(), "a", "https://evil.example")).status).toBe(403);
expect((await call("/preview", "POST", {}, "a", "https://evil.example")).status).toBe(403);
});
test("missing or invalid bot configuration cannot make outbound requests", async () => {
for (const config of [{ token: "", channelId: channel.id }, { token: "test", channelId: "https://evil.example" }, { token: "bad\nheader", channelId: channel.id }]) {
let requests = 0;
const { call } = setup(async () => { requests++; return Response.json(channel); }, config);
expect((await (await call("/discord")).json()).connected).toBe(false);
expect((await call("/discord/send", "POST", upload())).status).toBe(422);
expect(requests).toBe(0);
}
});
test("bot channel status exposes only the configured destination and caches channel metadata", async () => {
let requests = 0;
const { call } = setup(async (url, init) => {
requests++; expect(url).toBe(`https://discord.com/api/v10/channels/${channel.id}`);
expect(init?.redirect).toBe("error"); expect(new Headers(init?.headers).get("Authorization")).toBe(`Bot ${botConfig.token}`);
return Response.json(channel);
});
const expected = { connected: true, name: "#progress", channelUrl: `https://discord.com/channels/${channel.guild_id}/${channel.id}` };
const response = await call("/discord"); expect(response.status).toBe(200); expect(await response.json()).toEqual(expected);
expect(await (await call("/discord", "GET", undefined, "b")).json()).toEqual(expected);
expect(requests).toBe(1);
expect((await call("/discord", "PUT", { url: "https://evil.example" })).status).toBe(404);
expect((await call("/discord", "DELETE")).status).toBe(404);
});
test("bot errors do not expose the token or provider response", async () => {
const { call } = setup(async () => { throw new Error(botConfig.token); });
const response = await call("/discord"); expect(response.status).toBe(502); expect(await response.text()).not.toContain(botConfig.token);
});
test("card data respects ownership, recurrence, privacy boundaries, and date limits", async () => {
const { f, call } = setup();
f.setTime("2026-09-03T12:00:00Z");
const habit = await f.json("/habits", "POST", { name: "Evening", method: "tasks", tasks: [{ name: "private task name" }], schedule: { type: "weekdays", days: [4] } }, 201);
await f.json(`/habits/${habit.id}/days/2026-09-03/tasks/${habit.tasks[0].id}`, "PUT", { done: true });
f.setTime("2026-09-04T12:00:00Z");
const input = { habitIds: [habit.id], from: "2026-09-01", to: "2026-09-04" };
const response = await call("/preview", "POST", input); expect(response.status).toBe(200);
const data = await response.json(); expect(JSON.stringify(data)).not.toContain("private task name");
expect(cardStats(data)).toEqual({ completed: 1, scheduled: 1, percent: 100 });
expect(data.habits[0].legend).toEqual({ empty: "#ebedf0", partial: [], complete: "#196127" });
expect(data.habits[0].days[3].due).toBe(false);
expect((await call("/preview", "POST", input, "b")).status).toBe(404);
for (const patch of [{ to: "2026-09-05" }, { from: "2024-01-01" }, { from: "2026-09-05" }, { habitIds: [] }, { habitIds: [habit.id, habit.id] }]) expect((await call("/preview", "POST", { ...input, ...patch })).status).toBe(422);
});
test("delivery sends exactly the PNG with mentions disabled and deduplicates retries", async () => {
let posts = 0;
const { call, connect } = setup(async (target, init) => {
if (init?.method !== "POST") return Response.json(channel);
posts++; expect(target).toBe(`https://discord.com/api/v10/channels/${channel.id}/messages`);
expect(new Headers(init.headers).get("Authorization")).toBe(`Bot ${botConfig.token}`);
const form = init.body as FormData; const payload = JSON.parse(form.get("payload_json") as string);
expect(payload.allowed_mentions).toEqual({ parse: [] }); expect(payload.username).toBeUndefined(); expect(payload.enforce_nonce).toBe(true); expect(payload.nonce).toHaveLength(24);
expect(payload.content).toBeUndefined(); expect((form.get("files[0]") as File).type).toBe("image/png");
return Response.json({ id: "423456789012345678" });
});
await connect(); const id = crypto.randomUUID();
const first = await call("/discord/send", "POST", upload(id)); expect(first.status).toBe(200);
expect(await first.json()).toEqual({ status: "sent", messageUrl: "https://discord.com/channels/323456789012345678/223456789012345678/423456789012345678" });
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("sent"); expect(posts).toBe(1);
});
test("uncertain delivery is not resent and a known rate-limit rejection can be retried", async () => {
let posts = 0;
const { call, connect } = setup(async (_, init) => {
if (init?.method !== "POST") return Response.json(channel);
posts++; if (posts === 1) return new Response(null, { status: 429 }); throw new Error("Network failed after sending");
});
await connect(); const id = crypto.randomUUID();
expect((await call("/discord/send", "POST", upload(id))).status).toBe(429);
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain");
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain"); expect(posts).toBe(2);
});
test("invalid files and absent bot configuration cannot send", async () => {
let posts = 0; const { call, connect } = setup(async (_, init) => { if (init?.method === "POST") posts++; return Response.json(channel); });
const disabled = setup(undefined, { token: "", channelId: "" });
expect((await disabled.call("/discord/send", "POST", upload())).status).toBe(422);
await connect(); const form = upload(); form.set("image", new Blob(["not png"], { type: "image/png" }), "x.png");
expect((await call("/discord/send", "POST", form)).status).toBe(422); expect(posts).toBe(0);
});

134
src/sharing/routes.ts Normal file
View File

@@ -0,0 +1,134 @@
import { createHash } from "node:crypto";
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import { eq } from "drizzle-orm";
import { z } from "zod";
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
import { discordDeliveries } from "../db/schema";
import { HabitService, ApiError } from "../habits/service";
import { shade } from "../habits/calendar";
import { shareInput, type ShareData } from "./contracts";
import type { DiscordSharingConfig } from "./config";
export type DiscordFetch = (url: string, init?: RequestInit) => Promise<Response>;
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
const snowflake = /^\d{17,20}$/;
export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, config: DiscordSharingConfig, now: () => number, request: DiscordFetch = fetch) {
const app = new Hono<AuthEnv>();
const configured = Boolean(config.token && !/\s/.test(config.token) && snowflake.test(config.channelId));
const channelEndpoint = `https://discord.com/api/v10/channels/${config.channelId}`;
const headers = { Authorization: `Bot ${config.token}` };
let cachedBotAvatar: { url: string | null; expiresAt: number } | null = null;
let loadingBotAvatar: Promise<string | null> | null = null;
async function botAvatarUrl(): Promise<string | null> {
if (!configured) return null;
if (cachedBotAvatar && cachedBotAvatar.expiresAt > now()) return cachedBotAvatar.url;
if (loadingBotAvatar) return loadingBotAvatar;
loadingBotAvatar = (async () => {
let url: string | null = null;
try {
const response = await request("https://discord.com/api/v10/users/@me", { headers, redirect: "error", signal: AbortSignal.timeout(5000) });
const bot = response.ok ? await response.json() as { id?: string; bot?: boolean; avatar?: string | null; discriminator?: string } : null;
if (bot?.bot && snowflake.test(bot.id ?? "")) {
if (bot.avatar && /^(?:a_)?[a-fA-F0-9]{32}$/.test(bot.avatar)) {
url = `https://cdn.discordapp.com/avatars/${bot.id}/${bot.avatar}.png?size=128`;
} else if (!bot.avatar) {
const index = bot.discriminator && /^\d{4}$/.test(bot.discriminator) && bot.discriminator !== "0000"
? Number(bot.discriminator) % 5 : Number((BigInt(bot.id!) >> 22n) % 6n);
url = `https://cdn.discordapp.com/embed/avatars/${index}.png`;
}
}
} catch { /* Keep card exports available when Discord cannot supply the avatar. */ }
cachedBotAvatar = { url, expiresAt: now() + (url ? 300000 : 30000) };
return url;
})();
try { return await loadingBotAvatar; } finally { loadingBotAvatar = null; }
}
let cachedChannel: { name: string; guildId: string; channelUrl: string; expiresAt: number } | null = null;
async function channel() {
if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");
if (cachedChannel && cachedChannel.expiresAt > now()) return cachedChannel;
let response: Response;
try { response = await request(channelEndpoint, { headers, redirect: "error", signal: AbortSignal.timeout(10000) }); }
catch { throw new ApiError(502, "Could not reach Discord. Please try again."); }
if (!response.ok) {
const message = response.status === 401 ? "The Discord bot token is invalid. Update the server configuration."
: response.status === 403 || response.status === 404 ? "The bot cannot access the sharing channel. Check its channel permissions."
: response.status === 429 ? "Discord is busy. Wait a moment before trying again." : "Could not load the Discord sharing channel.";
throw new ApiError(response.status === 429 ? 429 : 502, message);
}
const data = await response.json().catch(() => null) as { id?: string; type?: number; name?: string; guild_id?: string } | null;
if (data?.id !== config.channelId || !snowflake.test(data.guild_id ?? "") || ![0, 5, 10, 11, 12].includes(data.type ?? -1))
throw new ApiError(422, "Set the sharing channel to a Discord server text channel or thread.");
cachedChannel = { name: data.name ? `#${data.name}` : "Discord sharing channel", guildId: data.guild_id!, channelUrl: `https://discord.com/channels/${data.guild_id}/${config.channelId}`, expiresAt: now() + 60000 };
return cachedChannel;
}
app.use("*", auth.requireAuth);
app.use("*", async (c, next) => c.req.method === "GET" ? next() : auth.requireSameOrigin(c, next));
app.use("*", bodyLimit({ maxSize: MAX_IMAGE_BYTES + 65536, onError: c => c.json({ error: "Choose a progress image smaller than 4 MB." }, 413) }));
async function json<T extends z.ZodType>(req: Request, schema: T): Promise<z.output<T>> {
if (req.headers.get("Content-Type")?.split(";")[0] !== "application/json") throw new ApiError(400, "Expected application/json");
const value = await req.json().catch(() => { throw new ApiError(400, "Malformed JSON"); });
const parsed = schema.safeParse(value);
if (!parsed.success) throw new ApiError(422, "Check your selection and try again.");
return parsed.data;
}
app.post("/preview", async c => {
const input = await json(c.req.raw, shareInput);
const service = new HabitService(db, c.get("user"), now()); service.sync();
if (input.to > service.today) throw new ApiError(422, "The end date cannot be after today.");
const result: ShareData = { from: input.from, to: input.to, timezone: service.user.timezone, habits: input.habitIds.map(id => {
const habit = service.current(id);
const chart = service.calendar([id], input.from, input.to, service.settings(id), false);
return { name: habit.name, color: chart.settings.mainColor,
legend: { empty: chart.settings.emptyColor, complete: chart.settings.mainColor,
partial: chart.days.some(d => d.shadeCount > 1) ? [0.25, 0.5, 0.75].map(ratio => shade(ratio, 4, chart.settings, false).color) : [] },
completed: chart.days.filter(d => d.completed > 0).length, scheduled: chart.days.filter(d => d.due > 0).length,
days: chart.days.map(d => ({ date: d.date, color: d.color, due: d.due > 0, complete: d.completed > 0, ratio: d.ratio })) };
}) };
result.botAvatarUrl = await botAvatarUrl();
return c.json(result);
});
app.get("/discord", async c => {
if (!configured) return c.json({ connected: false, message: "Discord sharing has not been configured on this server." });
const destination = await channel();
return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl });
});
app.post("/discord/send", async c => {
const userId = c.get("user").id;
if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");
const form = await c.req.formData().catch(() => { throw new ApiError(400, "Expected a progress image."); });
const id = z.string().uuid().safeParse(form.get("deliveryId"));
const image = form.get("image");
if (!id.success || !(image instanceof File) || image.type !== "image/png" || image.size > MAX_IMAGE_BYTES || image.size < 33) throw new ApiError(422, "Choose a PNG progress image smaller than 4 MB.");
const bytes = Buffer.from(await image.arrayBuffer());
if (bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" || bytes.toString("ascii", 12, 16) !== "IHDR" || bytes.readUInt32BE(16) !== 1200 || bytes.readUInt32BE(20) > 4000) throw new ApiError(422, "Regenerate the progress card before sending.");
const destination = await channel();
const imageHash = createHash("sha256").update(bytes).update(`bot:${config.channelId}`).digest("hex");
const previous = db.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get();
if (previous) {
if (previous.userId !== userId || previous.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again.");
return c.json({ status: previous.status === "sent" ? "sent" : "uncertain", messageUrl: previous.messageUrl ?? undefined });
}
db.insert(discordDeliveries).values({ id: id.data, userId, imageHash, status: "pending", createdAt: now() }).run();
const attachment = new FormData();
attachment.set("payload_json", JSON.stringify({ allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(`${userId}:${id.data}`).digest("hex").slice(0, 24), enforce_nonce: true, attachments: [{ id: 0, filename: "minabot-progress.png", description: "Progress card shared from minabot" }] }));
attachment.set("files[0]", image, "minabot-progress.png");
let response: Response;
try { response = await request(`${channelEndpoint}/messages`, { method: "POST", headers, body: attachment, redirect: "error", signal: AbortSignal.timeout(15000) }); }
catch { return c.json({ status: "uncertain" }); }
if (!response.ok) {
if (response.status >= 500) return c.json({ status: "uncertain" });
db.delete(discordDeliveries).where(eq(discordDeliveries.id, id.data)).run();
cachedChannel = null;
throw new ApiError(response.status === 429 ? 429 : 422, response.status === 429 ? "Discord is busy. Wait a moment before trying again." : "Discord rejected the card. Check the bot token and its View Channel, Send Messages, and Attach Files permissions (Send Messages in Threads for threads).");
}
const message = await response.json().catch(() => null) as { id?: string } | null;
const messageUrl = snowflake.test(message?.id ?? "") ? `${destination.channelUrl}/${message!.id}` : null;
db.update(discordDeliveries).set({ status: "sent", messageUrl }).where(eq(discordDeliveries.id, id.data)).run();
return c.json({ status: "sent", messageUrl: messageUrl ?? undefined });
});
return app;
}