feat: restructure styles and components for improved design system
- Added typography and design system styles to globals.css. - Removed deprecated home.css and landing.css files. - Introduced DiscordSignInButton component for Discord authentication. - Created Card and CardGrid components for structured content display. - Implemented ContainerShowcase to demonstrate card usage and layout. - Added DesignSystemTabs for navigation between design system sections. - Established typography.css for consistent text styling across components. - Added tests for typography styles to ensure compliance with design standards.
This commit is contained in:
654
src/App.test.tsx
654
src/App.test.tsx
@@ -10,34 +10,18 @@ import {
|
||||
} from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { act } from "react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { MemoryRouter, useLocation, useNavigate } 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.
|
||||
// Keep the design system independent of authentication 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 [
|
||||
@@ -90,26 +74,28 @@ afterAll(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
return <>
|
||||
<output data-testid="pathname">{location.pathname}</output>
|
||||
<output data-testid="hash">{location.hash}</output>
|
||||
<button onClick={() => navigate(-1)}>History back</button>
|
||||
<button onClick={() => navigate(1)}>History forward</button>
|
||||
</>;
|
||||
}
|
||||
|
||||
async function render(path = "/") {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App />
|
||||
<LocationProbe />
|
||||
</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(
|
||||
@@ -177,516 +163,114 @@ test("unscheduled dates stay inspectable without a progress-square fill", async
|
||||
).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,
|
||||
);
|
||||
describe("design-system-only routing", () => {
|
||||
for (const path of ["/design-system", "/design-system/", "/", "/about", "/settings", "/missing", "/?auth_error=denied"]) {
|
||||
test(`renders only the public design system at ${path}`, async () => {
|
||||
await render(path);
|
||||
expect(container.querySelector("#ds-title")).not.toBeNull();
|
||||
expect(container.querySelectorAll("main")).toHaveLength(1);
|
||||
expect(["/design-system", "/design-system/"]).toContain(container.querySelector('[data-testid="pathname"]')!.textContent!);
|
||||
expect(container.querySelector(".landing-root")).toBeNull();
|
||||
expect(container.querySelector(".home-root")).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(container.querySelectorAll('[role="tablist"]')).toHaveLength(1);
|
||||
expect(container.querySelectorAll('[role="tab"]')).toHaveLength(7);
|
||||
expect(container.querySelectorAll('[role="tabpanel"]:not([hidden])')).toHaveLength(1);
|
||||
for (const tab of container.querySelectorAll('[role="tab"]')) {
|
||||
const panel = document.getElementById(tab.getAttribute("aria-controls")!);
|
||||
expect(panel?.getAttribute("aria-labelledby")).toBe(tab.id);
|
||||
}
|
||||
} 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 User’s 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("You’re 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");
|
||||
test("design system playground remains interactive without API requests", async () => {
|
||||
await render("/design-system#playground");
|
||||
const tabs = Array.from(container.querySelectorAll<HTMLButtonElement>("button"));
|
||||
const detail = tabs.find(button => button.textContent?.trim() === "Habit detail")!;
|
||||
expect(detail).toBeDefined();
|
||||
await act(async () => detail.click());
|
||||
expect(detail.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("design system tabs", () => {
|
||||
const tab = (id: string) => container.querySelector<HTMLButtonElement>(`#ds-tab-${id}`)!;
|
||||
const panel = () => container.querySelector<HTMLElement>('[role="tabpanel"]:not([hidden])')!;
|
||||
const clickTab = async (id: string) => { await act(async () => tab(id).click()); };
|
||||
const press = async (id: string, key: string) => {
|
||||
await act(async () => tab(id).dispatchEvent(
|
||||
new dom.KeyboardEvent("keydown", { key, bubbles: true }) as unknown as KeyboardEvent,
|
||||
));
|
||||
};
|
||||
|
||||
test("defaults to foundations and each tab exposes only its own panel", async () => {
|
||||
await render();
|
||||
expect(panel().id).toBe("ds-panel-foundations");
|
||||
for (const id of ["components", "containers", "calendar-states", "playground", "editing", "views", "foundations"]) {
|
||||
await clickTab(id);
|
||||
expect(panel().id).toBe(`ds-panel-${id}`);
|
||||
expect(container.querySelectorAll('[role="tabpanel"]:not([hidden])')).toHaveLength(1);
|
||||
expect(tab(id).getAttribute("aria-selected")).toBe("true");
|
||||
expect(container.querySelectorAll('[role="tab"][tabindex="0"]')).toHaveLength(1);
|
||||
expect(container.querySelector('[data-testid="hash"]')?.textContent).toBe(`#${id}`);
|
||||
}
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("supports arrow keys, Home, End, and wraparound with focus", async () => {
|
||||
await render();
|
||||
await press("foundations", "ArrowLeft");
|
||||
expect(document.activeElement).toBe(tab("views"));
|
||||
expect(panel().id).toBe("ds-panel-views");
|
||||
await press("views", "ArrowRight");
|
||||
expect(document.activeElement).toBe(tab("foundations"));
|
||||
await press("foundations", "End");
|
||||
expect(document.activeElement).toBe(tab("views"));
|
||||
await press("views", "Home");
|
||||
expect(document.activeElement).toBe(tab("foundations"));
|
||||
expect(panel().id).toBe("ds-panel-foundations");
|
||||
});
|
||||
|
||||
test("honors deep links and browser history", async () => {
|
||||
await render("/design-system#editing");
|
||||
expect(panel().id).toBe("ds-panel-editing");
|
||||
await clickTab("containers");
|
||||
const historyButton = (label: string) => Array.from(container.querySelectorAll("button")).find(button => button.textContent === label)!;
|
||||
await act(async () => historyButton("History back").click());
|
||||
expect(panel().id).toBe("ds-panel-editing");
|
||||
await act(async () => historyButton("History forward").click());
|
||||
expect(panel().id).toBe("ds-panel-containers");
|
||||
});
|
||||
|
||||
test("keeps example state and opens the playground from View habit", async () => {
|
||||
await render("/design-system#components");
|
||||
await act(async () => panel().querySelector<HTMLButtonElement>('[aria-label="Increase example count"]')!.click());
|
||||
await clickTab("editing");
|
||||
await clickTab("components");
|
||||
expect(panel().querySelector("output")?.textContent).toBe("4 / 8");
|
||||
const viewHabit = Array.from(panel().querySelectorAll("button")).find(button => button.textContent?.startsWith("View habit"))!;
|
||||
await act(async () => viewHabit.click());
|
||||
expect(panel().id).toBe("ds-panel-playground");
|
||||
expect(panel().querySelector('.ds-view-switch [aria-pressed="true"]')?.textContent).toBe("Habit detail");
|
||||
expect(document.activeElement).toBe(tab("playground"));
|
||||
});
|
||||
|
||||
test("retains child component state and skip link does not switch tabs", async () => {
|
||||
await render("/design-system#containers");
|
||||
const reading = panel().querySelector<HTMLInputElement>('input[type="checkbox"]')!;
|
||||
await act(async () => reading.click());
|
||||
expect(reading.checked).toBe(true);
|
||||
await clickTab("foundations");
|
||||
await clickTab("containers");
|
||||
expect(reading.checked).toBe(true);
|
||||
await act(async () => container.querySelector<HTMLAnchorElement>(".ds-skip-link")!.click());
|
||||
expect(panel().id).toBe("ds-panel-containers");
|
||||
expect(document.activeElement?.id).toBe("ds-main");
|
||||
});
|
||||
|
||||
test("an unknown section safely falls back to foundations", async () => {
|
||||
await render("/design-system#unknown");
|
||||
expect(panel().id).toBe("ds-panel-foundations");
|
||||
});
|
||||
});
|
||||
|
||||
56
src/App.tsx
56
src/App.tsx
@@ -1,57 +1,11 @@
|
||||
import { Route, Routes, useLocation, useNavigate } from "react-router";
|
||||
import { AuthControls } from "./components/AuthControls";
|
||||
import { Home } from "./pages/Home";
|
||||
import { About } from "./pages/About";
|
||||
import { Settings } from "./pages/Settings";
|
||||
import { Navigate, Route, Routes } from "react-router";
|
||||
import { DesignSystem } from "./pages/DesignSystem";
|
||||
import { AuthProvider, useAuth } from "./components/AuthProvider";
|
||||
import { Landing } from "./pages/Landing";
|
||||
import { Button } from "./components/design-system/primitives";
|
||||
|
||||
export function App() {
|
||||
const { pathname } = useLocation();
|
||||
if (pathname === "/design-system" || pathname === "/design-system/") return <DesignSystem />;
|
||||
return <AuthProvider><AppRoutes /></AuthProvider>;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const { user, loading, accountError, retry } = useAuth();
|
||||
|
||||
if (pathname === "/") {
|
||||
if (loading || accountError) return (
|
||||
<div className="ds-root landing-root">
|
||||
<main className="landing-account-state">
|
||||
<a className="ds-wordmark" href="/">minabot.</a>
|
||||
{loading ? <p role="status">Loading your account…</p> : <>
|
||||
<p role="alert">{accountError}</p>
|
||||
<Button variant="secondary" onClick={retry}>Try again</Button>
|
||||
</>}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
if (!user) return <Landing />;
|
||||
return <Home />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav aria-label="Main navigation">
|
||||
<button type="button" disabled={pathname === "/"} onClick={() => navigate("/")}>Home</button>{" "}
|
||||
<button type="button" disabled={pathname === "/about"} onClick={() => navigate("/about")}>About</button>{" "}
|
||||
<button type="button" disabled={pathname === "/settings"} onClick={() => navigate("/settings")}>Settings</button>
|
||||
{" "}<button type="button" onClick={() => navigate("/design-system")}>Design system</button>
|
||||
</nav>
|
||||
<AuthControls />
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<h1>Page not found</h1>} />
|
||||
</Routes>
|
||||
</main>
|
||||
</>
|
||||
<Routes>
|
||||
<Route path="/design-system" element={<DesignSystem />} />
|
||||
<Route path="*" element={<Navigate to="/design-system" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useAuth } from "./AuthProvider";
|
||||
import { Button } from "./design-system/primitives";
|
||||
import { DiscordSignInButton } from "./DiscordSignInButton";
|
||||
|
||||
export function AuthControls() {
|
||||
const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();
|
||||
|
||||
return (
|
||||
<section aria-label="Account">
|
||||
<section className="ds-account-controls" aria-label="Account">
|
||||
{loading ? <p role="status">Loading account…</p> : user ? (
|
||||
<p>
|
||||
Signed in as <strong>{user.displayName}</strong> · {user.timezone}{" "}
|
||||
<button type="button" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</button>{" "}
|
||||
<Button variant="secondary" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</Button>{" "}
|
||||
<a href="/api/me">View my profile</a>
|
||||
</p>
|
||||
) : !accountError && <p><button type="button" onClick={signIn}>Sign in with Discord</button></p>}
|
||||
) : !accountError && <p><DiscordSignInButton onClick={signIn} /></p>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{accountError && <button type="button" onClick={retry}>Try again</button>}
|
||||
{accountError && <Button variant="secondary" onClick={retry}>Try again</Button>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
13
src/components/DiscordSignInButton.tsx
Normal file
13
src/components/DiscordSignInButton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { Button } from "./design-system/primitives";
|
||||
|
||||
export function DiscordSignInButton({ children = "Sign in with Discord", ...props }: ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button {...props}>
|
||||
<svg className="ds-button-icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
|
||||
<path d="M20.317 4.37a19.792 19.792 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.211.375-.445.864-.609 1.249a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.618-1.249.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.675 4.37a.07.07 0 0 0-.032.027C.533 9.043-.32 13.575.099 18.057a.082.082 0 0 0 .031.056 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128c.126-.095.252-.194.372-.294a.074.074 0 0 1 .078-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .079.01c.12.1.246.199.373.294a.077.077 0 0 1-.006.127c-.598.352-1.22.65-1.873.893a.076.076 0 0 0-.04.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.84 19.84 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.673-3.549-13.66a.061.061 0 0 0-.031-.03ZM8.02 15.331c-1.183 0-2.157-1.085-2.157-2.419s.955-2.419 2.157-2.419c1.211 0 2.176 1.095 2.157 2.419 0 1.334-.955 2.419-2.157 2.419Zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419s.955-2.419 2.157-2.419c1.211 0 2.176 1.095 2.157 2.419 0 1.334-.946 2.419-2.157 2.419Z" />
|
||||
</svg>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Checkbox, Counter } from "./design-system/primitives";
|
||||
import { HabitChart } from "./design-system/HabitChart";
|
||||
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
|
||||
import { combinedProgress, demoCalendar, HABIT_COLORS } from "./design-system/calendar-model";
|
||||
|
||||
export function LandingDemo() {
|
||||
const [water, setWater] = useState(7);
|
||||
const [tasks, setTasks] = useState([true, true, false]);
|
||||
const [view, setView] = useState<"Today" | "Combined progress">("Today");
|
||||
const taskCount = tasks.filter(Boolean).length;
|
||||
const total = combinedProgress([
|
||||
{ value: water, target: 8, due: true },
|
||||
{ value: taskCount, target: 3, due: true },
|
||||
]);
|
||||
|
||||
return (
|
||||
<section className="ds-section landing-demo" id="demo" aria-labelledby="demo-title">
|
||||
<div className="ds-section-top">
|
||||
<div>
|
||||
<span className="ds-eyebrow">01 / A LITTLE, EVERY DAY</span>
|
||||
<h2 id="demo-title">Small steps. <em>Visible progress.</em></h2>
|
||||
</div>
|
||||
<span className="ds-demo-note">Interactive demo · nothing is saved</span>
|
||||
</div>
|
||||
<div className="ds-preview-toolbar">
|
||||
<div className="ds-view-switch" role="group" aria-label="Demo view">
|
||||
{(["Today", "Combined progress"] as const).map((item) => (
|
||||
<button key={item} type="button" aria-pressed={view === item} onClick={() => setView(item)}>{item}</button>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="text" onClick={() => { setWater(7); setTasks([true, true, false]); setView("Today"); }}>
|
||||
Reset demo <span aria-hidden="true">↺</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ds-preview-heading">
|
||||
<div>
|
||||
<p className="ds-eyebrow">A SAMPLE DAY / SEPTEMBER 4, 2026</p>
|
||||
<h3>{view === "Today" ? "Make a little room for yourself." : "See your habits, together."}</h3>
|
||||
</div>
|
||||
<p aria-live="polite">{total.completed} of {total.due} habits complete</p>
|
||||
</div>
|
||||
{view === "Today" ? (
|
||||
<div className="ds-habit-chart-grid">
|
||||
<HabitChart 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 name="Evening reset" method="Task-based" value={taskCount} target={3} unit="tasks" color={HABIT_COLORS.tasks}
|
||||
tasks={["Clear desk", "Plan tomorrow", "Stretch"].map((label, index) => (
|
||||
<Checkbox key={label} label={label} checked={tasks[index]} onChange={(event) => {
|
||||
const checked = event.target.checked;
|
||||
setTasks((current) => current.map((done, i) => i === index ? checked : done));
|
||||
}} />
|
||||
))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<CalendarHeatmap days={demoCalendar(total.completed, total.due)} label="Combined habit calendar" unit="habits complete" />
|
||||
)}
|
||||
<p className="ds-footnote landing-demo-hint">
|
||||
{view === "Today"
|
||||
? "Try adding a glass of water, or open the evening tasks and tick off a small win. Select any day to take a closer look."
|
||||
: "Each fully completed habit counts equally. Partial progress appears in its own calendar; unscheduled habits stay out of the combined score."}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function CalendarHeatmap({
|
||||
historyLabel?: string;
|
||||
legend?: ReactNode;
|
||||
}) {
|
||||
const [months, setMonths] = useState<MonthView>(6);
|
||||
const [months, setMonths] = useState<MonthView>(12);
|
||||
const latestDate =
|
||||
allDays.findLast((day) => day.state !== "future")?.date ??
|
||||
allDays.at(-1)?.date;
|
||||
|
||||
44
src/components/design-system/Card.tsx
Normal file
44
src/components/design-system/Card.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useId, type HTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
/** A flat content group. Actions remain explicit buttons or links inside it. */
|
||||
export function Card({
|
||||
eyebrow,
|
||||
heading,
|
||||
children,
|
||||
footer,
|
||||
variant = "soft",
|
||||
span = "standard",
|
||||
className = "",
|
||||
...props
|
||||
}: HTMLAttributes<HTMLElement> & {
|
||||
eyebrow?: string;
|
||||
heading: ReactNode;
|
||||
footer?: ReactNode;
|
||||
variant?: "soft" | "outlined";
|
||||
span?: "standard" | "wide" | "featured";
|
||||
}) {
|
||||
const headingId = useId();
|
||||
return (
|
||||
<article
|
||||
aria-labelledby={headingId}
|
||||
className={`ds-card ds-card--${variant} ds-card--${span} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
<header className="ds-card-header">
|
||||
{eyebrow && <p className="ds-eyebrow">{eyebrow}</p>}
|
||||
<h3 id={headingId}>{heading}</h3>
|
||||
</header>
|
||||
{children && <div className="ds-card-body">{children}</div>}
|
||||
{footer && <footer className="ds-card-footer">{footer}</footer>}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** Equal cards by default; bento spans keep the same reading order on mobile. */
|
||||
export function CardGrid({
|
||||
layout = "equal",
|
||||
className = "",
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { layout?: "equal" | "bento" }) {
|
||||
return <div className={`ds-card-grid ds-card-grid--${layout} ${className}`} {...props} />;
|
||||
}
|
||||
64
src/components/design-system/ContainerShowcase.tsx
Normal file
64
src/components/design-system/ContainerShowcase.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardGrid } from "./Card";
|
||||
import { Button, Checkbox, Counter, SectionHeading } from "./primitives";
|
||||
|
||||
export function ContainerShowcase() {
|
||||
const [water, setWater] = useState(3);
|
||||
const [read, setRead] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="ds-section ds-container-showcase" id="containers" aria-labelledby="containers-title">
|
||||
<SectionHeading number="CONTAINERS & LAYOUT" id="containers-title" title={<>A little structure. <em>Room to breathe.</em></>}>
|
||||
Group related content with a quiet surface. Keep open layouts for long lists and calendars.
|
||||
</SectionHeading>
|
||||
|
||||
<div className="ds-spec-heading ds-spec-heading--spaced">
|
||||
<h3>Two quiet surfaces</h3>
|
||||
<span className="ds-code">Card · soft / outlined</span>
|
||||
</div>
|
||||
<CardGrid>
|
||||
<Card eyebrow="SOFT SURFACE" heading="A gentle grouping.">
|
||||
<p>A neutral fill gathers related content without adding another divider. Use it for summaries, guidance, and a small set of actions.</p>
|
||||
</Card>
|
||||
<Card variant="outlined" eyebrow="OUTLINED SURFACE" heading="A clear boundary.">
|
||||
<p>A fine border defines a standalone group on white. Useful when a form or a focused task needs its own space.</p>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
<div className="ds-spec-heading ds-spec-heading--spaced">
|
||||
<h3>A day, arranged simply</h3>
|
||||
<span className="ds-code">CardGrid · bento</span>
|
||||
</div>
|
||||
<CardGrid layout="bento" aria-label="Interactive bento layout example">
|
||||
<Card
|
||||
span="featured"
|
||||
eyebrow="TODAY / YOUR OWN PACE"
|
||||
heading={<>Small things, <em>adding up.</em></>}
|
||||
footer={<p className="ds-muted type-small">Interactive example · nothing is saved</p>}
|
||||
>
|
||||
<div className="ds-card-summary" role="status">
|
||||
<p className="type-display">{Number(water === 8) + Number(read)}<span className="type-title ds-muted"> / 2</span></p>
|
||||
<p>habits complete</p>
|
||||
</div>
|
||||
<p>Make room for a glass of water and a few pages. A little progress belongs here, too.</p>
|
||||
</Card>
|
||||
<Card eyebrow="DAILY / 8 GLASSES" heading="Drink water" variant="outlined">
|
||||
<Counter label="bento glasses of water" value={water} target={8} onChange={setWater} />
|
||||
<p className="type-small" role="status">{water === 8 ? "Complete for today" : `${8 - water} glasses to go`}</p>
|
||||
</Card>
|
||||
<Card eyebrow="A FEW PAGES" heading="Read a little" variant="outlined">
|
||||
<Checkbox label="Reading done" checked={read} onChange={(event) => setRead(event.target.checked)} />
|
||||
<p className="type-small">One page is a place to start.</p>
|
||||
</Card>
|
||||
<Card span="wide" eyebrow="A GENTLE REMINDER" heading="There’s no catching up.">
|
||||
<p>Come back to today. Your next small step is enough.</p>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
<div className="ds-container-guidance">
|
||||
<p className="ds-footnote">Equal grids suit peer items. Bento gives one summary more room; smaller cards hold short tasks. Both collapse in reading order on small screens. Use one surface per group, with spacing inside and no shadows.</p>
|
||||
<Button variant="text" onClick={() => { setWater(3); setRead(false); }}>Reset card examples ↺</Button>
|
||||
</div>
|
||||
<pre className="ds-code ds-container-code"><code>{'<CardGrid layout="bento">\n <Card heading="Today" span="featured">…</Card>\n <Card heading="Water" variant="outlined">…</Card>\n <Card heading="Reading" variant="outlined">…</Card>\n <Card heading="A reminder" span="wide">…</Card>\n</CardGrid>'}</code></pre>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
63
src/components/design-system/DesignSystemTabs.tsx
Normal file
63
src/components/design-system/DesignSystemTabs.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
type SystemTab = { id: string; label: string; content: ReactNode };
|
||||
|
||||
export function DesignSystemTabs({ tabs }: { tabs: SystemTab[] }) {
|
||||
const { hash } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const buttons = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const selected = tabs.findIndex((tab) => hash === `#${tab.id}`);
|
||||
const active = selected < 0 ? 0 : selected;
|
||||
|
||||
function select(index: number) {
|
||||
navigate({ hash: `#${tabs[index]!.id}` });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ds-explorer">
|
||||
<div className="ds-tab-bar" role="tablist" aria-label="Design system sections">
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={(element) => { buttons.current[index] = element; }}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`ds-tab-${tab.id}`}
|
||||
aria-controls={`ds-panel-${tab.id}`}
|
||||
aria-selected={active === index}
|
||||
tabIndex={active === index ? 0 : -1}
|
||||
onClick={() => select(index)}
|
||||
onKeyDown={(event) => {
|
||||
let next = index;
|
||||
if (event.key === "ArrowRight") next = (index + 1) % tabs.length;
|
||||
else if (event.key === "ArrowLeft") next = (index - 1 + tabs.length) % tabs.length;
|
||||
else if (event.key === "Home") next = 0;
|
||||
else if (event.key === "End") next = tabs.length - 1;
|
||||
else return;
|
||||
event.preventDefault();
|
||||
select(next);
|
||||
buttons.current[next]?.focus();
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Keep panels mounted so editors and examples retain their local state. */}
|
||||
{tabs.map((tab, index) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
id={`ds-panel-${tab.id}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`ds-tab-${tab.id}`}
|
||||
tabIndex={0}
|
||||
hidden={active !== index}
|
||||
className="ds-tab-panel"
|
||||
>
|
||||
{tab.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -361,7 +361,7 @@ export function EditingWorkbench({
|
||||
>
|
||||
<div className="ds-section-top">
|
||||
<div>
|
||||
<span className="ds-eyebrow">02 / EDITING & HISTORY</span>
|
||||
<span className="ds-eyebrow">EDITING & HISTORY</span>
|
||||
<h2 id="editing-title">
|
||||
Room for <em>real life.</em>
|
||||
</h2>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
AnchorHTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
@@ -21,19 +22,31 @@ export function Button({
|
||||
);
|
||||
}
|
||||
|
||||
export function ButtonLink({
|
||||
variant = "text",
|
||||
className = "",
|
||||
...props
|
||||
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
variant?: "primary" | "secondary" | "text";
|
||||
}) {
|
||||
return <a className={`ds-button ds-button--${variant} ${className}`} {...props} />;
|
||||
}
|
||||
|
||||
export function SectionHeading({
|
||||
number,
|
||||
title,
|
||||
children,
|
||||
id,
|
||||
}: {
|
||||
number: string;
|
||||
title: string;
|
||||
title: ReactNode;
|
||||
children?: ReactNode;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<header className="ds-section-heading">
|
||||
<span className="ds-eyebrow">{number}</span>
|
||||
<h2>{title}</h2>
|
||||
<h2 id={id}>{title}</h2>
|
||||
{children && <p>{children}</p>}
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function About() {
|
||||
return <h1>About</h1>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,744 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { Link } from "react-router";
|
||||
import { DeleteHabit } from "../components/DeleteHabit";
|
||||
import { useAuth } from "../components/AuthProvider";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Counter,
|
||||
} from "../components/design-system/primitives";
|
||||
import { CreateHabit, type HabitStarter } from "../components/CreateHabit";
|
||||
import { HabitHistory } from "../components/HabitHistory";
|
||||
import {
|
||||
formatTrackingDate,
|
||||
habitRequest,
|
||||
scheduleLabel,
|
||||
type TodayHabit,
|
||||
type TodayResponse,
|
||||
} from "../lib/dashboard";
|
||||
import "../../styles/design-system.css";
|
||||
import "../../styles/home.css";
|
||||
|
||||
const starters: (HabitStarter & { description: string; symbol: string })[] = [
|
||||
{
|
||||
name: "Read a little",
|
||||
method: "manual",
|
||||
description: "A few pages. A moment for yourself.",
|
||||
symbol: "↗",
|
||||
color: "#977344",
|
||||
},
|
||||
{
|
||||
name: "Drink water",
|
||||
method: "count",
|
||||
target: 8,
|
||||
unit: "glasses",
|
||||
description: "One glass at a time, throughout the day.",
|
||||
symbol: "+",
|
||||
color: "#426582",
|
||||
},
|
||||
{
|
||||
name: "Evening reset",
|
||||
method: "tasks",
|
||||
tasks: "Clear desk\nPlan tomorrow\nStretch",
|
||||
description: "A small routine to close the day.",
|
||||
symbol: "☾",
|
||||
color: "#79618d",
|
||||
},
|
||||
];
|
||||
|
||||
function CountEntry({
|
||||
habit,
|
||||
disabled,
|
||||
onSave,
|
||||
}: {
|
||||
habit: TodayHabit;
|
||||
disabled: boolean;
|
||||
onSave: (value: number) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(String(habit.value));
|
||||
useEffect(() => setValue(String(habit.value)), [habit.value]);
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (
|
||||
value.trim() &&
|
||||
Number.isInteger(Number(value)) &&
|
||||
Number(value) >= 0 &&
|
||||
Number(value) <= 1_000_000_000
|
||||
)
|
||||
onSave(Number(value));
|
||||
}
|
||||
return (
|
||||
<details className="home-count-entry">
|
||||
<summary>Set a total</summary>
|
||||
<form onSubmit={submit}>
|
||||
<label htmlFor={`count-${habit.habitId}`}>Total {habit.unit}</label>
|
||||
<input
|
||||
id={`count-${habit.habitId}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={1_000_000_000}
|
||||
step={1}
|
||||
required
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
/>
|
||||
<Button type="submit" variant="secondary" disabled={disabled}>
|
||||
Save total
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function Home() {
|
||||
const { user, signOut, busy: authBusy, error: authError, signIn } = useAuth();
|
||||
const [today, setToday] = useState<TodayResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [filter, setFilter] = useState<"all" | "remaining">("all");
|
||||
const [starter, setStarter] = useState<HabitStarter | null>(null);
|
||||
const [editing, setEditing] = useState<{
|
||||
habit: TodayHabit;
|
||||
color: string;
|
||||
} | null>(null);
|
||||
const [deleting, setDeleting] = useState<TodayHabit | null>(null);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [failedAvatar, setFailedAvatar] = useState<string | null>(null);
|
||||
const requestId = useRef(0);
|
||||
const mutation = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const id = ++requestId.current;
|
||||
try {
|
||||
const result = await habitRequest<TodayResponse>("/today");
|
||||
if (mounted.current && id === requestId.current) {
|
||||
setToday(result);
|
||||
setError("");
|
||||
setRevision((value) => value + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted.current && id === requestId.current)
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Could not load your habits.",
|
||||
);
|
||||
} finally {
|
||||
if (mounted.current && id === requestId.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void refresh();
|
||||
const refreshVisible = () => {
|
||||
if (!mutation.current && document.visibilityState !== "hidden")
|
||||
void refresh();
|
||||
};
|
||||
const timer = window.setInterval(refreshVisible, 60_000);
|
||||
window.addEventListener("focus", refreshVisible);
|
||||
document.addEventListener("visibilitychange", refreshVisible);
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
++requestId.current;
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("focus", refreshVisible);
|
||||
document.removeEventListener("visibilitychange", refreshVisible);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
async function log(
|
||||
habit: TodayHabit,
|
||||
value: { count: number } | { done: boolean },
|
||||
taskId?: string,
|
||||
) {
|
||||
if (mutation.current || !today) return;
|
||||
mutation.current = true;
|
||||
++requestId.current;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
const updated = await habitRequest<TodayHabit>(
|
||||
`/habits/${habit.habitId}/days/${today.date}/${taskId ? `tasks/${taskId}` : "progress"}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(value),
|
||||
},
|
||||
);
|
||||
if (!mounted.current) return;
|
||||
setToday((current) => {
|
||||
if (!current || current.date !== updated.date) 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,
|
||||
};
|
||||
});
|
||||
setNotice(`${habit.name} updated. Your progress is saved.`);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
if (mounted.current)
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Could not save progress. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
mutation.current = false;
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
const empty = today?.habits.length === 0;
|
||||
const due = today?.habits.filter((habit) => habit.due) ?? [];
|
||||
const offDay = today?.habits.filter((habit) => !habit.due) ?? [];
|
||||
const shown =
|
||||
filter === "remaining" ? due.filter((habit) => !habit.complete) : due;
|
||||
const remaining = today ? today.due - today.completed : 0;
|
||||
const name = user.displayName || user.username;
|
||||
|
||||
return (
|
||||
<div className="ds-root home-root" id="top">
|
||||
<a className="ds-skip-link" href="#home-main">
|
||||
Skip to content
|
||||
</a>
|
||||
<header className="ds-header home-header">
|
||||
<Link className="ds-wordmark" to="/" aria-label="Minabot home">
|
||||
minabot.
|
||||
</Link>
|
||||
<nav aria-label="Main navigation">
|
||||
<Link to="/" aria-current="page">
|
||||
Home
|
||||
</Link>
|
||||
<a href="#today">Today</a>
|
||||
<a href={empty ? "#first-habit" : "#rhythm"}>
|
||||
{empty ? "Get started" : "Your rhythm"}
|
||||
</a>
|
||||
</nav>
|
||||
<div className="home-account">
|
||||
{user.avatarUrl && failedAvatar !== user.avatarUrl ? (
|
||||
<img
|
||||
className="home-avatar"
|
||||
src={user.avatarUrl}
|
||||
alt={`${name}’s Discord avatar`}
|
||||
width={30}
|
||||
height={30}
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setFailedAvatar(user.avatarUrl)}
|
||||
/>
|
||||
) : (
|
||||
<span className="home-avatar" aria-hidden="true">
|
||||
{name.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="home-account-name" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
<Button
|
||||
variant="text"
|
||||
disabled={authBusy || busy || !!starter}
|
||||
onClick={() => void signOut()}
|
||||
>
|
||||
{authBusy ? "Signing out…" : "Sign out"}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="ds-main" id="home-main">
|
||||
<section className="home-welcome" aria-labelledby="home-title">
|
||||
<div>
|
||||
<p className="ds-eyebrow">YOUR SPACE / YOUR OWN PACE</p>
|
||||
<h1 id="home-title">
|
||||
{empty ? "Welcome home," : "A little today,"}
|
||||
<br />
|
||||
<em>{name}.</em>
|
||||
</h1>
|
||||
<p className="ds-intro-copy">
|
||||
{empty
|
||||
? "This is your space to build a rhythm. Let’s start with one small thing."
|
||||
: "Pick up where you are. Make room for what matters."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-date">
|
||||
<span className="ds-tiny-cross" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
{today ? (
|
||||
<>
|
||||
<time dateTime={today.date}>
|
||||
{formatTrackingDate(today.date)}
|
||||
</time>
|
||||
<p>{today.timezone.replaceAll("_", " ")}</p>
|
||||
</>
|
||||
) : (
|
||||
<p>Your daily home base.</p>
|
||||
)}
|
||||
<span className="ds-footnote">A new day. Your own pace.</span>
|
||||
</div>
|
||||
</section>
|
||||
{authError && (
|
||||
<p className="home-error" role="alert">
|
||||
{authError}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="home-error" role="alert">
|
||||
<p>
|
||||
{error}
|
||||
{today && " The view below may be out of date."}
|
||||
</p>
|
||||
{error.includes("session has expired") ? (
|
||||
<Button variant="secondary" onClick={signIn}>
|
||||
Sign in again
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setLoading(!today);
|
||||
void refresh();
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="home-notice" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
{loading && (
|
||||
<div className="home-state" role="status">
|
||||
Getting your day ready…
|
||||
</div>
|
||||
)}
|
||||
{today && (
|
||||
<>
|
||||
<section className="home-overview" aria-label="Today at a glance">
|
||||
<div>
|
||||
<span className="ds-eyebrow">
|
||||
{empty ? "A FRESH START" : "TODAY’S PROGRESS"}
|
||||
</span>
|
||||
<p className="home-stat">
|
||||
{today.completed}
|
||||
<span> / {today.due}</span>
|
||||
</p>
|
||||
<p>habits complete</p>
|
||||
<progress
|
||||
value={today.completed}
|
||||
max={today.due || 1}
|
||||
aria-label="Habits completed today"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="ds-eyebrow">
|
||||
{empty ? "ONE SMALL STEP" : "STILL TO COME"}
|
||||
</span>
|
||||
<p className="home-stat">
|
||||
{empty ? "01" : String(remaining).padStart(2, "0")}
|
||||
</p>
|
||||
<p>
|
||||
{empty
|
||||
? "is all it takes to begin"
|
||||
: remaining === 1
|
||||
? "habit left for today"
|
||||
: "habits left for today"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-overview-note">
|
||||
<span className="ds-eyebrow">
|
||||
{empty
|
||||
? "NO PERFECT START REQUIRED"
|
||||
: today.due === 0
|
||||
? "ROOM TO REST"
|
||||
: remaining === 0
|
||||
? "ENOUGH FOR TODAY"
|
||||
: "A GENTLE REMINDER"}
|
||||
</span>
|
||||
<h2>
|
||||
{empty ? (
|
||||
<>
|
||||
Small is <em>a good start.</em>
|
||||
</>
|
||||
) : today.due === 0 ? (
|
||||
<>
|
||||
A day off <em>counts, too.</em>
|
||||
</>
|
||||
) : remaining === 0 ? (
|
||||
<>
|
||||
You showed up. <em>Enjoy that.</em>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
A little is <em>still something.</em>
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
<p>
|
||||
{empty
|
||||
? "Choose something easy enough to come back to tomorrow."
|
||||
: today.due === 0
|
||||
? "Nothing is scheduled today. Your habits will be here when it’s time."
|
||||
: remaining === 0
|
||||
? "Everything scheduled for today is complete. No extra credit needed."
|
||||
: "Check in as you go. Every bit of progress belongs here."}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
className="ds-section home-today"
|
||||
id="today"
|
||||
aria-labelledby="today-title"
|
||||
>
|
||||
<div className="ds-section-top">
|
||||
<div>
|
||||
<span className="ds-eyebrow">01 / YOUR DAILY CHECK-IN</span>
|
||||
<h2 id="today-title">
|
||||
{empty ? (
|
||||
<>
|
||||
Begin with <em>a little.</em>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Make today <em>your own.</em>
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
disabled={busy || !!error}
|
||||
onClick={() => setStarter({ name: "", method: "manual" })}
|
||||
>
|
||||
{empty ? "Create your first habit" : "New habit"}
|
||||
<span aria-hidden="true">+</span>
|
||||
</Button>
|
||||
</div>
|
||||
{empty ? (
|
||||
<div id="first-habit" className="home-empty">
|
||||
<p>No habits yet. No catching up to do.</p>
|
||||
<p className="ds-muted">
|
||||
Create your own, or use an idea below as a starting point.
|
||||
You can make it yours before saving.
|
||||
</p>
|
||||
<div className="home-starters">
|
||||
{starters.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.name}
|
||||
disabled={!!error}
|
||||
onClick={() => setStarter(item)}
|
||||
>
|
||||
<span
|
||||
className="home-starter-symbol"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{item.symbol}
|
||||
</span>
|
||||
<h3>{item.name}</h3>
|
||||
<p>{item.description}</p>
|
||||
<span className="home-starter-action">
|
||||
Make it mine <span aria-hidden="true">↗</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ds-preview-toolbar">
|
||||
<div
|
||||
className="ds-view-switch"
|
||||
role="group"
|
||||
aria-label="Today’s habits"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={filter === "all"}
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
All today <span>{today.due}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={filter === "remaining"}
|
||||
onClick={() => setFilter("remaining")}
|
||||
>
|
||||
Remaining <span>{remaining}</span>
|
||||
</button>
|
||||
</div>
|
||||
<span className="home-autosave">Saved as you go</span>
|
||||
</div>
|
||||
{shown.length === 0 && (
|
||||
<div className="home-state">
|
||||
<h3>
|
||||
{today.due === 0
|
||||
? "A little breathing room."
|
||||
: "You’re all caught up."}
|
||||
</h3>
|
||||
<p>
|
||||
{today.due === 0
|
||||
? "No habits are scheduled for today. A day off isn’t a missed day."
|
||||
: "All of today’s habits are complete. Take a moment for yourself."}
|
||||
</p>
|
||||
{filter === "remaining" && today.due > 0 && (
|
||||
<Button variant="text" onClick={() => setFilter("all")}>
|
||||
View completed habits →
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="home-habits">
|
||||
{shown.map((habit) => (
|
||||
<article
|
||||
className={`home-habit${habit.complete ? " home-habit--complete" : ""}`}
|
||||
key={habit.habitId}
|
||||
aria-labelledby={`habit-${habit.habitId}`}
|
||||
>
|
||||
<div className="home-habit-heading">
|
||||
<div>
|
||||
<h3 id={`habit-${habit.habitId}`}>{habit.name}</h3>
|
||||
<p className="home-habit-status">
|
||||
{habit.method === "manual"
|
||||
? "SIMPLE CHECK-IN"
|
||||
: habit.method === "count"
|
||||
? "COUNT TARGET"
|
||||
: "TASK ROUTINE"}{" "}
|
||||
· {scheduleLabel(habit.requirements?.schedule)}
|
||||
{habit.complete && " · Complete"}
|
||||
{habit.carriedFrom && (
|
||||
<span>
|
||||
{" "}
|
||||
· Includes progress from {habit.carriedFrom}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-check-in">
|
||||
{habit.method === "manual" ? (
|
||||
<Checkbox
|
||||
label="Done"
|
||||
aria-label={`Mark ${habit.name} complete`}
|
||||
checked={habit.complete}
|
||||
disabled={busy || !!error}
|
||||
onChange={(event) =>
|
||||
void log(habit, {
|
||||
done: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : habit.method === "count" ? (
|
||||
<Counter
|
||||
label={`${habit.name} (${habit.unit})`}
|
||||
value={habit.value}
|
||||
target={habit.target ?? 1}
|
||||
disabled={busy || !!error}
|
||||
onChange={(count) => void log(habit, { count })}
|
||||
/>
|
||||
) : (
|
||||
<span className="home-task-count">
|
||||
{habit.value}
|
||||
<span> / {habit.target}</span>
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
className="home-history-link"
|
||||
href={`#history-${habit.habitId}`}
|
||||
aria-label={`View ${habit.name} history`}
|
||||
>
|
||||
↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{habit.method === "tasks" && (
|
||||
<details className="home-task-details">
|
||||
<summary>
|
||||
Tasks for today{" "}
|
||||
<span>
|
||||
{habit.value} / {habit.target}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="home-tasks">
|
||||
{habit.tasks.map((task) => (
|
||||
<Checkbox
|
||||
key={task.taskId}
|
||||
label={task.name}
|
||||
checked={task.done}
|
||||
disabled={busy || !!error}
|
||||
onChange={(event) =>
|
||||
void log(
|
||||
habit,
|
||||
{ done: event.target.checked },
|
||||
task.taskId,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{habit.method === "count" && (
|
||||
<CountEntry
|
||||
habit={habit}
|
||||
disabled={busy || !!error}
|
||||
onSave={(count) => void log(habit, { count })}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{offDay.length > 0 && (
|
||||
<details className="home-off-day">
|
||||
<summary>
|
||||
Not scheduled today <span>{offDay.length}</span>
|
||||
</summary>
|
||||
<p className="ds-footnote">
|
||||
These habits aren’t included in today’s progress.
|
||||
</p>
|
||||
{offDay.map((habit) => (
|
||||
<div key={habit.habitId}>
|
||||
<span>
|
||||
<strong>{habit.name}</strong>
|
||||
<small>
|
||||
{scheduleLabel(habit.requirements?.schedule)}
|
||||
{habit.method === "tasks" &&
|
||||
" · No tasks due today"}
|
||||
</small>
|
||||
</span>
|
||||
<a href={`#history-${habit.habitId}`}>
|
||||
View history ↗
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
{!empty && (
|
||||
<section
|
||||
className="ds-section home-rhythm"
|
||||
id="rhythm"
|
||||
aria-labelledby="rhythm-title"
|
||||
>
|
||||
<div className="ds-section-top">
|
||||
<div>
|
||||
<span className="ds-eyebrow">02 / THE BIGGER PICTURE</span>
|
||||
<h2 id="rhythm-title">
|
||||
Find <em>your rhythm.</em>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ds-habit-chart-grid">
|
||||
{today.habits.map((habit) => (
|
||||
<HabitHistory
|
||||
key={habit.habitId}
|
||||
habit={habit}
|
||||
date={today.date}
|
||||
revision={revision}
|
||||
disabled={busy || !!error}
|
||||
onEdit={(color) => setEditing({ habit, color })}
|
||||
onDelete={() => setDeleting(habit)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="ds-footnote">
|
||||
Your real progress, one day at a time. Select a date to look
|
||||
closer. Days off stay out of your score.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<footer className="ds-footer home-footer">
|
||||
<Link className="ds-wordmark" to="/">
|
||||
minabot.
|
||||
</Link>
|
||||
<span>A little, every day.</span>
|
||||
<a href="#top">Back to top ↑</a>
|
||||
</footer>
|
||||
</main>
|
||||
{starter && today && (
|
||||
<CreateHabit
|
||||
date={today.date}
|
||||
starter={starter}
|
||||
onClose={() => setStarter(null)}
|
||||
onCreated={(name) => {
|
||||
setStarter(null);
|
||||
setNotice(`${name} is ready. Your rhythm starts here.`);
|
||||
setFilter("all");
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{editing?.habit.requirements && today && (
|
||||
<CreateHabit
|
||||
date={today.date}
|
||||
editing={{
|
||||
id: editing.habit.habitId,
|
||||
config: editing.habit.requirements,
|
||||
}}
|
||||
starter={{
|
||||
name: editing.habit.requirements.name,
|
||||
method: editing.habit.requirements.method,
|
||||
color: editing.color,
|
||||
...(editing.habit.requirements.method === "count"
|
||||
? {
|
||||
target: editing.habit.requirements.target,
|
||||
unit: editing.habit.requirements.unit,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
onClose={() => setEditing(null)}
|
||||
onCreated={(name) => {
|
||||
setEditing(null);
|
||||
setNotice(`${name} updated. Your earlier history is kept.`);
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{deleting && (
|
||||
<DeleteHabit
|
||||
habit={deleting}
|
||||
onClose={() => setDeleting(null)}
|
||||
onDeleted={() => {
|
||||
setNotice(
|
||||
`${deleting.name} removed from your dashboard. Recorded history is kept.`,
|
||||
);
|
||||
// Reflect a successful deletion even if the following refresh fails.
|
||||
setToday((current) => {
|
||||
if (!current) return current;
|
||||
const habits = current.habits.filter(
|
||||
(habit) => habit.habitId !== deleting.habitId,
|
||||
);
|
||||
return {
|
||||
...current,
|
||||
habits,
|
||||
due: habits.filter((habit) => habit.due).length,
|
||||
completed: habits.filter((habit) => habit.complete).length,
|
||||
};
|
||||
});
|
||||
setDeleting(null);
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { Link } from "react-router";
|
||||
import { useAuth } from "../components/AuthProvider";
|
||||
import { Button } from "../components/design-system/primitives";
|
||||
import { LandingDemo } from "../components/LandingDemo";
|
||||
import "../../styles/design-system.css";
|
||||
import "../../styles/landing.css";
|
||||
|
||||
export function Landing() {
|
||||
const { signIn, error } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="ds-root landing-root" id="top">
|
||||
<a className="ds-skip-link" href="#landing-main">Skip to content</a>
|
||||
<header className="ds-header landing-header">
|
||||
<Link className="ds-wordmark" to="/" aria-label="Minabot home">minabot<span aria-hidden="true">.</span></Link>
|
||||
<nav aria-label="Main navigation">
|
||||
<a href="#demo">Try it out</a>
|
||||
<a href="#how-it-works">How it works</a>
|
||||
<a href="#discord">Discord</a>
|
||||
</nav>
|
||||
<Button variant="secondary" onClick={signIn}>Sign in <span aria-hidden="true">↗</span></Button>
|
||||
</header>
|
||||
<main className="ds-main" id="landing-main">
|
||||
{error && <p className="landing-auth-error" role="alert">{error}</p>}
|
||||
<section className="ds-intro landing-hero" aria-labelledby="landing-title">
|
||||
<div>
|
||||
<p className="ds-eyebrow">YOUR HABITS. YOUR OWN PACE.</p>
|
||||
<h1 id="landing-title">A little today.<br /><em>A rhythm for life.</em></h1>
|
||||
<p className="ds-intro-copy">A quiet place to track your habits, notice your progress,<br className="landing-desktop-break" /> and keep showing up for the things that matter.</p>
|
||||
<div className="landing-hero-actions">
|
||||
<Button onClick={signIn}>Sign in with Discord <span aria-hidden="true">↗</span></Button>
|
||||
<a className="landing-text-link" href="#demo">Try the demo <span aria-hidden="true">↓</span></a>
|
||||
</div>
|
||||
<p className="landing-signin-note">Your Discord account. One less password.</p>
|
||||
</div>
|
||||
<aside className="ds-intro-note landing-hero-note" aria-label="Our approach">
|
||||
<span className="ds-tiny-cross" aria-hidden="true">+</span>
|
||||
<p>Not a perfect streak.<br />Not another competition.<br />Just a little more intention.</p>
|
||||
<a href="#how-it-works">Find your rhythm <span aria-hidden="true">↓</span></a>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<LandingDemo />
|
||||
|
||||
<section className="ds-section landing-how" id="how-it-works" aria-labelledby="how-title">
|
||||
<div className="ds-section-top">
|
||||
<div>
|
||||
<span className="ds-eyebrow">02 / HOW IT WORKS</span>
|
||||
<h2 id="how-title">A habit, <em>not a project.</em></h2>
|
||||
</div>
|
||||
</div>
|
||||
<ol className="landing-steps">
|
||||
<li>
|
||||
<span className="ds-eyebrow" aria-hidden="true">01 — MAKE IT YOURS</span>
|
||||
<h3>Start with something small.</h3>
|
||||
<p>A glass of water. A few pages. A calmer evening. Choose a simple checkbox, a count target, or a short list of tasks, with a schedule that fits your days.</p>
|
||||
</li>
|
||||
<li>
|
||||
<span className="ds-eyebrow" aria-hidden="true">02 — SHOW UP</span>
|
||||
<h3>Log a little along the way.</h3>
|
||||
<p>Check it off, add to your count, or finish a task. Each habit keeps its own progress, and daily boundaries follow your saved timezone.</p>
|
||||
</li>
|
||||
<li>
|
||||
<span className="ds-eyebrow" aria-hidden="true">03 — SEE YOUR RHYTHM</span>
|
||||
<h3>Let the days tell the story.</h3>
|
||||
<p>A color for every habit. A square for every day. Look closer at one habit or bring them together in a combined calendar. A day off isn’t a missed day.</p>
|
||||
</li>
|
||||
</ol>
|
||||
<p className="ds-footnote">Explore the demo above, then sign in to create your own habits and keep your progress in one place.</p>
|
||||
</section>
|
||||
|
||||
<section className="ds-section landing-discord" id="discord" aria-labelledby="discord-title">
|
||||
<div>
|
||||
<span className="ds-eyebrow">03 / CONNECTED WITH DISCORD</span>
|
||||
<h2 id="discord-title">One familiar account.<br /><em>A little space for you.</em></h2>
|
||||
</div>
|
||||
<div className="landing-discord-copy">
|
||||
<p>Start with the Discord account you already use. Minabot connects your Discord profile to your own account here, without another password to remember.</p>
|
||||
<dl className="landing-discord-details">
|
||||
<div><dt>Your profile, connected.</dt><dd>Sign-in uses your Discord identity, not access to your messages or email.</dd></div>
|
||||
<div><dt>No server setup.</dt><dd>You don’t need to add a bot to a server to sign in. Habit tracking lives here on the web.</dd></div>
|
||||
</dl>
|
||||
<p className="ds-footnote">Discord integration currently covers sign-in. Bot commands and reminders aren’t available yet.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ds-section landing-closing" aria-labelledby="closing-title">
|
||||
<div>
|
||||
<p className="ds-eyebrow">NO PERFECT START REQUIRED</p>
|
||||
<h2 id="closing-title">Begin with <em>a little.</em></h2>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={signIn}>Sign in with Discord <span aria-hidden="true">↗</span></Button>
|
||||
<p className="landing-signin-note">Or explore the demo. No account needed.</p>
|
||||
</div>
|
||||
</section>
|
||||
<footer className="ds-footer landing-footer">
|
||||
<Link className="ds-wordmark" to="/">minabot.</Link>
|
||||
<span>A little, every day.</span>
|
||||
<a href="#top">Back to top ↑</a>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function Settings() {
|
||||
return <h1>Settings</h1>;
|
||||
}
|
||||
Reference in New Issue
Block a user