Add new styles for home and landing pages

- Created home.css with comprehensive styles for the home page layout, including typography, buttons, and responsive design adjustments.
- Created landing.css to style the landing page, focusing on typography, layout, and responsive behavior for various screen sizes.
This commit is contained in:
syntaxbullet
2026-09-04 11:50:24 +02:00
parent 1acf016169
commit 084603bb6e
43 changed files with 7191 additions and 65 deletions

692
src/App.test.tsx Normal file
View File

@@ -0,0 +1,692 @@
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
spyOn,
test,
} from "bun:test";
import { Window } from "happy-dom";
import { act } from "react";
import { MemoryRouter } from "react-router";
import type { Root } from "react-dom/client";
import { App } from "./App";
import { fixture } from "./habits/test-fixture";
import { CalendarHeatmap } from "./components/design-system/CalendarHeatmap";
// A simulated DOM keeps auth regression checks independent of Discord and the local database.
const dom = new Window({ url: "http://localhost:3000/" });
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
let createRoot: typeof import("react-dom/client").createRoot;
let root: Root;
let container: HTMLDivElement;
let fetchMock: ReturnType<typeof spyOn<typeof globalThis, "fetch">>;
const account = {
id: "test-user",
discordId: "123",
username: "demo",
displayName: "Demo User",
avatarUrl: null,
timezone: "UTC",
};
const emptyToday = {
date: "2026-09-04",
timezone: "UTC",
habits: [],
due: 0,
completed: 0,
};
beforeAll(async () => {
for (const key of [
"window",
"document",
"navigator",
"HTMLElement",
"HTMLInputElement",
"Element",
"Node",
"Event",
"MouseEvent",
"IS_REACT_ACT_ENVIRONMENT",
]) {
originalGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value:
key === "window"
? dom
: key === "IS_REACT_ACT_ENVIRONMENT"
? true
: (dom as unknown as Record<string, unknown>)[key],
});
}
({ createRoot } = await import("react-dom/client"));
});
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
fetchMock = spyOn(globalThis, "fetch").mockResolvedValue(
new Response(null, { status: 401 }),
);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
fetchMock.mockRestore();
});
afterAll(() => {
dom.happyDOM.abort();
for (const [key, descriptor] of originalGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
async function render(path = "/") {
await act(async () =>
root.render(
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>,
),
);
}
async function click(label: string) {
const button = Array.from(container.querySelectorAll("button")).find(
(element) =>
element.textContent?.trim() === label ||
element.getAttribute("aria-label") === label,
);
expect(button).toBeDefined();
await act(async () => button!.click());
}
test("unscheduled dates stay inspectable without a progress-square fill", async () => {
await act(async () =>
root.render(
<CalendarHeatmap
label="Reading history"
unit="pages"
compact
emptyColor="#ebedf0"
days={[
{
date: "2026-09-03",
value: 0,
target: 0,
state: "not-due",
color: "#f5f5f5",
},
{
date: "2026-09-04",
value: 0,
target: 20,
state: "due",
color: "#ebedf0",
},
{
date: "2026-09-05",
value: 0,
target: 20,
state: "future",
color: "#dbeafe",
},
]}
/>,
),
);
const restDay =
container.querySelector<HTMLButtonElement>(".ds-day--not-due")!;
expect(restDay.style.backgroundColor).toBe("transparent");
expect(restDay.getAttribute("aria-label")).toContain(
"Not included in the score",
);
expect(
container.querySelector<HTMLElement>(".ds-day--due")!.style.backgroundColor,
).toBe("#ebedf0");
expect(
container.querySelector<HTMLElement>(".ds-day--future")!.style
.backgroundColor,
).toBe("#ebedf0");
expect(container.querySelector(".ds-legend-swatch--not-due")).not.toBeNull();
await act(async () => restDay.click());
expect(restDay.getAttribute("aria-pressed")).toBe("true");
expect(restDay.tabIndex).toBe(0);
expect(container.querySelector(".ds-date-inspector")?.textContent).toContain(
"Nothing scheduled",
);
await act(async () =>
restDay.dispatchEvent(
new dom.KeyboardEvent("keydown", {
key: "ArrowDown",
bubbles: true,
}) as unknown as KeyboardEvent,
),
);
expect(
container.querySelector(".ds-day--due")?.getAttribute("aria-pressed"),
).toBe("true");
});
describe("home route authentication", () => {
test("signed-out visitors see the landing page with Discord and demo content", async () => {
await render();
expect(container.querySelector("h1")?.textContent).toBe(
"A little today.A rhythm for life.",
);
expect(container.querySelector("#discord")?.textContent).toContain(
"Discord integration currently covers sign-in",
);
expect(container.querySelector("#demo")).not.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("an unresolved account request never flashes the landing page", async () => {
let resolve!: (response: Response) => void;
fetchMock.mockResolvedValue(Response.json(emptyToday)).mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
);
await render();
expect(container.querySelector('[role="status"]')?.textContent).toBe(
"Loading your account…",
);
expect(container.querySelector("#landing-title")).toBeNull();
await act(async () => resolve(Response.json(account)));
expect(container.querySelector("h1")?.textContent).toBe(
"Welcome home,Demo User.",
);
expect(container.querySelector("#landing-title")).toBeNull();
});
test("signed-in users keep Home and signing out reveals the landing page", async () => {
fetchMock
.mockResolvedValueOnce(Response.json(account))
.mockResolvedValueOnce(Response.json(emptyToday))
.mockResolvedValueOnce(Response.json({ ok: true }));
await render();
expect(container.querySelector("h1")?.textContent).toBe(
"Welcome home,Demo User.",
);
expect(container.textContent).toContain("Demo User");
await click("Sign out");
expect(fetchMock).toHaveBeenLastCalledWith("/api/auth/logout", {
method: "POST",
});
expect(container.querySelector("#landing-title")).not.toBeNull();
});
test("logout failure keeps the signed-in view and shows an error", async () => {
fetchMock
.mockResolvedValueOnce(Response.json(account))
.mockResolvedValueOnce(Response.json(emptyToday))
.mockResolvedValueOnce(new Response(null, { status: 500 }));
await render();
await click("Sign out");
expect(container.querySelector("h1")?.textContent).toBe(
"Welcome home,Demo User.",
);
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Could not sign out",
);
});
test("an account error is not treated as signed out and can be retried", async () => {
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 503 }))
.mockResolvedValueOnce(new Response(null, { status: 401 }));
await render();
expect(container.querySelector("#landing-title")).toBeNull();
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Could not load your account",
);
await click("Try again");
expect(container.querySelector("#landing-title")).not.toBeNull();
});
test("OAuth cancellation remains visible on the landing page", async () => {
await render("/?auth_error=denied");
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Discord sign-in was cancelled",
);
});
test("all sign-in calls to action use Discord OAuth with the browser timezone", async () => {
const assign = spyOn(dom.location, "assign").mockImplementation(() => {});
try {
await render();
const buttons = Array.from(container.querySelectorAll("button")).filter(
(button) => button.textContent?.startsWith("Sign in"),
);
expect(buttons).toHaveLength(3);
for (const button of buttons) await act(async () => button.click());
expect(assign).toHaveBeenCalledTimes(3);
for (const [destination] of assign.mock.calls) {
const url = new URL(destination, "http://localhost:3000");
expect(url.pathname).toBe("/api/auth/discord");
expect(url.searchParams.get("timezone")).toBe(
Intl.DateTimeFormat().resolvedOptions().timeZone,
);
}
} finally {
assign.mockRestore();
}
});
test("other routes keep their shell and can navigate back to the public home", async () => {
await render("/about");
expect(container.querySelector("h1")?.textContent).toBe("About");
await click("Home");
expect(container.querySelector("#landing-title")).not.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("the design-system route stays public and independent of account loading", async () => {
await render("/design-system/");
expect(container.querySelector("#ds-title")).not.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
test("the demo updates completion, switches views, and resets without API writes", async () => {
await render();
await click("Increase glasses of water");
expect(
container.querySelector(".ds-preview-heading")?.textContent,
).toContain("1 of 2 habits complete");
expect(
container.querySelector<HTMLButtonElement>(
'[aria-label="Increase glasses of water"]',
)?.disabled,
).toBe(true);
await click("Combined progress");
expect(
container.querySelector('[aria-label="Combined habit calendar"]'),
).not.toBeNull();
const reset = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Reset demo"),
)!;
await act(async () => reset.click());
expect(
container.querySelector(".ds-preview-heading")?.textContent,
).toContain("0 of 2 habits complete");
expect(
container.querySelector('[aria-label="Increase glasses of water"]'),
).not.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe("signed-in dashboard with the real habit API", () => {
let api: ReturnType<typeof fixture>;
beforeEach(() => {
api = fixture();
fetchMock.mockImplementation((async (
input: RequestInfo | URL,
options?: RequestInit,
) => {
const path = String(input).replace(/^\/api/, "");
if (path === "/me") return Response.json(account);
return api.request(
path,
options?.method ?? "GET",
options?.body ? JSON.parse(String(options.body)) : undefined,
);
}) as typeof fetch);
});
afterEach(() => api.close());
async function input(selector: string, value: string) {
const element = container.querySelector<HTMLInputElement>(selector)!;
expect(element).not.toBeNull();
await act(async () => {
const prototype =
element.tagName === "SELECT"
? dom.HTMLSelectElement.prototype
: element.tagName === "TEXTAREA"
? dom.HTMLTextAreaElement.prototype
: dom.HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(
element,
value,
);
element.dispatchEvent(
new Event(element.tagName === "SELECT" ? "change" : "input", {
bubbles: true,
}),
);
});
}
test("empty accounts get useful starters without invented history", async () => {
await render();
expect(container.textContent).toContain(
"No habits yet. No catching up to do.",
);
expect(container.querySelectorAll(".home-starters > button")).toHaveLength(
3,
);
expect(container.querySelector("#rhythm")).toBeNull();
expect(container.textContent).not.toContain("Demo history");
expect(container.textContent).toContain("Europe/Belgrade");
});
test("uses the Discord avatar and falls back only when it fails", async () => {
const avatarUrl = "https://cdn.discordapp.com/avatars/123/avatar.png";
fetchMock.mockResolvedValueOnce(Response.json({ ...account, avatarUrl }));
await render();
const avatar =
container.querySelector<HTMLImageElement>("img.home-avatar")!;
expect(avatar.src).toBe(avatarUrl);
expect(avatar.alt).toBe("Demo Users Discord avatar");
await act(async () => avatar.dispatchEvent(new Event("error")));
expect(container.querySelector("img.home-avatar")).toBeNull();
expect(container.querySelector(".home-avatar")?.textContent).toBe("D");
});
test("a starter is editable and creates a persistent first habit", async () => {
await render();
await act(async () =>
container
.querySelector<HTMLButtonElement>(".home-starters > button")!
.click(),
);
expect(
container.querySelector<HTMLInputElement>("#habit-name")!.value,
).toBe("Read a little");
await input("#habit-name", "Read five pages");
await click("Dusk #79618d");
await act(async () =>
container
.querySelector("dialog form")!
.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
),
);
expect(container.querySelector("dialog")).toBeNull();
const created = (await api.json("/habits")).habits[0];
expect(created.name).toBe("Read five pages");
expect(
(await api.json(`/habits/${created.id}/calendar-settings`)).mainColor,
).toBe("#79618d");
expect(container.querySelector(".home-habit")?.textContent).toContain(
"Read five pages",
);
expect(container.textContent).toContain("Your recorded progress");
expect(container.textContent).not.toContain("No habits yet");
const chart = container.querySelector(`#history-${created.id}`)!;
expect(chart.classList.contains("ds-habit-chart")).toBe(true);
expect(
chart.querySelector<HTMLElement>(".ds-habit-chart-heading h4 > span")!
.style.backgroundColor,
).toBe("#79618d");
});
test("edit controls prefill settings, cancel safely, and persist validated changes", async () => {
const habit = await api.json(
"/habits",
"POST",
{
name: "Water",
method: "count",
target: 8,
unit: "glasses",
carryPartialProgress: true,
color: "#426582",
schedule: { type: "interval", every: 2, anchor: "2026-09-04" },
},
201,
);
await render();
await click("Edit Water");
expect(
container.querySelector<HTMLInputElement>("#habit-target")!.value,
).toBe("8");
expect(
container.querySelector<HTMLSelectElement>("#habit-method")!.disabled,
).toBe(true);
await input("#habit-name", "Not saved");
await click("Cancel");
expect((await api.json(`/habits/${habit.id}`)).name).toBe("Water");
await click("Edit Water");
await input("#habit-name", "Drink water");
await input("#habit-target", "10");
await click("Dusk #79618d");
const submit = async () =>
act(async () =>
container
.querySelector("dialog form")!
.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
),
);
fetchMock.mockResolvedValueOnce(
Response.json({ error: "Try saving again" }, { status: 503 }),
);
await submit();
expect(container.querySelector("dialog [role=alert]")?.textContent).toBe(
"Try saving again",
);
expect(
container.querySelector<HTMLInputElement>("#habit-name")!.value,
).toBe("Drink water");
await submit();
expect(container.querySelector("dialog")).toBeNull();
const updated = await api.json(`/habits/${habit.id}`);
expect(updated.name).toBe("Drink water");
expect(updated.target).toBe(10);
expect(updated.carryPartialProgress).toBe(true);
expect(updated.schedule).toEqual(habit.schedule);
expect(
(await api.json(`/habits/${habit.id}/calendar-settings`)).mainColor,
).toBe("#79618d");
expect(
container.querySelector(`#history-${habit.id} h4`)?.textContent,
).toBe("Drink water");
});
test("editing a task habit preserves task IDs and schedules", async () => {
const habit = await api.json(
"/habits",
"POST",
{
name: "Reset",
method: "tasks",
tasks: [
{ name: "Clear desk", schedule: { type: "weekdays", days: [5] } },
],
},
201,
);
await render();
await click("Edit Reset");
await input("#habit-name", "Evening reset");
await act(async () =>
container
.querySelector("dialog form")!
.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
),
);
expect(container.querySelector("dialog")).toBeNull();
const updated = await api.json(`/habits/${habit.id}`);
expect(updated.name).toBe("Evening reset");
expect(updated.tasks).toEqual(habit.tasks);
});
test("deletion requires confirmation, supports retry, and handles an empty dashboard", async () => {
const habit = await api.json(
"/habits",
"POST",
{ name: "Read", method: "manual" },
201,
);
await render();
await click("Delete Read");
expect(container.querySelector("dialog")?.textContent).toContain(
"not permanently erased",
);
expect((await api.json("/habits")).habits).toHaveLength(1);
await click("Keep habit");
expect(container.querySelector("dialog")).toBeNull();
await click("Delete Read");
fetchMock.mockResolvedValueOnce(
Response.json({ error: "Could not delete" }, { status: 503 }),
);
await click("Delete habit");
expect(container.querySelector("dialog [role=alert]")?.textContent).toBe(
"Could not delete",
);
expect(container.querySelector(`#history-${habit.id}`)).not.toBeNull();
await click("Delete habit");
expect(container.querySelector("dialog")).toBeNull();
expect(container.querySelector(`#history-${habit.id}`)).toBeNull();
expect(container.textContent).toContain("No habits yet");
expect((await api.json("/habits")).habits).toHaveLength(0);
expect((await api.json(`/habits/${habit.id}`)).archived).toBe(true);
});
test("all tracking methods save, undo, and exclude unscheduled habits", async () => {
const manual = await api.json(
"/habits",
"POST",
{ name: "Read", method: "manual" },
201,
);
const count = await api.json(
"/habits",
"POST",
{ name: "Water", method: "count", target: 8, unit: "glasses" },
201,
);
const tasks = await api.json(
"/habits",
"POST",
{ name: "Reset", method: "tasks", tasks: [{ name: "Clear desk" }] },
201,
);
await api.json(
"/habits",
"POST",
{
name: "Sunday walk",
method: "manual",
schedule: { type: "weekdays", days: [0] },
},
201,
);
await render();
expect(container.querySelectorAll(".home-habit")).toHaveLength(3);
expect(
container.querySelectorAll(
"#rhythm .ds-habit-chart-grid > .ds-habit-chart",
),
).toHaveLength(4);
expect(container.querySelector("#history-habit")).toBeNull();
expect(
container.querySelectorAll("#rhythm .ds-calendar--compact"),
).toHaveLength(4);
expect(
container.querySelector(".home-task-details")?.hasAttribute("open"),
).toBe(false);
expect(container.querySelector(".home-off-day")?.textContent).toContain(
"Sunday walk",
);
await act(async () =>
container
.querySelector<HTMLInputElement>(`#habit-${manual.id}`)!
.closest("article")!
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
.click(),
);
expect((await api.json("/today")).completed).toBe(1);
await click("Increase Water (glasses)");
expect((await api.json(`/habits/${count.id}/days/2026-09-04`)).value).toBe(
1,
);
await act(async () =>
container
.querySelector(`#habit-${tasks.id}`)!
.closest("article")!
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
.click(),
);
expect((await api.json("/today")).completed).toBe(2);
await click("Remaining 1");
expect(container.querySelectorAll(".home-habit")).toHaveLength(1);
await click("All today 3");
await act(async () =>
container
.querySelector(`#habit-${manual.id}`)!
.closest("article")!
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
.click(),
);
expect((await api.json("/today")).completed).toBe(1);
});
test("all-complete and no-schedule days are different from having no habits", async () => {
const habit = await api.json(
"/habits",
"POST",
{ name: "Read", method: "manual" },
201,
);
await api.json(`/habits/${habit.id}/days/2026-09-04/progress`, "PUT", {
done: true,
});
await render();
expect(container.textContent).toContain(
"Everything scheduled for today is complete",
);
await click("Remaining 0");
expect(container.textContent).toContain("Youre all caught up.");
await api.json(`/habits/${habit.id}`, "PATCH", {
schedule: { type: "weekdays", days: [0] },
});
await act(async () => window.dispatchEvent(new Event("focus")));
expect(container.textContent).toContain("Nothing is scheduled today");
expect(container.textContent).not.toContain("No habits yet");
});
test("failed logging preserves progress and offers a refresh", async () => {
await api.json(
"/habits",
"POST",
{ name: "Water", method: "count", target: 8, unit: "glasses" },
201,
);
await render();
fetchMock.mockResolvedValueOnce(
Response.json({ error: "Could not save progress" }, { status: 503 }),
);
await click("Increase Water (glasses)");
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Could not save progress",
);
expect((await api.json("/today")).habits[0].value).toBe(0);
await click("Try again");
await click("Increase Water (glasses)");
expect((await api.json("/today")).habits[0].value).toBe(1);
});
test("a failed initial load is not shown as an empty account", async () => {
fetchMock
.mockResolvedValueOnce(Response.json(account))
.mockResolvedValueOnce(new Response(null, { status: 503 }));
await render();
expect(container.querySelector('[role="alert"]')).not.toBeNull();
expect(container.textContent).not.toContain("No habits yet");
await click("Try again");
expect(container.textContent).toContain("No habits yet");
});
});