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