Split optional routes and editing dialogs from initial browser code

This commit is contained in:
syntaxbullet
2026-09-08 16:20:04 +02:00
parent 9a0fc108a9
commit b22c933fc4
6 changed files with 68 additions and 8 deletions

View File

@@ -91,6 +91,12 @@ function LocationProbe() {
</>;
}
async function settleDeferred() {
for (let attempt = 0; attempt < 100 && [...container.querySelectorAll('[role="status"]')].some(node => node.textContent === "Loading…"); attempt++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 1)); });
}
}
async function render(path = "/design-system") {
await act(async () =>
root.render(
@@ -100,6 +106,7 @@ async function render(path = "/design-system") {
</MemoryRouter>,
),
);
await settleDeferred();
}
function connectAccount() {
@@ -131,6 +138,7 @@ describe("homepage", () => {
const opener = buttonNamed("Settings");
opener.focus();
await act(async () => opener.click());
await settleDeferred();
const dialog = container.querySelector<HTMLDialogElement>("dialog#account-settings")!;
expect(dialog.open).toBe(true);
expect(dialog.contains(document.activeElement)).toBe(true);
@@ -158,6 +166,7 @@ describe("homepage", () => {
expect(inner.open).toBe(true);
expect(inner.querySelector<HTMLButtonElement>('[aria-label="Close dialog"]')?.disabled).toBe(true);
await act(async () => buttonNamed("Finish save").click());
await settleDeferred();
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
expect(container.querySelector("#inner")).toBeNull();
expect(container.querySelector<HTMLDialogElement>("#outer")?.open).toBe(true);
@@ -175,10 +184,13 @@ describe("homepage", () => {
await f.request(`/habits/${h.id}`, "DELETE");
await render("/");
await act(async () => buttonNamed("Archived habits").click());
await settleDeferred();
expect(buttonNamed("Restore Reading")).toBeDefined();
await act(async () => buttonNamed("View history").click());
await settleDeferred();
expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
await act(async () => buttonNamed("Restore Reading").click());
await settleDeferred();
expect(container.textContent).toContain("No archived habits.");
expect((await f.json("/today")).habits[0].name).toBe("Reading");
expect((await f.json(`/habits/${h.id}/days/2026-09-03`)).complete).toBe(true);
@@ -190,12 +202,16 @@ describe("homepage", () => {
await f.request(`/habits/${habit.id}`, "DELETE");
await render("/");
await act(async () => buttonNamed("Archived habits").click());
await settleDeferred();
await act(async () => buttonNamed("Delete permanently Discard me").click());
await settleDeferred();
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("This cannot be undone.");
expect(document.activeElement).toBe(buttonNamed("Keep habit"));
await act(async () => buttonNamed("Keep habit").click());
await settleDeferred();
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
await act(async () => buttonNamed("Delete permanently Discard me").click());
await settleDeferred();
const confirm = () => container.querySelector<HTMLButtonElement>("#delete-archived-habit .ds-action-row button:last-child")!;
fetchMock.mockResolvedValueOnce(Response.json({ error: "Temporary failure" }, { status: 500 }));
await act(async () => confirm().click());
@@ -220,6 +236,7 @@ describe("homepage", () => {
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label^="2026-09-03:"]')!.click());
expect(buttonNamed("Edit selected check-in").closest(".ds-date-inspector")?.textContent).toContain("September 3, 2026");
await act(async () => buttonNamed("Edit selected check-in").click());
await settleDeferred();
const past = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!;
expect(past).toBeDefined();
await act(async () => past.click());
@@ -233,6 +250,7 @@ describe("homepage", () => {
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());
await settleDeferred();
expect(container.querySelector<HTMLInputElement>('[aria-label="Total glasses of water"]')?.value).toBe("4");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
@@ -264,6 +282,7 @@ describe("homepage", () => {
expect(container.textContent).toContain("Account unavailable");
expect(container.querySelector("#welcome-title")).toBeNull();
await act(async () => buttonNamed("Try again").click());
await settleDeferred();
expect(container.querySelector("#welcome-title")).not.toBeNull();
});
@@ -273,6 +292,7 @@ describe("homepage", () => {
expect(container.textContent).toContain("Welcome back, alice.");
expect(container.textContent).toContain("Start with one habit.");
await act(async () => buttonNamed("Add a habit +").click());
await settleDeferred();
const input = container.querySelector<HTMLInputElement>("form input")!;
expect(document.activeElement).toBe(input);
await act(async () => {
@@ -285,6 +305,7 @@ describe("homepage", () => {
expect(container.textContent).not.toContain("Read a little created.");
expect(container.querySelector(".ds-habit-chart")).not.toBeNull();
await act(async () => buttonNamed("Sign out").click());
await settleDeferred();
expect(container.querySelector("#welcome-title")).not.toBeNull();
expect((await f.request("/me")).status).toBe(401);
});
@@ -310,12 +331,14 @@ describe("homepage", () => {
expect(container.querySelectorAll('.ds-goal--tasks .ds-routine-items > li')).toHaveLength(1);
await act(async () => buttonNamed("Increase Water").click());
await settleDeferred();
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3");
expect(container.querySelectorAll(".ds-goal-graph .ds-calendar")).toHaveLength(3);
expect(countGoal.querySelector(".ds-goal-status")?.textContent).toBe("Complete");
expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)?.textContent).toContain("8 of 8 glasses");
expect(container.textContent).not.toContain("Sunday walk");
await act(async () => buttonNamed("All habits · 4").click());
await settleDeferred();
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());
@@ -323,6 +346,7 @@ describe("homepage", () => {
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());
await settleDeferred();
expect((await f.json("/today")).completed).toBe(2);
});
@@ -339,6 +363,7 @@ describe("homepage", () => {
expect(calendarRequests()[0]).toContain("compact=true");
const parse = spyOn(Date, "parse");
await act(async () => buttonNamed("All habits · 2").click());
await settleDeferred();
expect(parse).not.toHaveBeenCalled();
parse.mockRestore();
fetchMock.mockClear();
@@ -380,6 +405,7 @@ describe("homepage", () => {
}, { preconnect: fetch.preconnect }));
fetchMock.mockClear();
await act(async () => buttonNamed("Increase Water").click());
await settleDeferred();
expect(buttonNamed("Increase Water").disabled).toBe(true);
const check = container.querySelector<HTMLInputElement>('[aria-label="Reading done"]')!;
expect(check.disabled).toBe(false);
@@ -406,6 +432,7 @@ describe("homepage", () => {
f.setTime("2026-09-04T12:00:00Z");
await render("/");
await act(async () => buttonNamed("Edit habit Water").click());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit habit Water"]')!;
expect(document.activeElement).toBe(form.querySelector("input"));
await act(async () => {
@@ -423,11 +450,15 @@ describe("homepage", () => {
expect(container.querySelector("form")).toBeNull();
expect(container.querySelector("h3")?.textContent).toContain("Daily water");
await act(async () => buttonNamed("Archive habit Daily water").click());
await settleDeferred();
expect((await f.json("/habits")).habits).toHaveLength(1);
await act(async () => buttonNamed("Cancel").click());
await settleDeferred();
expect((await f.json("/habits")).habits).toHaveLength(1);
await act(async () => buttonNamed("Archive habit Daily water").click());
await settleDeferred();
await act(async () => buttonNamed("Archive habit").click());
await settleDeferred();
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`);
@@ -446,6 +477,7 @@ describe("homepage", () => {
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());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Stretch"]')!;
await act(async () => {
const input = form.querySelector("input")!;
@@ -458,7 +490,9 @@ describe("homepage", () => {
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 settleDeferred();
await act(async () => buttonNamed("Delete task").click());
await settleDeferred();
const today = (await f.json("/today")).habits[0];
expect(today.target).toBe(1);
expect(today.value).toBe(0);
@@ -474,9 +508,11 @@ describe("homepage", () => {
await render("/");
expect(container.querySelector(".ds-habit-chart")).toBeNull();
await act(async () => buttonNamed("All habits · 1").click());
await settleDeferred();
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());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
await act(async () => {
const input = form.querySelector("input")!;
@@ -516,6 +552,7 @@ describe("homepage", () => {
{ type: "daily" },
] as const) {
await act(async () => buttonNamed("Edit task Walk").click());
await settleDeferred();
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") {
@@ -528,6 +565,7 @@ describe("homepage", () => {
if (schedule.type === "interval") expect(buttonNamed("All habits · 1").getAttribute("aria-pressed")).toBe("true");
expect(container.querySelector("form")).toBeNull();
await act(async () => buttonNamed("Edit task Walk").click());
await settleDeferred();
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") {
@@ -535,6 +573,7 @@ describe("homepage", () => {
expect(reopened.querySelector<HTMLInputElement>('input[type="date"]')?.value).toBe(schedule.anchor);
}
await act(async () => buttonNamed("Cancel").click());
await settleDeferred();
}
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
expect(previous.tasks[0].done).toBe(true);
@@ -554,17 +593,20 @@ describe("homepage", () => {
};
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 settleDeferred();
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());
await settleDeferred();
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());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Add task"]')!;
await act(async () => {
const input = form.querySelector("input")!;
@@ -584,16 +626,19 @@ describe("homepage", () => {
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await render("/");
await act(async () => buttonNamed("Archive habit Reading").click());
await settleDeferred();
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("Archive habit").click());
await settleDeferred();
expect(container.querySelector("form")).toBeNull();
expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh");
expect(buttonNamed("Archive habit Reading").disabled).toBe(true);
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
await act(async () => buttonNamed("Try again").click());
await settleDeferred();
expect(container.querySelector(".ds-habit-chart")).toBeNull();
expect(fetchMock.mock.calls.filter(call => call[1]?.method === "DELETE")).toHaveLength(1);
});
@@ -604,10 +649,12 @@ describe("homepage", () => {
await render("/");
fetchMock.mockResolvedValueOnce(Response.json({ error: "Save unavailable" }, { status: 500 }));
await act(async () => buttonNamed("Increase Water").click());
await settleDeferred();
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save unavailable");
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("0");
expect(buttonNamed("Increase Water").disabled).toBe(false);
await act(async () => buttonNamed("Increase Water").click());
await settleDeferred();
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("1");
expect((await f.json("/today")).habits[0].value).toBe(1);
});

View File

@@ -1,5 +1,6 @@
import { Navigate, Route, Routes } from "react-router";
import { DesignSystem } from "./pages/DesignSystem";
import { deferred } from "./components/design-system/deferred";
const DesignSystem = deferred(() => import("./pages/DesignSystem").then(module => module.DesignSystem));
import { Home } from "./pages/Home";
import { AuthProvider } from "./components/AuthProvider";

View File

@@ -10,7 +10,8 @@ import { Button } from "./design-system/primitives";
import { HabitChart } from "./design-system/HabitChart";
import { CalendarLegend } from "./design-system/CalendarLegend";
import { shade } from "../habits/calendar";
import { CheckInEditor } from "./CheckInEditor";
import { deferred } from "./design-system/deferred";
const CheckInEditor = deferred(() => import("./CheckInEditor").then(module => module.CheckInEditor));
import { Field } from "./design-system/Field";
import { ItemActions } from "./design-system/ItemActions";

View File

@@ -0,0 +1,9 @@
import { lazy, Suspense, type ComponentProps, type ComponentType } from "react";
/** Keep the surrounding workspace usable while an optional surface loads. */
export function deferred<Component extends ComponentType<any>>(load: () => Promise<Component>) {
const Surface = lazy(async () => ({ default: await load() })) as ComponentType<ComponentProps<Component>>;
return function Deferred(props: ComponentProps<Component>) {
return <Suspense fallback={<p className="ds-supporting-copy" role="status">Loading…</p>}><Surface {...props} /></Suspense>;
};
}

View File

@@ -1,7 +1,8 @@
import { deferred } from "../components/design-system/deferred";
import { CalendarCacheProvider } from "../components/CalendarCache";
import { Archive, Plus, Settings2 } from "lucide-react";
import { ArchivedHabits } from "../components/ArchivedHabits";
import { AccountSettings } from "../components/AccountSettings";
const ArchivedHabits = deferred(() => import("../components/ArchivedHabits").then(module => module.ArchivedHabits));
const AccountSettings = deferred(() => import("../components/AccountSettings").then(module => module.AccountSettings));
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "../components/AuthProvider";
import { DiscordSignInButton } from "../components/DiscordSignInButton";
@@ -16,17 +17,17 @@ import {
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";
const HabitForm = deferred(() => import("../components/HabitForm").then(module => module.HabitForm));
import { habitRequest, reconcileToday, 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";
const InlineHabitEditor = deferred(() => import("../components/InlineHabitEditor").then(module => module.InlineHabitEditor));
const InlineTaskEditor = deferred(() => import("../components/InlineTaskEditor").then(module => module.InlineTaskEditor));
import type { Schedule } from "../habits/contracts";
import { ShareProgress } from "../components/ShareProgress";
const ShareProgress = deferred(() => import("../components/ShareProgress").then(module => module.ShareProgress));
export function Home() {
const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();