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:
692
src/App.test.tsx
Normal file
692
src/App.test.tsx
Normal 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 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");
|
||||
});
|
||||
});
|
||||
28
src/App.tsx
28
src/App.tsx
@@ -3,10 +3,37 @@ import { AuthControls } from "./components/AuthControls";
|
||||
import { Home } from "./pages/Home";
|
||||
import { About } from "./pages/About";
|
||||
import { Settings } from "./pages/Settings";
|
||||
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 (
|
||||
<>
|
||||
@@ -14,6 +41,7 @@ export function App() {
|
||||
<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>
|
||||
|
||||
BIN
src/assets/design/habit-detail.png
Normal file
BIN
src/assets/design/habit-detail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 750 KiB |
BIN
src/assets/design/today-v2.png
Normal file
BIN
src/assets/design/today-v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
src/assets/design/today.png
Normal file
BIN
src/assets/design/today.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 812 KiB |
BIN
src/assets/fonts/InstrumentSerif-Italic.ttf
Normal file
BIN
src/assets/fonts/InstrumentSerif-Italic.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/InstrumentSerif-Regular.ttf
Normal file
BIN
src/assets/fonts/InstrumentSerif-Regular.ttf
Normal file
Binary file not shown.
93
src/assets/fonts/OFL.txt
Normal file
93
src/assets/fonts/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,58 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import type { PublicUser } from "../shared/user";
|
||||
|
||||
const authErrors: Record<string, string> = {
|
||||
not_configured: "Discord sign-in is not configured yet.",
|
||||
invalid_state: "Your sign-in attempt expired or could not be verified. Please try again.",
|
||||
denied: "Discord sign-in was cancelled. You can try again when ready.",
|
||||
invalid_code: "Discord did not return a sign-in code. Please try again.",
|
||||
discord_unavailable: "Could not complete Discord sign-in. Please try again.",
|
||||
};
|
||||
import { useAuth } from "./AuthProvider";
|
||||
|
||||
export function AuthControls() {
|
||||
const [user, setUser] = useState<PublicUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const { search } = useLocation();
|
||||
const signInError = authErrors[new URLSearchParams(search).get("auth_error") ?? ""];
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
async function loadUser() {
|
||||
try {
|
||||
const response = await fetch("/api/me", { signal: controller.signal });
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) throw new Error("Could not load your account. Please reload to try again.");
|
||||
setUser(await response.json());
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) setError(error instanceof Error ? error.message : "Could not load your account.");
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
}
|
||||
void loadUser();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
function signIn() {
|
||||
let timezone = "UTC";
|
||||
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
|
||||
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/auth/logout", { method: "POST" });
|
||||
if (!response.ok) throw new Error("Could not sign out. Please try again.");
|
||||
setUser(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : "Could not sign out.");
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();
|
||||
|
||||
return (
|
||||
<section aria-label="Account">
|
||||
@@ -62,8 +11,9 @@ export function AuthControls() {
|
||||
<button type="button" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</button>{" "}
|
||||
<a href="/api/me">View my profile</a>
|
||||
</p>
|
||||
) : <p><button type="button" onClick={signIn}>Sign in with Discord</button></p>}
|
||||
{(error || signInError) && <p role="alert">{error || signInError}</p>}
|
||||
) : !accountError && <p><button type="button" onClick={signIn}>Sign in with Discord</button></p>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{accountError && <button type="button" onClick={retry}>Try again</button>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
91
src/components/AuthProvider.tsx
Normal file
91
src/components/AuthProvider.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import type { PublicUser } from "../shared/user";
|
||||
|
||||
const authErrors: Record<string, string> = {
|
||||
not_configured: "Discord sign-in is not configured yet.",
|
||||
invalid_state: "Your sign-in attempt expired or could not be verified. Please try again.",
|
||||
denied: "Discord sign-in was cancelled. You can try again when ready.",
|
||||
invalid_code: "Discord did not return a sign-in code. Please try again.",
|
||||
discord_unavailable: "Could not complete Discord sign-in. Please try again.",
|
||||
};
|
||||
|
||||
function signIn() {
|
||||
let timezone = "UTC";
|
||||
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
|
||||
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
|
||||
}
|
||||
|
||||
type AuthState = {
|
||||
user: PublicUser | null;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
accountError: string;
|
||||
error: string;
|
||||
signIn: () => void;
|
||||
signOut: () => Promise<void>;
|
||||
retry: () => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthState | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<PublicUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [accountError, setAccountError] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const { search } = useLocation();
|
||||
const signInError = authErrors[new URLSearchParams(search).get("auth_error") ?? ""];
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
async function loadUser() {
|
||||
try {
|
||||
const response = await fetch("/api/me", { signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
if (response.status === 401) { setUser(null); return; }
|
||||
if (!response.ok) throw new Error("Could not load your account. Please try again.");
|
||||
const account: PublicUser = await response.json();
|
||||
if (!controller.signal.aborted) setUser(account);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) setAccountError(error instanceof Error ? error.message : "Could not load your account.");
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
}
|
||||
void loadUser();
|
||||
return () => controller.abort();
|
||||
}, [attempt]);
|
||||
|
||||
function retry() {
|
||||
setAccountError("");
|
||||
setLoading(true);
|
||||
setAttempt((current) => current + 1);
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
setBusy(true);
|
||||
setActionError("");
|
||||
try {
|
||||
const response = await fetch("/api/auth/logout", { method: "POST" });
|
||||
if (!response.ok) throw new Error("Could not sign out. Please try again.");
|
||||
setUser(null);
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : "Could not sign out.");
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, busy, accountError, error: accountError || actionError || signInError || "", signIn, signOut, retry }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const auth = useContext(AuthContext);
|
||||
if (!auth) throw new Error("useAuth must be used within AuthProvider");
|
||||
return auth;
|
||||
}
|
||||
261
src/components/CreateHabit.tsx
Normal file
261
src/components/CreateHabit.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { Button } from "./design-system/primitives";
|
||||
import { ScheduleEditor } from "./design-system/EditingWorkbench";
|
||||
import {
|
||||
habitInput,
|
||||
habitPatch,
|
||||
type HabitConfig,
|
||||
type Schedule,
|
||||
} from "../habits/contracts";
|
||||
import { habitRequest } from "../lib/dashboard";
|
||||
import { HabitColorPicker } from "./design-system/HabitColorPicker";
|
||||
|
||||
export type HabitStarter = {
|
||||
name: string;
|
||||
method: "manual" | "count" | "tasks";
|
||||
target?: number;
|
||||
unit?: string;
|
||||
tasks?: string;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export function CreateHabit({
|
||||
date,
|
||||
starter,
|
||||
onClose,
|
||||
onCreated,
|
||||
editing,
|
||||
}: {
|
||||
date: string;
|
||||
starter: HabitStarter;
|
||||
onClose: () => void;
|
||||
onCreated: (name: string) => void;
|
||||
editing?: { id: string; config: HabitConfig };
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const saving = useRef(false);
|
||||
const [name, setName] = useState(starter.name);
|
||||
const [method, setMethod] = useState(starter.method);
|
||||
const [target, setTarget] = useState(starter.target ?? 8);
|
||||
const [unit, setUnit] = useState(starter.unit ?? "glasses");
|
||||
const [tasks, setTasks] = useState(starter.tasks ?? "");
|
||||
const [color, setColor] = useState(starter.color ?? "#58765b");
|
||||
const [schedule, setSchedule] = useState<Schedule>(
|
||||
editing?.config.schedule ?? { type: "daily" },
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
dialog.current?.showModal();
|
||||
dialog.current?.querySelector<HTMLInputElement>("#habit-name")?.focus();
|
||||
return () => {
|
||||
previous?.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (saving.current) return;
|
||||
const taskNames = tasks
|
||||
.split("\n")
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean);
|
||||
if (!editing && method === "tasks" && !taskNames.length) {
|
||||
setError("Add at least one task to get started.");
|
||||
return;
|
||||
}
|
||||
const input = {
|
||||
name,
|
||||
method,
|
||||
schedule,
|
||||
color,
|
||||
...(method === "count"
|
||||
? { target, unit }
|
||||
: method === "tasks" && !editing
|
||||
? { tasks: taskNames.map((name) => ({ name })) }
|
||||
: {}),
|
||||
};
|
||||
const parsed = editing
|
||||
? habitPatch.safeParse(input)
|
||||
: habitInput.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues.map((issue) => issue.message).join(" "));
|
||||
return;
|
||||
}
|
||||
saving.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await habitRequest(editing ? `/habits/${editing.id}` : "/habits", {
|
||||
method: editing ? "PATCH" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(parsed.data),
|
||||
});
|
||||
onCreated(parsed.data.name!);
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Could not save your habit.",
|
||||
);
|
||||
saving.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="home-create"
|
||||
aria-labelledby="create-title"
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
if (!saving.current) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="home-create-heading">
|
||||
<p className="ds-eyebrow">
|
||||
{editing ? "SHAPE YOUR RHYTHM" : "A LITTLE ROOM FOR SOMETHING GOOD"}
|
||||
</p>
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label={editing ? "Close habit editor" : "Close new habit"}
|
||||
disabled={busy}
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<h2 id="create-title">
|
||||
{editing ? (
|
||||
<>
|
||||
Edit your <em>habit.</em>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Make it <em>yours.</em>
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
<p className="ds-muted">
|
||||
{editing
|
||||
? "Changes start today. Earlier targets and schedules stay as they were."
|
||||
: "Start small. You don’t need a perfect plan."}
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
<fieldset disabled={busy} className="home-form-fields">
|
||||
<div className="ds-field">
|
||||
<label htmlFor="habit-name">Habit name</label>
|
||||
<input
|
||||
id="habit-name"
|
||||
required
|
||||
maxLength={200}
|
||||
placeholder="Something you want to make time for"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ds-field">
|
||||
<label htmlFor="habit-method">How will you track it?</label>
|
||||
<select
|
||||
id="habit-method"
|
||||
disabled={!!editing}
|
||||
value={method}
|
||||
onChange={(event) =>
|
||||
setMethod(event.target.value as HabitStarter["method"])
|
||||
}
|
||||
>
|
||||
<option value="manual">A simple check-in</option>
|
||||
<option value="count">A count target</option>
|
||||
<option value="tasks">A list of tasks</option>
|
||||
</select>
|
||||
{editing && (
|
||||
<small>
|
||||
Tracking method stays the same. Create another habit to track it
|
||||
differently.
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
{method === "count" && (
|
||||
<div className="ds-form-grid">
|
||||
<div className="ds-field">
|
||||
<label htmlFor="habit-target">Daily target</label>
|
||||
<input
|
||||
id="habit-target"
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
max={10000}
|
||||
step={1}
|
||||
value={Number.isNaN(target) ? "" : target}
|
||||
onChange={(event) => setTarget(event.target.valueAsNumber)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ds-field">
|
||||
<label htmlFor="habit-unit">Unit</label>
|
||||
<input
|
||||
id="habit-unit"
|
||||
required
|
||||
maxLength={80}
|
||||
value={unit}
|
||||
onChange={(event) => setUnit(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{method === "tasks" && !editing && (
|
||||
<div className="ds-field">
|
||||
<label htmlFor="habit-tasks">Tasks · one per line</label>
|
||||
<textarea
|
||||
id="habit-tasks"
|
||||
required
|
||||
rows={4}
|
||||
value={tasks}
|
||||
onChange={(event) => setTasks(event.target.value)}
|
||||
placeholder={"Clear desk\nPlan tomorrow\nStretch"}
|
||||
/>
|
||||
<small>
|
||||
These tasks repeat on each scheduled day. Up to 100 tasks.
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
<ScheduleEditor
|
||||
value={schedule}
|
||||
onChange={setSchedule}
|
||||
anchorDate={date}
|
||||
/>
|
||||
{method === "tasks" && editing && (
|
||||
<p className="ds-footnote">
|
||||
Your existing tasks and their individual schedules are kept.
|
||||
</p>
|
||||
)}
|
||||
<HabitColorPicker
|
||||
value={color}
|
||||
onChange={setColor}
|
||||
mode={editing ? "edit" : "create"}
|
||||
/>
|
||||
</fieldset>
|
||||
{error && (
|
||||
<p className="home-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="home-form-actions">
|
||||
<Button variant="secondary" disabled={busy} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={busy}>
|
||||
{busy
|
||||
? editing
|
||||
? "Saving…"
|
||||
: "Creating…"
|
||||
: editing
|
||||
? "Save changes"
|
||||
: "Create habit"}{" "}
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
73
src/components/DeleteHabit.tsx
Normal file
73
src/components/DeleteHabit.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { habitRequest, type TodayHabit } from "../lib/dashboard";
|
||||
import { Button } from "./design-system/primitives";
|
||||
|
||||
export function DeleteHabit({
|
||||
habit,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: {
|
||||
habit: TodayHabit;
|
||||
onClose: () => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const saving = useRef(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
dialog.current?.showModal();
|
||||
return () => previous?.focus();
|
||||
}, []);
|
||||
async function remove() {
|
||||
if (saving.current) return;
|
||||
saving.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await habitRequest(`/habits/${habit.habitId}`, { method: "DELETE" });
|
||||
onDeleted();
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Could not delete your habit.",
|
||||
);
|
||||
saving.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="home-create home-delete"
|
||||
aria-labelledby="delete-title"
|
||||
aria-describedby="delete-description"
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
if (!saving.current) onClose();
|
||||
}}
|
||||
>
|
||||
<p className="ds-eyebrow">MAKE A LITTLE ROOM</p>
|
||||
<h2 id="delete-title">
|
||||
Delete this <em>habit?</em>
|
||||
</h2>
|
||||
<p id="delete-description" className="ds-muted">
|
||||
<strong>{habit.name}</strong> will leave your dashboard and stop future
|
||||
check-ins. Your recorded history is kept, not permanently erased.
|
||||
</p>
|
||||
{error && (
|
||||
<p className="home-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="home-form-actions">
|
||||
<Button variant="secondary" disabled={busy} onClick={onClose} autoFocus>
|
||||
Keep habit
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={() => void remove()}>
|
||||
{busy ? "Deleting…" : "Delete habit"}
|
||||
</Button>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
156
src/components/HabitHistory.tsx
Normal file
156
src/components/HabitHistory.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { CalendarResponse } from "../shared/calendar";
|
||||
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
||||
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
|
||||
import { monthWindow } from "./design-system/calendar-model";
|
||||
import { Button } from "./design-system/primitives";
|
||||
import { HabitChart } from "./design-system/HabitChart";
|
||||
import { CalendarLegend } from "./design-system/CalendarLegend";
|
||||
import { shade } from "../habits/calendar";
|
||||
|
||||
export function HabitHistory({
|
||||
habit,
|
||||
date,
|
||||
revision,
|
||||
onEdit,
|
||||
onDelete,
|
||||
disabled = false,
|
||||
}: {
|
||||
habit: TodayHabit;
|
||||
date: string;
|
||||
revision: number;
|
||||
onEdit: (color: string) => void;
|
||||
onDelete: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setError("");
|
||||
const range = monthWindow(date, 12);
|
||||
habitRequest<CalendarResponse>(
|
||||
`/habits/${habit.habitId}/calendar?${new URLSearchParams(range)}`,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((result) => {
|
||||
if (!controller.signal.aborted) setCalendar(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) setError(error.message);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [habit.habitId, date, revision, attempt]);
|
||||
|
||||
const days =
|
||||
calendar?.days.map((day) => ({
|
||||
date: day.date,
|
||||
color: day.color,
|
||||
unit: day.habits[0]?.unit,
|
||||
value: day.habits[0]?.value ?? 0,
|
||||
target: day.habits[0]?.target ?? 0,
|
||||
state: day.future
|
||||
? ("future" as const)
|
||||
: day.due
|
||||
? ("due" as const)
|
||||
: ("not-due" as const),
|
||||
})) ?? [];
|
||||
const steps = Math.max(
|
||||
1,
|
||||
calendar?.days.find((day) => day.date === date)?.shadeCount ??
|
||||
habit.target ??
|
||||
1,
|
||||
);
|
||||
const shades = calendar
|
||||
? [
|
||||
calendar.settings.emptyColor,
|
||||
...Array.from(
|
||||
{ length: Math.min(8, steps) },
|
||||
(_, index) =>
|
||||
shade(
|
||||
Math.ceil(((index + 1) * steps) / Math.min(8, steps)) / steps,
|
||||
steps,
|
||||
calendar.settings,
|
||||
true,
|
||||
).color,
|
||||
),
|
||||
]
|
||||
: [];
|
||||
const chart = error ? (
|
||||
<div className="home-state">
|
||||
<p role="alert">{error}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setAttempt((value) => value + 1)}
|
||||
>
|
||||
Retry history
|
||||
</Button>
|
||||
</div>
|
||||
) : !calendar ? (
|
||||
<p className="home-state" role="status">
|
||||
Loading your habit history…
|
||||
</p>
|
||||
) : (
|
||||
<CalendarHeatmap
|
||||
compact
|
||||
historyLabel="Your recorded progress"
|
||||
label={`${habit.name} progress calendar`}
|
||||
unit={habit.unit}
|
||||
color={calendar.settings.mainColor}
|
||||
emptyColor={calendar.settings.emptyColor}
|
||||
days={days}
|
||||
legend={
|
||||
<CalendarLegend
|
||||
label={`${habit.name} progress calendar`}
|
||||
days={days}
|
||||
color={calendar.settings.mainColor}
|
||||
shades={shades}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<HabitChart
|
||||
id={`history-${habit.habitId}`}
|
||||
name={habit.name ?? "Habit"}
|
||||
method={
|
||||
habit.method === "count"
|
||||
? "Count target"
|
||||
: habit.method === "tasks"
|
||||
? "Task-based"
|
||||
: "Simple check-in"
|
||||
}
|
||||
schedule={scheduleLabel(habit.requirements?.schedule)}
|
||||
due={habit.due}
|
||||
value={habit.value}
|
||||
target={habit.target ?? 0}
|
||||
unit={habit.unit}
|
||||
color={calendar?.settings.mainColor ?? "#196127"}
|
||||
calendar={chart}
|
||||
>
|
||||
<div
|
||||
className="home-habit-actions"
|
||||
role="group"
|
||||
aria-label={`${habit.name} actions`}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label={`Edit ${habit.name}`}
|
||||
disabled={disabled || !calendar}
|
||||
onClick={() => onEdit(calendar!.settings.mainColor)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label={`Delete ${habit.name}`}
|
||||
disabled={disabled}
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</HabitChart>
|
||||
);
|
||||
}
|
||||
67
src/components/LandingDemo.tsx
Normal file
67
src/components/LandingDemo.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
245
src/components/design-system/CalendarHeatmap.tsx
Normal file
245
src/components/design-system/CalendarHeatmap.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { CalendarLegend } from "./CalendarLegend";
|
||||
import {
|
||||
describeDay,
|
||||
progressShade,
|
||||
MONTH_VIEWS,
|
||||
visibleCalendarDays,
|
||||
type CalendarDay,
|
||||
type MonthView,
|
||||
} from "./calendar-model";
|
||||
|
||||
export function CalendarHeatmap({
|
||||
days: allDays,
|
||||
unit,
|
||||
label,
|
||||
color = "#111111",
|
||||
emptyColor = "#eeeeee",
|
||||
compact = false,
|
||||
selectedDate: controlledDate,
|
||||
onSelectDate,
|
||||
historyLabel,
|
||||
legend,
|
||||
}: {
|
||||
days: CalendarDay[];
|
||||
unit: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
emptyColor?: string;
|
||||
compact?: boolean;
|
||||
selectedDate?: string;
|
||||
onSelectDate?: (date: string) => void;
|
||||
historyLabel?: string;
|
||||
legend?: ReactNode;
|
||||
}) {
|
||||
const [months, setMonths] = useState<MonthView>(6);
|
||||
const latestDate =
|
||||
allDays.findLast((day) => day.state !== "future")?.date ??
|
||||
allDays.at(-1)?.date;
|
||||
// A date selected in the editor must stay visible even outside the current window.
|
||||
const anchor =
|
||||
controlledDate && allDays.some((day) => day.date === controlledDate)
|
||||
? controlledDate
|
||||
: latestDate;
|
||||
const [localSelectedDate, setLocalSelectedDate] = useState(anchor);
|
||||
const selectedDate = controlledDate ?? localSelectedDate;
|
||||
function setSelectedDate(date: string) {
|
||||
if (onSelectDate) onSelectDate(date);
|
||||
else setLocalSelectedDate(date);
|
||||
}
|
||||
const days = anchor ? visibleCalendarDays(allDays, months, anchor) : [];
|
||||
const selected =
|
||||
days.find((day) => day.date === selectedDate) ??
|
||||
days.find((day) => day.date === anchor) ??
|
||||
days[0];
|
||||
const offset = days[0]
|
||||
? new Date(`${days[0].date}T12:00:00Z`).getUTCDay()
|
||||
: 0;
|
||||
const weeks = Math.ceil((days.length + offset) / 7);
|
||||
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const scrollArea = useRef<HTMLDivElement | null>(null);
|
||||
const inspectorId = useId();
|
||||
const selectedIndex = selected ? days.indexOf(selected) : -1;
|
||||
useEffect(() => {
|
||||
const container = scrollArea.current;
|
||||
const button = buttons.current[selectedIndex];
|
||||
if (!container || !button) return;
|
||||
const cellBounds = button.getBoundingClientRect();
|
||||
const bounds = container.getBoundingClientRect();
|
||||
if (cellBounds.right > bounds.right - 5)
|
||||
container.scrollLeft += cellBounds.right - bounds.right + 5;
|
||||
else if (cellBounds.left < bounds.left + 5)
|
||||
container.scrollLeft += cellBounds.left - bounds.left - 5;
|
||||
}, [months, selectedIndex]);
|
||||
if (!selected) return <p>No calendar dates to display.</p>;
|
||||
return (
|
||||
<div
|
||||
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
|
||||
data-months={months}
|
||||
>
|
||||
<div className="ds-calendar-range-toolbar">
|
||||
<span className="ds-calendar-range-label" aria-live="polite">
|
||||
{new Date(`${days[0]!.date}T12:00:00Z`).toLocaleDateString("en", {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}{" "}
|
||||
–{" "}
|
||||
{new Date(`${days.at(-1)!.date}T12:00:00Z`).toLocaleDateString("en", {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
</span>
|
||||
<div
|
||||
className="ds-month-views"
|
||||
role="group"
|
||||
aria-label={`${label} time range`}
|
||||
>
|
||||
{MONTH_VIEWS.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-label={`${value} months`}
|
||||
aria-pressed={months === value}
|
||||
onClick={() => setMonths(value)}
|
||||
>
|
||||
{value}m
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollArea}
|
||||
className="ds-calendar-scroll"
|
||||
role="group"
|
||||
aria-label={label}
|
||||
>
|
||||
<div
|
||||
className="ds-calendar-inner"
|
||||
style={{
|
||||
minWidth:
|
||||
months === 12
|
||||
? 0
|
||||
: Math.max(compact ? 280 : 600, weeks * 14 + 38),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="ds-calendar-months"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${weeks}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{days.map(
|
||||
(day, index) =>
|
||||
(index === 0 || day.date.endsWith("-01")) && (
|
||||
<span
|
||||
key={day.date}
|
||||
style={{ gridColumn: Math.floor((index + offset) / 7) + 1 }}
|
||||
>
|
||||
{new Date(`${day.date}T12:00:00Z`).toLocaleDateString(
|
||||
"en",
|
||||
{ month: "short", timeZone: "UTC" },
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="ds-calendar-body">
|
||||
<div className="ds-calendar-weekdays" aria-hidden="true">
|
||||
<span>Mon</span>
|
||||
<span>Wed</span>
|
||||
<span>Fri</span>
|
||||
</div>
|
||||
<div
|
||||
className="ds-calendar-grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${weeks}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: offset }, (_, index) => (
|
||||
<span key={`padding-${index}`} aria-hidden="true" />
|
||||
))}
|
||||
{days.map((day, index) => (
|
||||
<button
|
||||
key={day.date}
|
||||
ref={(element) => {
|
||||
buttons.current[index] = element;
|
||||
}}
|
||||
type="button"
|
||||
className={`ds-day ds-day--${day.state}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
day.state === "not-due"
|
||||
? "transparent"
|
||||
: day.state === "future"
|
||||
? emptyColor
|
||||
: progressShade(day, color),
|
||||
}}
|
||||
aria-label={`${day.date}: ${describeDay(day, unit)}`}
|
||||
title={`${day.date}: ${describeDay(day, unit)}`}
|
||||
aria-pressed={day.date === selected.date}
|
||||
aria-describedby={inspectorId}
|
||||
tabIndex={day.date === selected.date ? 0 : -1}
|
||||
onClick={() => setSelectedDate(day.date)}
|
||||
onKeyDown={(event) => {
|
||||
const offset = {
|
||||
ArrowDown: 1,
|
||||
ArrowUp: -1,
|
||||
ArrowRight: 7,
|
||||
ArrowLeft: -7,
|
||||
}[event.key];
|
||||
const next =
|
||||
event.key === "Home"
|
||||
? 0
|
||||
: event.key === "End"
|
||||
? days.length - 1
|
||||
: offset !== undefined
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(days.length - 1, index + offset),
|
||||
)
|
||||
: undefined;
|
||||
if (next !== undefined) {
|
||||
event.preventDefault();
|
||||
setSelectedDate(days[next]!.date);
|
||||
buttons.current[next]?.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{legend ?? <CalendarLegend days={days} color={color} label={label} />}
|
||||
<div className="ds-calendar-caption">
|
||||
<span>
|
||||
{months} months{" "}
|
||||
<span className="ds-muted">
|
||||
/{" "}
|
||||
{historyLabel ??
|
||||
(compact ? "Demo history" : "Illustrative history")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ds-muted">
|
||||
{compact
|
||||
? "Select a day · Arrow keys"
|
||||
: "Select a day to inspect · Arrow keys to move"}
|
||||
</span>
|
||||
</div>
|
||||
<div id={inspectorId} className="ds-date-inspector" aria-live="polite">
|
||||
<span>
|
||||
{new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
</span>
|
||||
<span>{describeDay(selected, unit)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/design-system/CalendarLegend.tsx
Normal file
53
src/components/design-system/CalendarLegend.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { legendShades, type CalendarDay } from "./calendar-model";
|
||||
|
||||
export function CalendarLegend({
|
||||
days,
|
||||
color,
|
||||
label,
|
||||
shades: suppliedShades,
|
||||
}: {
|
||||
days: CalendarDay[];
|
||||
color: string;
|
||||
label: string;
|
||||
shades?: string[];
|
||||
}) {
|
||||
const shades = suppliedShades ?? legendShades(days, color);
|
||||
return (
|
||||
<div
|
||||
className="ds-calendar-legend"
|
||||
role="group"
|
||||
aria-label={`${label} legend`}
|
||||
>
|
||||
<div className="ds-legend-scale">
|
||||
<span>0 / Upcoming</span>
|
||||
<span
|
||||
className="ds-legend-swatches"
|
||||
role="img"
|
||||
aria-label={
|
||||
shades.length > 2
|
||||
? "Empty, then increasing partial progress through complete"
|
||||
: shades.length === 2
|
||||
? "Empty or complete"
|
||||
: "Empty"
|
||||
}
|
||||
>
|
||||
{shades.map((shade) => (
|
||||
<span
|
||||
key={shade}
|
||||
className="ds-legend-swatch"
|
||||
style={{ backgroundColor: shade }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
{shades.length > 1 && <span>Complete</span>}
|
||||
</div>
|
||||
<span className="ds-legend-neutral">
|
||||
<span
|
||||
className="ds-legend-swatch ds-legend-swatch--not-due"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Not scheduled
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
909
src/components/design-system/EditingWorkbench.tsx
Normal file
909
src/components/design-system/EditingWorkbench.tsx
Normal file
@@ -0,0 +1,909 @@
|
||||
import { useEffect, useId, useState, type ReactNode } from "react";
|
||||
import type { HabitConfig, Schedule } from "../../habits/contracts";
|
||||
import { Button, Checkbox } from "./primitives";
|
||||
import { CalendarHeatmap } from "./CalendarHeatmap";
|
||||
import {
|
||||
HabitColorPicker,
|
||||
isHabitColor,
|
||||
previewHabitColors,
|
||||
type HabitColors,
|
||||
} from "./HabitColorPicker";
|
||||
import {
|
||||
EDITOR_HABITS,
|
||||
EDITOR_START,
|
||||
EDITOR_TODAY,
|
||||
backfillError,
|
||||
historicalDays,
|
||||
validateEditor,
|
||||
validateTasks,
|
||||
} from "./editing-model";
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: (id: string) => ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="ds-field">
|
||||
<label htmlFor={id}>{label}</label>
|
||||
{children(id)}
|
||||
{hint && <small>{hint}</small>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
export function ScheduleEditor({
|
||||
value,
|
||||
onChange,
|
||||
anchorDate = EDITOR_TODAY,
|
||||
}: {
|
||||
value: Schedule;
|
||||
onChange: (schedule: Schedule) => void;
|
||||
anchorDate?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="ds-schedule-editor">
|
||||
<Field label="Repeats">
|
||||
{(id) => (
|
||||
<select
|
||||
id={id}
|
||||
value={value.type}
|
||||
onChange={(event) => {
|
||||
const type = event.target.value;
|
||||
onChange(
|
||||
type === "daily"
|
||||
? { type }
|
||||
: type === "weekdays"
|
||||
? { type, days: [1, 2, 3, 4, 5] }
|
||||
: type === "interval"
|
||||
? { type, every: 2, anchor: anchorDate }
|
||||
: {
|
||||
type: "weekly",
|
||||
every: 1,
|
||||
weekday: 1,
|
||||
anchor: anchorDate,
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="daily">Every day</option>
|
||||
<option value="weekdays">Selected weekdays</option>
|
||||
<option value="interval">Every few days</option>
|
||||
<option value="weekly">Every few weeks</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
{value.type === "weekdays" && (
|
||||
<fieldset className="ds-weekday-field">
|
||||
<legend>Scheduled days · choose at least one</legend>
|
||||
<div className="ds-weekdays">
|
||||
{WEEKDAYS.map((day, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={day}
|
||||
aria-label={day}
|
||||
aria-pressed={value.days.includes(index)}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...value,
|
||||
days: value.days.includes(index)
|
||||
? value.days.filter((item) => item !== index)
|
||||
: [...value.days, index].sort(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
{(value.type === "interval" || value.type === "weekly") && (
|
||||
<div className="ds-form-grid">
|
||||
<Field
|
||||
label={value.type === "interval" ? "Every (days)" : "Every (weeks)"}
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
max={value.type === "interval" ? 3650 : 520}
|
||||
value={Number.isNaN(value.every) ? "" : value.every}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, every: event.target.valueAsNumber })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Starting on">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="date"
|
||||
required
|
||||
min="1970-01-01"
|
||||
max="9998-12-31"
|
||||
value={value.anchor}
|
||||
onInput={(event) =>
|
||||
onChange({ ...value, anchor: event.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{value.type === "weekly" && (
|
||||
<Field label="On weekday">
|
||||
{(id) => (
|
||||
<select
|
||||
id={id}
|
||||
value={value.weekday}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, weekday: Number(event.target.value) })
|
||||
}
|
||||
>
|
||||
{WEEKDAYS.map((day, index) => (
|
||||
<option key={day} value={index}>
|
||||
{day}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveBar({
|
||||
dirty,
|
||||
error,
|
||||
onCancel,
|
||||
label = "Save changes",
|
||||
disabled = false,
|
||||
}: {
|
||||
dirty: boolean;
|
||||
error: string | null;
|
||||
onCancel: () => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<p className="ds-form-feedback" role={error ? "alert" : "status"}>
|
||||
{error || (dirty ? "Unsaved changes" : "No unsaved changes")}
|
||||
</p>
|
||||
<div className="ds-form-actions">
|
||||
<Button type="submit" disabled={!dirty || disabled}>
|
||||
{label} <span aria-hidden="true">↗</span>
|
||||
</Button>
|
||||
<Button variant="text" onClick={onCancel} disabled={!dirty}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type EditorTab = "Habit settings" | "Tasks" | "Backfill progress";
|
||||
type TaskConfig = Extract<HabitConfig, { method: "tasks" }>;
|
||||
type Correction = {
|
||||
habitId: string;
|
||||
name: string;
|
||||
date: string;
|
||||
before: number;
|
||||
after: number;
|
||||
target: number;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
export function EditingWorkbench({
|
||||
onColorsChange,
|
||||
}: {
|
||||
onColorsChange?: (colors: HabitColors) => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<EditorTab>("Habit settings");
|
||||
const [habits, setHabits] = useState(() => structuredClone(EDITOR_HABITS));
|
||||
const [selectedId, setSelectedId] = useState("water");
|
||||
const selected = habits.find((habit) => habit.id === selectedId)!;
|
||||
const [draftColor, setDraftColor] = useState(selected.color);
|
||||
const previewColor = isHabitColor(draftColor) ? draftColor : selected.color;
|
||||
useEffect(() => {
|
||||
onColorsChange?.(previewHabitColors(habits, selectedId, previewColor));
|
||||
}, [habits, selectedId, previewColor, onColorsChange]);
|
||||
const [draft, setDraft] = useState<HabitConfig>(() =>
|
||||
structuredClone(selected.config),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [corrections, setCorrections] = useState<Correction[]>([]);
|
||||
const [entries, setEntries] = useState<
|
||||
Record<string, { value: number; tasks: boolean[] }>
|
||||
>({});
|
||||
const [date, setDate] = useState("2026-09-03");
|
||||
const historical = EDITOR_HABITS.find(
|
||||
(habit) => habit.id === selectedId,
|
||||
)!.config;
|
||||
const [progress, setProgress] = useState(
|
||||
() => historicalDays("water").find((day) => day.date === date)!.value,
|
||||
);
|
||||
const [taskChecks, setTaskChecks] = useState<boolean[]>([]);
|
||||
const entryKey = `${selectedId}:${date}`;
|
||||
const day = historicalDays(selectedId).find((day) => day.date === date);
|
||||
const savedProgress = entries[entryKey]?.value ?? day?.value ?? 0;
|
||||
const savedChecks =
|
||||
entries[entryKey]?.tasks ??
|
||||
(historical.method === "tasks"
|
||||
? historical.tasks.map((_, index) => index < savedProgress)
|
||||
: []);
|
||||
const configDirty =
|
||||
JSON.stringify(draft) !== JSON.stringify(selected.config) ||
|
||||
draftColor.toLowerCase() !== selected.color.toLowerCase();
|
||||
const progressDirty =
|
||||
progress !== savedProgress ||
|
||||
(historical.method === "tasks" &&
|
||||
JSON.stringify(taskChecks) !== JSON.stringify(savedChecks));
|
||||
const dirty = tab === "Backfill progress" ? progressDirty : configDirty;
|
||||
const blocked = backfillError(selectedId, date, progress);
|
||||
|
||||
function loadProgress(id: string, nextDate: string) {
|
||||
const original = EDITOR_HABITS.find((habit) => habit.id === id)!.config;
|
||||
const entry = entries[`${id}:${nextDate}`];
|
||||
const value =
|
||||
entry?.value ??
|
||||
historicalDays(id).find((day) => day.date === nextDate)?.value ??
|
||||
0;
|
||||
setProgress(value);
|
||||
setTaskChecks(
|
||||
entry?.tasks ??
|
||||
(original.method === "tasks"
|
||||
? original.tasks.map((_, index) => index < value)
|
||||
: []),
|
||||
);
|
||||
setError(null);
|
||||
}
|
||||
function canLeave() {
|
||||
return !dirty || window.confirm("Discard unsaved changes in this preview?");
|
||||
}
|
||||
function selectHabit(id: string) {
|
||||
if (!canLeave()) return;
|
||||
setSelectedId(id);
|
||||
setDraft(structuredClone(habits.find((habit) => habit.id === id)!.config));
|
||||
setDraftColor(habits.find((habit) => habit.id === id)!.color);
|
||||
loadProgress(id, date);
|
||||
setNotice("");
|
||||
}
|
||||
function selectTab(next: EditorTab) {
|
||||
if (next === tab || !canLeave()) return;
|
||||
const id = next === "Tasks" ? "tasks" : selectedId;
|
||||
setTab(next);
|
||||
setSelectedId(id);
|
||||
setDraft(structuredClone(habits.find((habit) => habit.id === id)!.config));
|
||||
setDraftColor(habits.find((habit) => habit.id === id)!.color);
|
||||
loadProgress(id, date);
|
||||
setNotice("");
|
||||
}
|
||||
function selectDate(next: string) {
|
||||
if (next === date || !canLeave()) return;
|
||||
setDate(next);
|
||||
loadProgress(selectedId, next);
|
||||
setNotice("");
|
||||
}
|
||||
function saveConfig() {
|
||||
if (!isHabitColor(draftColor)) {
|
||||
setError("Use a six-digit hex color, such as #426582.");
|
||||
return;
|
||||
}
|
||||
const validation =
|
||||
tab === "Tasks" && draft.method === "tasks"
|
||||
? validateTasks(draft.tasks)
|
||||
: validateEditor(draft);
|
||||
if (validation) {
|
||||
setError(validation);
|
||||
return;
|
||||
}
|
||||
const clean = {
|
||||
...draft,
|
||||
name: draft.name.trim(),
|
||||
...(draft.method === "count" ? { unit: draft.unit.trim() } : {}),
|
||||
...(draft.method === "tasks"
|
||||
? {
|
||||
tasks: draft.tasks.map((task) => ({
|
||||
...task,
|
||||
name: task.name.trim(),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
} as HabitConfig;
|
||||
setHabits((current) =>
|
||||
current.map((habit) =>
|
||||
habit.id === selectedId
|
||||
? {
|
||||
...habit,
|
||||
config: structuredClone(clean),
|
||||
color: draftColor.toLowerCase(),
|
||||
}
|
||||
: habit,
|
||||
),
|
||||
);
|
||||
setDraft(clean);
|
||||
setDraftColor(draftColor.toLowerCase());
|
||||
setError(null);
|
||||
setNotice(
|
||||
`${tab === "Tasks" ? "Tasks" : clean.name} saved in this preview. Effective September 4; earlier dates are unchanged.`,
|
||||
);
|
||||
}
|
||||
function updateTask(id: string, patch: Partial<TaskConfig["tasks"][number]>) {
|
||||
if (draft.method === "tasks")
|
||||
setDraft({
|
||||
...draft,
|
||||
tasks: draft.tasks.map((task) =>
|
||||
task.id === id ? { ...task, ...patch } : task,
|
||||
),
|
||||
});
|
||||
setError(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="ds-section"
|
||||
id="editing"
|
||||
aria-labelledby="editing-title"
|
||||
>
|
||||
<div className="ds-section-top">
|
||||
<div>
|
||||
<span className="ds-eyebrow">02 / EDITING & HISTORY</span>
|
||||
<h2 id="editing-title">
|
||||
Room for <em>real life.</em>
|
||||
</h2>
|
||||
</div>
|
||||
<span className="ds-demo-note">
|
||||
Independent editor demo · not saved
|
||||
</span>
|
||||
</div>
|
||||
<div className="ds-preview-toolbar">
|
||||
<div className="ds-view-switch" role="group" aria-label="Editing view">
|
||||
{(["Habit settings", "Tasks", "Backfill progress"] as const).map(
|
||||
(item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
aria-pressed={tab === item}
|
||||
onClick={() => selectTab(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Reset all editor changes and corrections? This only affects the preview.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
setHabits(structuredClone(EDITOR_HABITS));
|
||||
setSelectedId("water");
|
||||
setDraft(structuredClone(EDITOR_HABITS[0]!.config));
|
||||
setDraftColor(EDITOR_HABITS[0]!.color);
|
||||
setTab("Habit settings");
|
||||
setEntries({});
|
||||
setCorrections([]);
|
||||
setDate("2026-09-03");
|
||||
setProgress(
|
||||
historicalDays("water").find((day) => day.date === "2026-09-03")!
|
||||
.value,
|
||||
);
|
||||
setTaskChecks([]);
|
||||
setError(null);
|
||||
setNotice("Editor demo reset.");
|
||||
}}
|
||||
>
|
||||
Reset editor ↺
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ds-edit-layout">
|
||||
<aside className="ds-edit-context">
|
||||
<span className="ds-eyebrow">
|
||||
{tab === "Backfill progress"
|
||||
? "A DATE, NOT A RESET"
|
||||
: "MAKE IT YOURS"}
|
||||
</span>
|
||||
<h3>
|
||||
{tab === "Habit settings"
|
||||
? "Shape your rhythm."
|
||||
: tab === "Tasks"
|
||||
? "Small steps, clearly defined."
|
||||
: "A missed log is not a missed day."}
|
||||
</h3>
|
||||
<p>
|
||||
{tab === "Habit settings"
|
||||
? "Adjust a name, target, or schedule. Changes start today; your history stays as it was."
|
||||
: tab === "Tasks"
|
||||
? "Give each task its own repeat rule. It is due only when both the habit and task schedules match."
|
||||
: "Choose a past date and enter what actually happened. That date keeps its original requirements."}
|
||||
</p>
|
||||
{tab !== "Tasks" && (
|
||||
<Field label="Habit">
|
||||
{(id) => (
|
||||
<select
|
||||
id={id}
|
||||
value={selectedId}
|
||||
onChange={(event) => selectHabit(event.target.value)}
|
||||
>
|
||||
{habits.map((habit) => (
|
||||
<option value={habit.id} key={habit.id}>
|
||||
{habit.config.name}
|
||||
{habit.config.archived ? " · Archived" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<div className="ds-edit-meta">
|
||||
<span
|
||||
className="ds-habit-dot"
|
||||
style={{ background: previewColor }}
|
||||
/>
|
||||
<span>
|
||||
{tab === "Backfill progress"
|
||||
? historical.name
|
||||
: selected.config.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="ds-footnote">
|
||||
Demo clock · September 4, 2026
|
||||
<br />
|
||||
Tracking timezone · Europe/Belgrade
|
||||
</p>
|
||||
</aside>
|
||||
<div className="ds-edit-content">
|
||||
{tab === "Habit settings" && (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
saveConfig();
|
||||
}}
|
||||
>
|
||||
<div className="ds-spec-heading">
|
||||
<h3>Edit habit</h3>
|
||||
<span className="ds-code">Effective today</span>
|
||||
</div>
|
||||
<Field label="Habit name">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
required
|
||||
maxLength={200}
|
||||
value={draft.name}
|
||||
onChange={(event) => {
|
||||
setDraft({ ...draft, name: event.target.value });
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<HabitColorPicker
|
||||
value={draftColor}
|
||||
onChange={(color) => {
|
||||
setDraftColor(color);
|
||||
setError(null);
|
||||
setNotice("");
|
||||
}}
|
||||
/>
|
||||
<Field
|
||||
label="Completion method"
|
||||
hint="Method is fixed in this preview. Choose another habit to try its editor."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
readOnly
|
||||
value={
|
||||
draft.method === "count"
|
||||
? "Count target"
|
||||
: draft.method === "manual"
|
||||
? "Manual checkbox"
|
||||
: "Task checklist"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{draft.method === "count" && (
|
||||
<>
|
||||
<div className="ds-form-grid">
|
||||
<Field label="Daily target">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
max={10000}
|
||||
value={Number.isNaN(draft.target) ? "" : draft.target}
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
target: event.target.valueAsNumber,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Unit">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
required
|
||||
maxLength={80}
|
||||
value={draft.unit}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, unit: event.target.value })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<Checkbox
|
||||
label="Carry unfinished counts to the next scheduled day"
|
||||
checked={draft.carryPartialProgress}
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
carryPartialProgress: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="ds-footnote">
|
||||
Completed counts reset. Earlier explicit logs are never
|
||||
overwritten.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="ds-form-section">
|
||||
<ScheduleEditor
|
||||
value={draft.schedule}
|
||||
onChange={(schedule) => {
|
||||
setDraft({ ...draft, schedule });
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="ds-form-section">
|
||||
<Checkbox
|
||||
label="Archive this habit"
|
||||
checked={draft.archived}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, archived: event.target.checked })
|
||||
}
|
||||
/>
|
||||
<p className="ds-footnote">
|
||||
Stops tracking from today. History is kept. Uncheck and save
|
||||
to restore.
|
||||
</p>
|
||||
</div>
|
||||
<SaveBar
|
||||
dirty={configDirty}
|
||||
error={error}
|
||||
onCancel={() => {
|
||||
setDraft(structuredClone(selected.config));
|
||||
setDraftColor(selected.color);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
{tab === "Tasks" && draft.method === "tasks" && (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
saveConfig();
|
||||
}}
|
||||
>
|
||||
<div className="ds-spec-heading">
|
||||
<h3>Edit tasks</h3>
|
||||
<span className="ds-code">
|
||||
{draft.tasks.length} / 100 tasks
|
||||
</span>
|
||||
</div>
|
||||
{draft.archived && (
|
||||
<p className="ds-form-feedback" role="status">
|
||||
This habit is archived. Restore it in Habit settings before
|
||||
editing tasks.
|
||||
</p>
|
||||
)}
|
||||
<fieldset
|
||||
className="ds-task-editor-fieldset"
|
||||
disabled={draft.archived}
|
||||
aria-label="Task definitions"
|
||||
>
|
||||
{draft.tasks.length === 0 && (
|
||||
<p className="ds-empty-state">
|
||||
No tasks yet. Add a first step; a habit with no due tasks is
|
||||
not scored.
|
||||
</p>
|
||||
)}
|
||||
{draft.tasks.map((task, index) => (
|
||||
<div className="ds-edit-task" key={task.id}>
|
||||
<div className="ds-spec-heading">
|
||||
<span className="ds-code">
|
||||
STEP {String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label={`Remove ${task.name || "unnamed task"}`}
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
...draft,
|
||||
tasks: draft.tasks.filter(
|
||||
(item) => item.id !== task.id,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove −
|
||||
</Button>
|
||||
</div>
|
||||
<Field label="Task name">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
required
|
||||
maxLength={200}
|
||||
value={task.name}
|
||||
onChange={(event) =>
|
||||
updateTask(task.id, { name: event.target.value })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<ScheduleEditor
|
||||
value={task.schedule}
|
||||
onChange={(schedule) => updateTask(task.id, { schedule })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={draft.tasks.length >= 100}
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
...draft,
|
||||
tasks: [
|
||||
...draft.tasks,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: "",
|
||||
schedule: { type: "daily" },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
Add task +
|
||||
</Button>
|
||||
</fieldset>
|
||||
<p className="ds-footnote">
|
||||
Removing a task takes effect when you save. Earlier checklists
|
||||
and their completion records remain available for backfilling.
|
||||
</p>
|
||||
<SaveBar
|
||||
dirty={configDirty}
|
||||
error={error}
|
||||
disabled={draft.archived}
|
||||
onCancel={() => {
|
||||
setDraft(structuredClone(selected.config));
|
||||
setDraftColor(selected.color);
|
||||
setError(null);
|
||||
}}
|
||||
label="Save tasks"
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
{tab === "Backfill progress" && (
|
||||
<>
|
||||
<div className="ds-spec-heading">
|
||||
<h3>Correct a past day</h3>
|
||||
<span className="ds-code">Historical snapshot</span>
|
||||
</div>
|
||||
<Field
|
||||
label="Progress date"
|
||||
hint="Select a date here or in the calendar below."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="date"
|
||||
required
|
||||
min={EDITOR_START}
|
||||
max="2026-09-03"
|
||||
value={date}
|
||||
onInput={(event) => selectDate(event.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (blocked) {
|
||||
setError(blocked);
|
||||
return;
|
||||
}
|
||||
const correction = {
|
||||
habitId: selectedId,
|
||||
name: historical.name,
|
||||
date,
|
||||
before: savedProgress,
|
||||
after: progress,
|
||||
target: day!.target,
|
||||
unit:
|
||||
historical.method === "count"
|
||||
? historical.unit
|
||||
: historical.method === "tasks"
|
||||
? "tasks"
|
||||
: "session",
|
||||
};
|
||||
setEntries((current) => ({
|
||||
...current,
|
||||
[entryKey]: { value: progress, tasks: [...taskChecks] },
|
||||
}));
|
||||
setCorrections((current) => [correction, ...current]);
|
||||
setError(null);
|
||||
setNotice(
|
||||
`Correction saved in this preview for ${date}. The calendar below is updated.`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{blocked && (
|
||||
<p className="ds-form-feedback" role="status">
|
||||
{blocked}
|
||||
</p>
|
||||
)}
|
||||
{day?.state === "due" && date < EDITOR_TODAY && (
|
||||
<>
|
||||
<div className="ds-history-requirement">
|
||||
<span>Required on {date}</span>
|
||||
<strong>
|
||||
{day.target}{" "}
|
||||
{historical.method === "count"
|
||||
? historical.unit
|
||||
: historical.method === "tasks"
|
||||
? "tasks"
|
||||
: "session"}
|
||||
</strong>
|
||||
</div>
|
||||
{historical.method === "count" ? (
|
||||
<Field
|
||||
label="Actual count"
|
||||
hint="Enter the total, not an increment. Zero clears progress; counts may exceed the target."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
required
|
||||
min={0}
|
||||
max={1_000_000_000}
|
||||
step={1}
|
||||
value={Number.isNaN(progress) ? "" : progress}
|
||||
onChange={(event) => {
|
||||
setProgress(event.target.valueAsNumber);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
) : historical.method === "manual" ? (
|
||||
<Checkbox
|
||||
label="Completed on this date"
|
||||
checked={progress === 1}
|
||||
onChange={(event) =>
|
||||
setProgress(Number(event.target.checked))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="ds-backfill-tasks">
|
||||
{historical.tasks.map((task, index) => (
|
||||
<Checkbox
|
||||
key={task.id}
|
||||
label={task.name}
|
||||
checked={taskChecks[index] ?? false}
|
||||
onChange={(event) => {
|
||||
const next = taskChecks.map((done, i) =>
|
||||
i === index ? event.target.checked : done,
|
||||
);
|
||||
setTaskChecks(next);
|
||||
setProgress(next.filter(Boolean).length);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="ds-correction-summary">
|
||||
{savedProgress} →{" "}
|
||||
{Number.isFinite(progress) ? progress : "—"} /{" "}
|
||||
{day.target}
|
||||
<span>
|
||||
{progress >= day.target ? "Complete" : "Incomplete"}
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<SaveBar
|
||||
dirty={progressDirty}
|
||||
error={error}
|
||||
disabled={!!blocked}
|
||||
label="Save correction"
|
||||
onCancel={() => loadProgress(selectedId, date)}
|
||||
/>
|
||||
</form>
|
||||
<div className="ds-backfill-calendar">
|
||||
<CalendarHeatmap
|
||||
key={selectedId}
|
||||
compact
|
||||
color={selected.color}
|
||||
days={historicalDays(selectedId).map((item) => ({
|
||||
...item,
|
||||
value:
|
||||
entries[`${selectedId}:${item.date}`]?.value ??
|
||||
item.value,
|
||||
}))}
|
||||
unit={
|
||||
historical.method === "count"
|
||||
? historical.unit
|
||||
: historical.method === "tasks"
|
||||
? "tasks"
|
||||
: "session"
|
||||
}
|
||||
label="Backfill history"
|
||||
selectedDate={date}
|
||||
onSelectDate={selectDate}
|
||||
/>
|
||||
</div>
|
||||
<p className="ds-footnote">
|
||||
This preview updates only the calendar here. In the connected
|
||||
app, corrections also recalculate combined charts and inherited
|
||||
counts.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p className="ds-form-feedback ds-save-notice" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{corrections.length > 0 && (
|
||||
<div className="ds-correction-log">
|
||||
<div className="ds-spec-heading">
|
||||
<h3>Correction history</h3>
|
||||
<span className="ds-code">This preview only · newest first</span>
|
||||
</div>
|
||||
<ol>
|
||||
{corrections.map((item, index) => (
|
||||
<li key={corrections.length - index}>
|
||||
<time dateTime={item.date}>{item.date}</time>
|
||||
<span>{item.name}</span>
|
||||
<span>
|
||||
{item.before} → {item.after} / {item.target} {item.unit}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
84
src/components/design-system/HabitChart.tsx
Normal file
84
src/components/design-system/HabitChart.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useId, type ReactNode } from "react";
|
||||
import { CalendarHeatmap } from "./CalendarHeatmap";
|
||||
import { demoCalendar } from "./calendar-model";
|
||||
|
||||
/** Open chart section: intentionally no card surface or enclosing border. */
|
||||
export function HabitChart({
|
||||
name,
|
||||
method,
|
||||
value,
|
||||
target,
|
||||
unit,
|
||||
color,
|
||||
children,
|
||||
tasks,
|
||||
calendar,
|
||||
schedule = "Every day",
|
||||
due = true,
|
||||
id,
|
||||
}: {
|
||||
name: string;
|
||||
method: string;
|
||||
value: number;
|
||||
target: number;
|
||||
unit: string;
|
||||
color: string;
|
||||
children?: ReactNode;
|
||||
tasks?: ReactNode;
|
||||
calendar?: ReactNode;
|
||||
schedule?: string;
|
||||
due?: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
const headingId = useId();
|
||||
return (
|
||||
<section className="ds-habit-chart" id={id} aria-labelledby={headingId}>
|
||||
<header className="ds-habit-chart-heading">
|
||||
<div>
|
||||
<h4 id={headingId}>
|
||||
<span style={{ backgroundColor: color }} aria-hidden="true" />
|
||||
{name}
|
||||
</h4>
|
||||
<p>
|
||||
{method} · {schedule}
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
</header>
|
||||
<p className="ds-habit-chart-progress" aria-live="polite">
|
||||
<span>
|
||||
{due
|
||||
? `${value} of ${target} ${unit}`
|
||||
: "A day off isn’t a missed day."}
|
||||
</span>
|
||||
<span>
|
||||
{!due
|
||||
? "Not scheduled"
|
||||
: value >= target
|
||||
? "Complete"
|
||||
: "In progress"}
|
||||
</span>
|
||||
</p>
|
||||
{calendar ?? (
|
||||
<CalendarHeatmap
|
||||
days={demoCalendar(value, target)}
|
||||
label={`${name} progress calendar`}
|
||||
unit={unit}
|
||||
color={color}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
{tasks && (
|
||||
<details className="ds-task-accordion">
|
||||
<summary>
|
||||
Tasks for today{" "}
|
||||
<span>
|
||||
{value} / {target}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="ds-task-accordion-content">{tasks}</div>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
48
src/components/design-system/HabitColorPicker.test.ts
Normal file
48
src/components/design-system/HabitColorPicker.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
HABIT_PALETTES,
|
||||
isHabitColor,
|
||||
previewHabitColors,
|
||||
} from "./HabitColorPicker";
|
||||
import { HABIT_COLORS } from "./calendar-model";
|
||||
|
||||
test("eight suggested palettes contain thirty-two unique, valid habit colors", () => {
|
||||
const colors = HABIT_PALETTES.flatMap((palette) => [...palette.colors]);
|
||||
expect(HABIT_PALETTES.length).toBe(8);
|
||||
expect(HABIT_PALETTES.every((palette) => palette.colors.length === 4)).toBe(true);
|
||||
expect(new Set(colors).size).toBe(32);
|
||||
expect(colors.every(isHabitColor)).toBe(true);
|
||||
});
|
||||
|
||||
test("custom colors accept six hex digits and reject incomplete or malformed colors", () => {
|
||||
for (const color of ["#123456", "#abcdef", "#ABCDEF", "#ffffff", "#000000"])
|
||||
expect(isHabitColor(color)).toBe(true);
|
||||
for (const color of ["", "red", "123456", "#fff", "#gggggg", "#12345678"])
|
||||
expect(isHabitColor(color)).toBe(false);
|
||||
});
|
||||
|
||||
test("a draft color updates only its habit and cancel restores the saved color", () => {
|
||||
const habits = [
|
||||
{ id: "water", color: HABIT_COLORS.water },
|
||||
{ id: "reading", color: "#123456" },
|
||||
];
|
||||
const preview = previewHabitColors(habits, "water", "#9c647c");
|
||||
expect(preview.water).toBe("#9c647c");
|
||||
expect(preview.reading).toBe("#123456");
|
||||
expect(preview.movement).toBe(HABIT_COLORS.movement);
|
||||
expect(previewHabitColors(habits, "water", habits[0]!.color).water).toBe(
|
||||
HABIT_COLORS.water,
|
||||
);
|
||||
expect(habits[0]!.color).toBe(HABIT_COLORS.water);
|
||||
});
|
||||
|
||||
test("invalid drafts fall back to saved colors and saved edits survive selecting another habit", () => {
|
||||
const habits = [
|
||||
{ id: "water", color: "#9c647c" },
|
||||
{ id: "reading", color: HABIT_COLORS.reading },
|
||||
];
|
||||
expect(previewHabitColors(habits, "water", "#zz").water).toBe("#9c647c");
|
||||
expect(previewHabitColors(habits, "reading", "#654321").water).toBe(
|
||||
"#9c647c",
|
||||
);
|
||||
});
|
||||
153
src/components/design-system/HabitColorPicker.tsx
Normal file
153
src/components/design-system/HabitColorPicker.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useId } from "react";
|
||||
import { HABIT_COLORS, progressShade } from "./calendar-model";
|
||||
|
||||
export type HabitColors = {
|
||||
-readonly [K in keyof typeof HABIT_COLORS]: string;
|
||||
};
|
||||
|
||||
export function previewHabitColors(
|
||||
habits: { id: string; color: string }[],
|
||||
selectedId: string,
|
||||
draftColor: string,
|
||||
): HabitColors {
|
||||
const colors: HabitColors = { ...HABIT_COLORS };
|
||||
for (const habit of habits) {
|
||||
if (!Object.hasOwn(colors, habit.id)) continue;
|
||||
colors[habit.id as keyof HabitColors] =
|
||||
habit.id === selectedId && isHabitColor(draftColor)
|
||||
? draftColor
|
||||
: habit.color;
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
export const HABIT_PALETTES = [
|
||||
{ name: "Earth", colors: ["#58765b", "#977344", "#a3604d", "#76734e"] },
|
||||
{ name: "Coast", colors: ["#426582", "#427b80", "#657e9c", "#667d70"] },
|
||||
{ name: "Dusk", colors: ["#79618d", "#9c647c", "#736a9c", "#686878"] },
|
||||
{ name: "Forest", colors: ["#386641", "#52734d", "#6b705c", "#3d6b62"] },
|
||||
{ name: "Citrus", colors: ["#b45309", "#a16207", "#9a5b35", "#7a801c"] },
|
||||
{ name: "Blossom", colors: ["#b05276", "#a34e62", "#a65f5a", "#8e5572"] },
|
||||
{ name: "Jewel", colors: ["#6d28a8", "#1d678d", "#087f73", "#a12e54"] },
|
||||
{ name: "Slate", colors: ["#475569", "#52616b", "#65605b", "#404047"] },
|
||||
] as const;
|
||||
|
||||
export function isHabitColor(value: string) {
|
||||
return /^#[0-9a-f]{6}$/i.test(value);
|
||||
}
|
||||
|
||||
export function HabitColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
mode = "demo",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
mode?: "demo" | "create" | "edit";
|
||||
}) {
|
||||
const id = useId();
|
||||
const valid = isHabitColor(value);
|
||||
return (
|
||||
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
|
||||
<legend>Habit color</legend>
|
||||
<p id={`${id}-hint`} className="ds-footnote">
|
||||
A color for this habit and its calendar. Choose a suggested shade or
|
||||
make it your own.
|
||||
</p>
|
||||
<div className="ds-color-palettes">
|
||||
{HABIT_PALETTES.map((palette) => (
|
||||
<div
|
||||
key={palette.name}
|
||||
className="ds-color-palette"
|
||||
role="group"
|
||||
aria-label={`${palette.name} palette`}
|
||||
>
|
||||
<span>{palette.name}</span>
|
||||
<div className="ds-color-swatches">
|
||||
{palette.colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
aria-label={`${palette.name} ${color}`}
|
||||
aria-pressed={value.toLowerCase() === color}
|
||||
title={color}
|
||||
onClick={() => onChange(color)}
|
||||
>
|
||||
<span style={{ backgroundColor: color }}>
|
||||
{value.toLowerCase() === color ? "✓" : ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="ds-custom-color">
|
||||
<div className="ds-field">
|
||||
<label htmlFor={`${id}-native`}>Custom color</label>
|
||||
<input
|
||||
id={`${id}-native`}
|
||||
type="color"
|
||||
value={valid ? value : "#426582"}
|
||||
onInput={(event) => onChange(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ds-field">
|
||||
<label htmlFor={`${id}-hex`}>Hex color</label>
|
||||
<input
|
||||
id={`${id}-hex`}
|
||||
type="text"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
maxLength={7}
|
||||
pattern="#[0-9a-fA-F]{6}"
|
||||
required
|
||||
placeholder="#426582"
|
||||
value={value}
|
||||
aria-invalid={!valid}
|
||||
aria-describedby={!valid ? `${id}-error` : undefined}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{valid && (
|
||||
<div
|
||||
className="ds-color-live-preview"
|
||||
aria-label="Live calendar color preview"
|
||||
>
|
||||
<span className="ds-muted">Calendar preview</span>
|
||||
<div
|
||||
className="ds-color-preview-shades"
|
||||
role="img"
|
||||
aria-label={`Progress shades using ${value}`}
|
||||
>
|
||||
{Array.from({ length: 9 }, (_, count) => (
|
||||
<span
|
||||
key={count}
|
||||
style={{
|
||||
backgroundColor: progressShade(
|
||||
{ date: "", value: count, target: 8, state: "due" },
|
||||
value,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="ds-muted">0 → complete</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="ds-footnote">
|
||||
{mode === "create"
|
||||
? "Your chosen color is saved with your habit and used in its progress calendar."
|
||||
: mode === "edit"
|
||||
? "Save changes to apply this color to your habit’s calendar. Cancel keeps your current color."
|
||||
: "Previews update immediately, including the charts above. Save to keep this color in the demo; Cancel to revert."}
|
||||
</p>
|
||||
{!valid && (
|
||||
<p id={`${id}-error`} role="alert" className="ds-form-feedback">
|
||||
Use a six-digit hex color, such as #426582.
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
124
src/components/design-system/calendar-model.test.ts
Normal file
124
src/components/design-system/calendar-model.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
combinedProgress,
|
||||
demoCalendar,
|
||||
describeDay,
|
||||
progressShade,
|
||||
PROGRESS_SHADES,
|
||||
monthWindow,
|
||||
visibleCalendarDays,
|
||||
MONTH_VIEWS,
|
||||
legendShades,
|
||||
} from "./calendar-model";
|
||||
|
||||
test("legends match habit colors and only display possible progress shades", () => {
|
||||
for (const color of ["#111111", "#426582", "#977344", "#79618d", "#58765b"]) {
|
||||
for (const target of [1, 3, 4, 8, 20]) {
|
||||
const shades = legendShades(demoCalendar(0, target), color);
|
||||
expect(shades).toHaveLength(Math.min(target, 8) + 1);
|
||||
expect(shades[0]).toBe(PROGRESS_SHADES[0]);
|
||||
expect(shades.at(-1)).toBe(color);
|
||||
for (let value = 0; value <= target; value++) {
|
||||
expect(shades).toContain(
|
||||
progressShade({ date: "", value, target, state: "due" }, color),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(legendShades(demoCalendar(0, 0))).toEqual([PROGRESS_SHADES[0]]);
|
||||
expect(legendShades([])).toEqual([PROGRESS_SHADES[0]]);
|
||||
});
|
||||
|
||||
test("eight glasses have eight positive shades plus empty; 7/8 remains incomplete", () => {
|
||||
const days = Array.from({ length: 9 }, (_, value) => ({
|
||||
date: "2026-09-04",
|
||||
value,
|
||||
target: 8,
|
||||
state: "due" as const,
|
||||
}));
|
||||
expect(new Set(days.map((day) => progressShade(day))).size).toBe(9);
|
||||
for (const color of ["#426582", "#977344", "#79618d", "#58765b"]) {
|
||||
expect(new Set(days.map((day) => progressShade(day, color))).size).toBe(9);
|
||||
expect(progressShade(days[8]!, color)).toBe(color);
|
||||
}
|
||||
expect(describeDay(days[7]!, "glasses")).toBe("7 of 8 glasses · Incomplete");
|
||||
expect(progressShade({ ...days[8]!, value: 10 })).toBe(PROGRESS_SHADES[8]);
|
||||
});
|
||||
|
||||
test("combined progress counts only completed due habits with equal weights", () => {
|
||||
expect(
|
||||
combinedProgress([
|
||||
{ value: 7, target: 8, due: true },
|
||||
{ value: 1, target: 1, due: true },
|
||||
{ value: 2, target: 3, due: true },
|
||||
{ value: 0, target: 1, due: false },
|
||||
]),
|
||||
).toEqual({ completed: 1, due: 3 });
|
||||
expect(combinedProgress([{ value: 0, target: 0, due: true }])).toEqual({
|
||||
completed: 0,
|
||||
due: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("future and zero share a fill while inspection retains their distinct meaning", () => {
|
||||
const days = demoCalendar(7, 8);
|
||||
expect(days).toHaveLength(365);
|
||||
expect(days.find((day) => day.date === "2026-09-04")?.value).toBe(7);
|
||||
expect(
|
||||
describeDay(
|
||||
days.find((day) => day.state === "not-due")!,
|
||||
"glasses",
|
||||
),
|
||||
).toContain("Nothing scheduled");
|
||||
expect(describeDay(days.at(-1)!, "glasses")).toContain("Upcoming");
|
||||
const zero = {
|
||||
date: "2026-09-04",
|
||||
value: 0,
|
||||
target: 8,
|
||||
state: "due" as const,
|
||||
};
|
||||
for (const color of ["#111111", "#426582", "#977344", "#79618d", "#58765b"]) {
|
||||
expect(progressShade(days.at(-1)!, color)).toBe(progressShade(zero, color));
|
||||
expect(progressShade(days.at(-1)!, color)).toBe(PROGRESS_SHADES[0]);
|
||||
}
|
||||
expect(describeDay(zero, "glasses")).toBe("0 of 8 glasses · Incomplete");
|
||||
});
|
||||
|
||||
test("month presets include exact whole months and cross year and leap-year boundaries", () => {
|
||||
expect(
|
||||
MONTH_VIEWS.map((months) => monthWindow("2026-09-04", months)),
|
||||
).toEqual([
|
||||
{ from: "2026-07-01", to: "2026-09-30" },
|
||||
{ from: "2026-06-01", to: "2026-09-30" },
|
||||
{ from: "2026-04-01", to: "2026-09-30" },
|
||||
{ from: "2025-10-01", to: "2026-09-30" },
|
||||
]);
|
||||
expect(monthWindow("2024-02-29", 3)).toEqual({
|
||||
from: "2023-12-01",
|
||||
to: "2024-02-29",
|
||||
});
|
||||
});
|
||||
|
||||
test("switching month views preserves dated values, complete coverage and future states", () => {
|
||||
const days = demoCalendar(7, 8);
|
||||
expect(
|
||||
MONTH_VIEWS.map(
|
||||
(months) => visibleCalendarDays(days, months, "2026-09-04").length,
|
||||
),
|
||||
).toEqual([92, 122, 183, 365]);
|
||||
for (const months of MONTH_VIEWS) {
|
||||
const visible = visibleCalendarDays(days, months, "2026-09-04");
|
||||
expect(visible.find((day) => day.date === "2026-09-04")).toEqual(
|
||||
days.find((day) => day.date === "2026-09-04"),
|
||||
);
|
||||
expect(visible.filter((day) => day.state === "future")).toHaveLength(26);
|
||||
expect(new Set(visible.map((day) => day.date)).size).toBe(visible.length);
|
||||
}
|
||||
expect(visibleCalendarDays([], 3, "2026-09-04")).toEqual([]);
|
||||
});
|
||||
|
||||
test("live calendars preserve server colors and historical units", () => {
|
||||
const day = { date: "2026-09-03", value: 3, target: 8, state: "due" as const, color: "#abcdef", unit: "pages" };
|
||||
expect(progressShade(day, "#111111")).toBe("#abcdef");
|
||||
expect(describeDay(day, "glasses")).toBe("3 of 8 pages · Incomplete");
|
||||
});
|
||||
139
src/components/design-system/calendar-model.ts
Normal file
139
src/components/design-system/calendar-model.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
export const PROGRESS_SHADES = [
|
||||
"#eeeeee",
|
||||
"#d8d8d8",
|
||||
"#bfbfbf",
|
||||
"#a5a5a5",
|
||||
"#8a8a8a",
|
||||
"#707070",
|
||||
"#555555",
|
||||
"#363636",
|
||||
"#111111",
|
||||
] as const;
|
||||
export type CalendarDay = {
|
||||
color?: string;
|
||||
unit?: string;
|
||||
date: string;
|
||||
value: number;
|
||||
target: number;
|
||||
state: "due" | "not-due" | "future";
|
||||
};
|
||||
|
||||
export const MONTH_VIEWS = [3, 4, 6, 12] as const;
|
||||
export type MonthView = (typeof MONTH_VIEWS)[number];
|
||||
|
||||
/** Whole calendar months, including the month containing the anchor date. */
|
||||
export function monthWindow(anchor: string, months: MonthView) {
|
||||
const date = new Date(`${anchor}T12:00:00Z`);
|
||||
const first = new Date(
|
||||
Date.UTC(date.getUTCFullYear(), date.getUTCMonth() - months + 1, 1),
|
||||
);
|
||||
const last = new Date(
|
||||
Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
|
||||
);
|
||||
return {
|
||||
from: first.toISOString().slice(0, 10),
|
||||
to: last.toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
export function visibleCalendarDays(
|
||||
days: CalendarDay[],
|
||||
months: MonthView,
|
||||
anchor: string,
|
||||
) {
|
||||
const range = monthWindow(anchor, months);
|
||||
return days.filter((day) => day.date >= range.from && day.date <= range.to);
|
||||
}
|
||||
|
||||
export const HABIT_COLORS = {
|
||||
water: "#426582",
|
||||
reading: "#977344",
|
||||
tasks: "#79618d",
|
||||
movement: "#58765b",
|
||||
} as const;
|
||||
|
||||
export function progressShade(day: CalendarDay, color = "#111111") {
|
||||
if (day.color) return day.color;
|
||||
if (day.state === "not-due") return "#ffffff";
|
||||
if (day.state === "future") return PROGRESS_SHADES[0];
|
||||
if (day.target <= 0 || day.value <= 0) return PROGRESS_SHADES[0];
|
||||
const step = Math.max(
|
||||
1,
|
||||
Math.min(8, Math.round((day.value / day.target) * 8)),
|
||||
);
|
||||
if (color === "#111111" || !/^#[0-9a-f]{6}$/i.test(color))
|
||||
return PROGRESS_SHADES[step]!;
|
||||
const weight = step / 8;
|
||||
return `#${[1, 3, 5]
|
||||
.map((offset) =>
|
||||
Math.round(
|
||||
238 + (parseInt(color.slice(offset, offset + 2), 16) - 238) * weight,
|
||||
)
|
||||
.toString(16)
|
||||
.padStart(2, "0"),
|
||||
)
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
export function describeDay(day: CalendarDay, unit: string) {
|
||||
if (day.state === "future") return "Upcoming · Logging is not available yet";
|
||||
if (day.state === "not-due")
|
||||
return "Nothing scheduled · Not included in the score";
|
||||
return `${day.value} of ${day.target} ${day.unit ?? unit} · ${day.value >= day.target ? "Complete" : "Incomplete"}`;
|
||||
}
|
||||
|
||||
/** Only show shades that the chart's completion targets can produce. */
|
||||
export function legendShades(days: CalendarDay[], color = "#111111") {
|
||||
const levels = new Set<number>();
|
||||
for (const target of new Set(days.map((day) => day.target))) {
|
||||
if (!Number.isFinite(target) || target <= 0) continue;
|
||||
for (let value = 1; value <= Math.min(target, 8); value++) {
|
||||
levels.add(
|
||||
target > 8 ? value : Math.max(1, Math.round((value / target) * 8)),
|
||||
);
|
||||
}
|
||||
}
|
||||
return [
|
||||
PROGRESS_SHADES[0],
|
||||
...[...levels]
|
||||
.sort((a, b) => a - b)
|
||||
.map((value) =>
|
||||
progressShade({ date: "", value, target: 8, state: "due" }, color),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function combinedProgress(
|
||||
habits: { value: number; target: number; due: boolean }[],
|
||||
) {
|
||||
const due = habits.filter((habit) => habit.due && habit.target > 0);
|
||||
return {
|
||||
completed: due.filter((habit) => habit.value >= habit.target).length,
|
||||
due: due.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** A full 12 calendar months of stable demo history, including future dates. */
|
||||
export function demoCalendar(value: number, target: number): CalendarDay[] {
|
||||
return Array.from({ length: 365 }, (_, index) => {
|
||||
const timestamp = Date.UTC(2025, 9, 1 + index);
|
||||
// Keep the previously displayed dates' fixture values unchanged.
|
||||
const fixtureIndex = (timestamp - Date.UTC(2026, 2, 8)) / 86_400_000;
|
||||
const rawValue = fixtureIndex * 17 + Math.floor(fixtureIndex / 7) * 3;
|
||||
const date = new Date(timestamp).toISOString().slice(0, 10);
|
||||
return {
|
||||
date,
|
||||
target,
|
||||
value:
|
||||
date === "2026-09-04"
|
||||
? value
|
||||
: ((rawValue % (target + 1)) + target + 1) % (target + 1),
|
||||
state:
|
||||
date > "2026-09-04"
|
||||
? "future"
|
||||
: fixtureIndex % 19 === 0
|
||||
? "not-due"
|
||||
: "due",
|
||||
};
|
||||
});
|
||||
}
|
||||
71
src/components/design-system/editing-model.test.ts
Normal file
71
src/components/design-system/editing-model.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
EDITOR_HABITS,
|
||||
backfillError,
|
||||
historicalDays,
|
||||
validateEditor,
|
||||
validateTasks,
|
||||
} from "./editing-model";
|
||||
|
||||
describe("design-system editors", () => {
|
||||
test("all initial configurations are valid", () => {
|
||||
for (const habit of EDITOR_HABITS)
|
||||
expect(validateEditor(habit.config)).toBeNull();
|
||||
});
|
||||
test("requires a name, valid target, and nonempty weekday schedule", () => {
|
||||
const config = EDITOR_HABITS[0]!.config;
|
||||
expect(validateEditor({ ...config, name: " " })).not.toBeNull();
|
||||
if (config.method !== "count") throw new Error("Expected count fixture");
|
||||
for (const target of [0, 1.5, NaN, 10001])
|
||||
expect(validateEditor({ ...config, target })).not.toBeNull();
|
||||
expect(
|
||||
validateEditor({ ...config, schedule: { type: "weekdays", days: [] } }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
test("validates task names and independent recurrence", () => {
|
||||
expect(
|
||||
validateTasks([{ id: "1", name: "", schedule: { type: "daily" } }]),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
validateTasks([
|
||||
{
|
||||
id: "1",
|
||||
name: "Read",
|
||||
schedule: { type: "interval", every: 0, anchor: "2026-09-04" },
|
||||
},
|
||||
]),
|
||||
).not.toBeNull();
|
||||
expect(validateTasks([])).toBeNull();
|
||||
});
|
||||
test("backfills reject today, future, nonexistent and unscheduled dates", () => {
|
||||
for (const date of [
|
||||
"2026-09-04",
|
||||
"2026-09-05",
|
||||
"2026-02-30",
|
||||
"2025-09-30",
|
||||
"",
|
||||
])
|
||||
expect(backfillError("water", date, 1)).not.toBeNull();
|
||||
const notDue = historicalDays("water").find(
|
||||
(day) => day.state === "not-due",
|
||||
)!;
|
||||
expect(backfillError("water", notDue.date, 1)).not.toBeNull();
|
||||
});
|
||||
test("corrections allow zero and over-target counts but reject invalid totals", () => {
|
||||
for (const value of [0, 8, 12, 1_000_000_000])
|
||||
expect(backfillError("water", "2026-09-03", value)).toBeNull();
|
||||
for (const value of [-1, 1.5, NaN, 1_000_000_001])
|
||||
expect(backfillError("water", "2026-09-03", value)).not.toBeNull();
|
||||
expect(backfillError("reading", "2026-09-03", 2)).not.toBeNull();
|
||||
expect(backfillError("tasks", "2026-09-03", 4)).not.toBeNull();
|
||||
});
|
||||
test("editing a current target or task list does not rewrite historical requirements", () => {
|
||||
const copy = structuredClone(EDITOR_HABITS);
|
||||
const water = copy[0]!.config;
|
||||
const tasks = copy[2]!.config;
|
||||
if (water.method === "count") water.target = 12;
|
||||
if (tasks.method === "tasks") tasks.tasks.pop();
|
||||
expect(historicalDays("water")[0]!.target).toBe(8);
|
||||
expect(historicalDays("tasks")[0]!.target).toBe(3);
|
||||
});
|
||||
});
|
||||
110
src/components/design-system/editing-model.ts
Normal file
110
src/components/design-system/editing-model.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
dateSchema,
|
||||
habitInput,
|
||||
taskInput,
|
||||
type HabitConfig,
|
||||
} from "../../habits/contracts";
|
||||
import { demoCalendar, HABIT_COLORS } from "./calendar-model";
|
||||
|
||||
export const EDITOR_TODAY = "2026-09-04";
|
||||
export const EDITOR_START = "2025-10-01";
|
||||
export const EDITOR_HABITS: {
|
||||
id: string;
|
||||
color: string;
|
||||
config: HabitConfig;
|
||||
}[] = [
|
||||
{
|
||||
id: "water",
|
||||
color: HABIT_COLORS.water,
|
||||
config: {
|
||||
name: "Drink water",
|
||||
method: "count",
|
||||
target: 8,
|
||||
unit: "glasses",
|
||||
carryPartialProgress: false,
|
||||
schedule: { type: "daily" },
|
||||
archived: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "reading",
|
||||
color: HABIT_COLORS.reading,
|
||||
config: {
|
||||
name: "Read a little",
|
||||
method: "manual",
|
||||
schedule: { type: "daily" },
|
||||
archived: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tasks",
|
||||
color: HABIT_COLORS.tasks,
|
||||
config: {
|
||||
name: "Evening reset",
|
||||
method: "tasks",
|
||||
schedule: { type: "daily" },
|
||||
archived: false,
|
||||
tasks: ["Clear desk", "Plan tomorrow", "Stretch"].map((name, index) => ({
|
||||
id: `task-${index}`,
|
||||
name,
|
||||
schedule: { type: "daily" },
|
||||
})),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function validateEditor(config: HabitConfig): string | null {
|
||||
const { archived: _, ...input } = config;
|
||||
const result = habitInput.safeParse(
|
||||
config.method === "tasks"
|
||||
? { ...input, tasks: config.tasks.map(({ id: _, ...task }) => task) }
|
||||
: input,
|
||||
);
|
||||
return result.success
|
||||
? null
|
||||
: (result.error.issues[0]?.message ?? "Check the habit settings.");
|
||||
}
|
||||
|
||||
export function validateTasks(
|
||||
tasks: Extract<HabitConfig, { method: "tasks" }>["tasks"],
|
||||
): string | null {
|
||||
if (tasks.length > 100) return "Use no more than 100 tasks.";
|
||||
for (const { id: _, ...task } of tasks) {
|
||||
const result = taskInput.safeParse(task);
|
||||
if (!result.success)
|
||||
return result.error.issues[0]?.message ?? "Check each task.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Frozen historical requirements deliberately do not read today's edited config. */
|
||||
export function historicalDays(id: string) {
|
||||
const config = EDITOR_HABITS.find((habit) => habit.id === id)!.config;
|
||||
return demoCalendar(
|
||||
0,
|
||||
config.method === "count"
|
||||
? config.target
|
||||
: config.method === "tasks"
|
||||
? config.tasks.length
|
||||
: 1,
|
||||
);
|
||||
}
|
||||
|
||||
export function backfillError(
|
||||
id: string,
|
||||
date: string,
|
||||
value: number,
|
||||
): string | null {
|
||||
if (!dateSchema.safeParse(date).success) return "Choose a valid date.";
|
||||
if (date >= EDITOR_TODAY)
|
||||
return "Choose a past date. Today is edited in the daily view.";
|
||||
if (date < EDITOR_START) return "This habit did not exist on that date.";
|
||||
const day = historicalDays(id).find((day) => day.date === date);
|
||||
if (!day || day.state !== "due")
|
||||
return "Nothing was scheduled on this date. There is no progress to correct.";
|
||||
if (!Number.isInteger(value) || value < 0 || value > 1_000_000_000)
|
||||
return "Enter a whole number from 0 to 1,000,000,000.";
|
||||
if (id !== "water" && value > day.target)
|
||||
return "Progress cannot exceed the scheduled items.";
|
||||
return null;
|
||||
}
|
||||
124
src/components/design-system/primitives.tsx
Normal file
124
src/components/design-system/primitives.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
className = "",
|
||||
type = "button",
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: "primary" | "secondary" | "text";
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={`ds-button ds-button--${variant} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionHeading({
|
||||
number,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
number: string;
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="ds-section-heading">
|
||||
<span className="ds-eyebrow">{number}</span>
|
||||
<h2>{title}</h2>
|
||||
{children && <p>{children}</p>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
label,
|
||||
className = "",
|
||||
...props
|
||||
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string }) {
|
||||
return (
|
||||
<label className={`ds-checkbox ${className}`}>
|
||||
<input type="checkbox" {...props} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Counter({
|
||||
label,
|
||||
value,
|
||||
target,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
target: number;
|
||||
onChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="ds-counter" role="group" aria-label={label}>
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label={`Decrease ${label}`}
|
||||
disabled={disabled || value <= 0}
|
||||
onClick={() => onChange(Math.max(0, value - 1))}
|
||||
>
|
||||
−
|
||||
</Button>
|
||||
<output aria-live="polite">
|
||||
<span>{value}</span>
|
||||
<span className="ds-muted"> / {target}</span>
|
||||
</output>
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label={`Increase ${label}`}
|
||||
disabled={disabled || value >= target}
|
||||
onClick={() => onChange(Math.min(target, value + 1))}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HabitRow({
|
||||
name,
|
||||
description,
|
||||
complete,
|
||||
children,
|
||||
tasks,
|
||||
}: {
|
||||
name: string;
|
||||
description: string;
|
||||
complete: boolean;
|
||||
children: ReactNode;
|
||||
tasks?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="ds-habit-row">
|
||||
<div className="ds-habit-main">
|
||||
<div>
|
||||
<h4>{name}</h4>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="ds-habit-control">
|
||||
{children}
|
||||
<span className="ds-status">
|
||||
{complete ? "Complete" : "In progress"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{tasks && <div className="ds-task-list">{tasks}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const schedule = scheduleSchema.default({ type: 'daily' });
|
||||
export const taskInput = z.object({ name, schedule }).strict();
|
||||
// Creation defaults must not become writes when PATCH omits a property.
|
||||
export const taskPatch = z.object({ name: name.optional(), schedule: scheduleSchema.optional() }).strict().refine(v => Object.keys(v).length > 0);
|
||||
const common = { name, schedule };
|
||||
const common = { name, schedule, color: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Use a six-digit hex color').optional() };
|
||||
export const habitInput = z.discriminatedUnion('method', [
|
||||
z.object({ ...common, method: z.literal('count'), carryPartialProgress: z.boolean().default(false), target: z.number().int().min(1).max(10000), unit: z.string().trim().min(1).max(80).default('steps') }).strict(),
|
||||
z.object({ ...common, method: z.literal('manual') }).strict(),
|
||||
@@ -27,7 +27,7 @@ export type HabitConfig = { name: string; schedule: Schedule; archived: boolean
|
||||
{ method: 'manual' } |
|
||||
{ method: 'tasks'; tasks: { id: string; name: string; schedule: Schedule }[] }
|
||||
);
|
||||
export const habitPatch = z.object({ carryPartialProgress: z.boolean().optional(), name: name.optional(), schedule: scheduleSchema.optional(), method: z.enum(['count', 'manual', 'tasks']).optional(), target: z.number().int().min(1).max(10000).optional(), unit: z.string().trim().min(1).max(80).optional(), archived: z.boolean().optional() }).strict().refine(v => Object.keys(v).length > 0);
|
||||
export const habitPatch = z.object({ color: common.color, carryPartialProgress: z.boolean().optional(), name: name.optional(), schedule: scheduleSchema.optional(), method: z.enum(['count', 'manual', 'tasks']).optional(), target: z.number().int().min(1).max(10000).optional(), unit: z.string().trim().min(1).max(80).optional(), archived: z.boolean().optional() }).strict().refine(v => Object.keys(v).length > 0);
|
||||
export const progressInput = z.union([z.object({ count: z.number().int().min(0).max(1_000_000_000) }).strict(), z.object({ done: z.boolean() }).strict()]);
|
||||
export const doneInput = z.object({ done: z.boolean() }).strict();
|
||||
const color = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Use a six-digit hex color');
|
||||
|
||||
@@ -8,6 +8,36 @@ test('failed revision writes roll back the habit atomically and do not expose da
|
||||
expect(response.status).toBe(500); expect(await response.json()).toEqual({ error: 'Internal server error' });
|
||||
expect((await f.json('/habits')).habits).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('creation saves color atomically and never puts presentation into requirements', async () => {
|
||||
for (const method of ['manual', 'count', 'tasks']) {
|
||||
const h = await f.json('/habits', 'POST', { name: method, method, color: '#79618d', ...(method === 'count' ? { target: 8 } : {}) }, 201);
|
||||
expect((await f.json(`/habits/${h.id}/calendar-settings`)).mainColor).toBe('#79618d');
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).requirements.color).toBeUndefined();
|
||||
}
|
||||
expect((await f.request('/habits', 'POST', { name: 'Invalid', method: 'manual', color: 'red' })).status).toBe(422);
|
||||
expect((await f.json('/habits')).habits).toHaveLength(3);
|
||||
f.sqlite.exec("CREATE TRIGGER reject_color BEFORE INSERT ON habit_calendar_settings BEGIN SELECT RAISE(ABORT, 'private color detail'); END");
|
||||
const response = await f.request('/habits', 'POST', { name: 'Rollback color', method: 'manual', color: '#426582' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({ error: 'Internal server error' });
|
||||
expect((await f.json('/habits')).habits).toHaveLength(3);
|
||||
});
|
||||
test('edits save color with requirements atomically, preserving history and other settings', async () => {
|
||||
const h = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8, color: '#426582' }, 201);
|
||||
await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#426582', emptyColor: '#fafafa', shadeCount: 4 });
|
||||
f.setTime('2026-09-05T12:00Z');
|
||||
await f.json(`/habits/${h.id}`, 'PATCH', { target: 10, color: '#79618d' });
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).target).toBe(8);
|
||||
expect((await f.json(`/habits/${h.id}/calendar-settings`))).toMatchObject({ mainColor: '#79618d', emptyColor: '#fafafa', shadeCount: 4 });
|
||||
expect((await f.json(`/habits/${h.id}`)).color).toBeUndefined();
|
||||
expect((await f.request(`/habits/${h.id}`, 'PATCH', { color: 'red' })).status).toBe(422);
|
||||
f.sqlite.exec("CREATE TRIGGER reject_color_update BEFORE UPDATE ON habit_calendar_settings BEGIN SELECT RAISE(ABORT, 'private color detail'); END");
|
||||
expect((await f.request(`/habits/${h.id}`, 'PATCH', { name: 'Should roll back', target: 12, color: '#58765b' })).status).toBe(500);
|
||||
expect((await f.json(`/habits/${h.id}`))).toMatchObject({ name: 'Water', target: 10 });
|
||||
expect((await f.json(`/habits/${h.id}/calendar-settings`)).mainColor).toBe('#79618d');
|
||||
});
|
||||
|
||||
test('same-day method switches reset inherited progress even when returning to count', async () => {
|
||||
const h = await f.json('/habits', 'POST', { name: 'Carry', method: 'count', target: 8, carryPartialProgress: true }, 201);
|
||||
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 }); f.setTime('2026-09-05T12:00Z');
|
||||
|
||||
@@ -55,12 +55,14 @@ export class HabitService {
|
||||
}
|
||||
create(input: ReturnType<typeof habitInput.parse>) {
|
||||
const id = crypto.randomUUID();
|
||||
const config: HabitConfig = input.method === 'tasks'
|
||||
? { ...input, archived: false, tasks: input.tasks.map(t => ({ ...t, id: crypto.randomUUID() })) }
|
||||
: { ...input, archived: false };
|
||||
const { color, ...requirements } = input;
|
||||
const config: HabitConfig = requirements.method === 'tasks'
|
||||
? { ...requirements, archived: false, tasks: requirements.tasks.map(t => ({ ...t, id: crypto.randomUUID() })) }
|
||||
: { ...requirements, archived: false };
|
||||
this.db.transaction(() => {
|
||||
this.db.insert(habits).values({ id, userId: this.user.id, createdDate: this.today, createdAt: this.now }).run();
|
||||
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
|
||||
if (color) this.saveSettings(id, calendarSettingsSchema.parse({ mainColor: color }));
|
||||
this.materialize(this.owned(id));
|
||||
});
|
||||
return this.current(id);
|
||||
@@ -80,7 +82,13 @@ export class HabitService {
|
||||
const config: HabitConfig = parsed.data.method === 'tasks'
|
||||
? { ...parsed.data, tasks: old.method === 'tasks' ? old.tasks : [], archived: (patch.archived ?? old.archived) as boolean }
|
||||
: { ...parsed.data, archived: (patch.archived ?? old.archived) as boolean };
|
||||
return this.revise(id, config);
|
||||
return this.db.transaction(() => {
|
||||
const updated = this.revise(id, config);
|
||||
if (patch.color !== undefined) {
|
||||
this.saveSettings(id, calendarSettingsSchema.parse({ ...this.settings(id), mainColor: patch.color }));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
revise(id: string, config: HabitConfig) {
|
||||
this.invalidate(id);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Minabot</title>
|
||||
<meta name="description" content="A quiet place to track your habits and see your progress. Explore Minabot’s interactive habit calendars and connect with Discord." />
|
||||
<script type="module" src="./frontend.tsx" async></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
48
src/lib/dashboard.ts
Normal file
48
src/lib/dashboard.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { HabitService } from "../habits/service";
|
||||
import type { Schedule } from "../habits/contracts";
|
||||
|
||||
export type TodayHabit = ReturnType<HabitService["day"]>;
|
||||
export type TodayResponse = {
|
||||
date: string;
|
||||
timezone: string;
|
||||
habits: TodayHabit[];
|
||||
due: number;
|
||||
completed: number;
|
||||
};
|
||||
|
||||
export async function habitRequest<T>(
|
||||
path: string,
|
||||
options?: RequestInit,
|
||||
): Promise<T> {
|
||||
const response = await fetch(`/api${path}`, options);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401)
|
||||
throw new Error("Your session has expired. Sign in again to continue.");
|
||||
const body = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
body?.error || "Could not reach your workspace. Please try again.",
|
||||
);
|
||||
}
|
||||
return response.status === 204
|
||||
? (undefined as T)
|
||||
: (response.json() as Promise<T>);
|
||||
}
|
||||
|
||||
export function scheduleLabel(schedule?: Schedule) {
|
||||
if (!schedule || schedule.type === "daily") return "Every day";
|
||||
if (schedule.type === "weekdays")
|
||||
return schedule.days
|
||||
.map((day) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][day])
|
||||
.join(", ");
|
||||
if (schedule.type === "interval") return `Every ${schedule.every} days`;
|
||||
return `Every ${schedule.every === 1 ? "week" : `${schedule.every} weeks`} · ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][schedule.weekday]}`;
|
||||
}
|
||||
|
||||
export function formatTrackingDate(date: string) {
|
||||
return new Date(`${date}T12:00:00Z`).toLocaleDateString("en", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
603
src/pages/DesignSystem.tsx
Normal file
603
src/pages/DesignSystem.tsx
Normal file
@@ -0,0 +1,603 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Counter,
|
||||
SectionHeading,
|
||||
} from "../components/design-system/primitives";
|
||||
import { CalendarHeatmap } from "../components/design-system/CalendarHeatmap";
|
||||
import { HabitChart } from "../components/design-system/HabitChart";
|
||||
import { EditingWorkbench } from "../components/design-system/EditingWorkbench";
|
||||
import type { HabitColors } from "../components/design-system/HabitColorPicker";
|
||||
import {
|
||||
combinedProgress,
|
||||
demoCalendar,
|
||||
PROGRESS_SHADES,
|
||||
HABIT_COLORS,
|
||||
} from "../components/design-system/calendar-model";
|
||||
import todayImage from "../assets/design/today-v2.png";
|
||||
import habitImage from "../assets/design/habit-detail.png";
|
||||
import "../../styles/design-system.css";
|
||||
|
||||
type View = "Today" | "Habit detail" | "Combined chart";
|
||||
|
||||
export function DesignSystem() {
|
||||
const [habitColors, setHabitColors] = useState<HabitColors>({ ...HABIT_COLORS });
|
||||
const [view, setView] = useState<View>("Today");
|
||||
const [water, setWater] = useState(7);
|
||||
const [read, setRead] = useState(true);
|
||||
const [tasks, setTasks] = useState([true, true, false]);
|
||||
const [movement, setMovement] = useState(10);
|
||||
const [included, setIncluded] = useState([true, true, true, true]);
|
||||
const [exampleChecked, setExampleChecked] = useState(false);
|
||||
const [exampleCount, setExampleCount] = useState(3);
|
||||
const [habitName, setHabitName] = useState("");
|
||||
const [savedName, setSavedName] = useState("");
|
||||
const taskCount = tasks.filter(Boolean).length;
|
||||
const habits = [
|
||||
{ value: water, target: 8, due: true },
|
||||
{ value: Number(read), target: 1, due: true },
|
||||
{ value: taskCount, target: 3, due: true },
|
||||
{ value: movement, target: 20, due: true },
|
||||
];
|
||||
const total = combinedProgress(habits);
|
||||
const combined = combinedProgress(
|
||||
habits.filter((_, index) => included[index]),
|
||||
);
|
||||
const isIndividual = view === "Habit detail";
|
||||
const chartDays = demoCalendar(
|
||||
isIndividual ? water : combined.completed,
|
||||
isIndividual ? 8 : combined.due,
|
||||
).map((day) =>
|
||||
!isIndividual && combined.due === 0 && day.state !== "future"
|
||||
? { ...day, state: "not-due" as const }
|
||||
: day,
|
||||
);
|
||||
function resetDemo() {
|
||||
setWater(7);
|
||||
setRead(true);
|
||||
setTasks([true, true, false]);
|
||||
setMovement(10);
|
||||
setIncluded([true, true, true, true]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ds-root" id="top">
|
||||
<a className="ds-skip-link" href="#ds-main">
|
||||
Skip to content
|
||||
</a>
|
||||
<header className="ds-header">
|
||||
<Link className="ds-wordmark" to="/">
|
||||
minabot<span aria-hidden="true">.</span>
|
||||
</Link>
|
||||
<nav aria-label="Design system navigation">
|
||||
<a href="#editing">Editing</a>
|
||||
<a href="#foundations">Foundations</a>
|
||||
<a href="#components">Components</a>
|
||||
<a href="#views">
|
||||
Views <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</nav>
|
||||
<span className="ds-edition">DESIGN SYSTEM / 01</span>
|
||||
</header>
|
||||
<main id="ds-main" className="ds-main">
|
||||
<section className="ds-intro" aria-labelledby="ds-title">
|
||||
<div>
|
||||
<p className="ds-eyebrow">MINABOT — INTERFACE LANGUAGE</p>
|
||||
<h1 id="ds-title">
|
||||
Less interface.
|
||||
<br />
|
||||
<em>More intention.</em>
|
||||
</h1>
|
||||
<p className="ds-intro-copy">
|
||||
A quiet foundation for everyday progress.
|
||||
<br />A quiet canvas. A color for every habit.
|
||||
</p>
|
||||
</div>
|
||||
<div className="ds-intro-note">
|
||||
<span className="ds-tiny-cross" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
<p>
|
||||
Flat by design.
|
||||
<br />
|
||||
Space, not containers.
|
||||
<br />
|
||||
Clarity in every state.
|
||||
</p>
|
||||
<a href="#playground">
|
||||
Explore the system <span aria-hidden="true">↓</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="ds-section"
|
||||
id="playground"
|
||||
aria-labelledby="playground-title"
|
||||
>
|
||||
<div className="ds-section-top">
|
||||
<div>
|
||||
<span className="ds-eyebrow">01 / IN PRACTICE</span>
|
||||
<h2 id="playground-title">
|
||||
A little, <em>every day.</em>
|
||||
</h2>
|
||||
</div>
|
||||
<span className="ds-demo-note">
|
||||
<span aria-hidden="true">◦</span> Interactive demo · not saved
|
||||
</span>
|
||||
</div>
|
||||
<div className="ds-preview-toolbar">
|
||||
<div
|
||||
className="ds-view-switch"
|
||||
role="group"
|
||||
aria-label="Preview view"
|
||||
>
|
||||
{(["Today", "Habit detail", "Combined chart"] as const).map(
|
||||
(item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item}
|
||||
aria-pressed={view === item}
|
||||
onClick={() => setView(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<Button variant="text" onClick={resetDemo}>
|
||||
Reset demo <span aria-hidden="true">↺</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ds-preview-heading">
|
||||
<div>
|
||||
<p className="ds-eyebrow">FRIDAY, SEPTEMBER 4, 2026</p>
|
||||
<h3>
|
||||
{view === "Today"
|
||||
? "Your habits, at a glance."
|
||||
: isIndividual
|
||||
? "Drink water"
|
||||
: "Your rhythm, together."}
|
||||
</h3>
|
||||
</div>
|
||||
<p aria-live="polite">
|
||||
{view === "Today"
|
||||
? `${total.completed} of ${total.due} complete`
|
||||
: isIndividual
|
||||
? "Daily · 8 glasses"
|
||||
: combined.due
|
||||
? `${combined.completed} of ${combined.due} habits complete`
|
||||
: "No habits selected"}
|
||||
</p>
|
||||
</div>
|
||||
{view === "Today" ? (
|
||||
<div className="ds-habit-chart-grid">
|
||||
<HabitChart
|
||||
name="Drink water"
|
||||
method="Count target"
|
||||
value={water}
|
||||
target={8}
|
||||
unit="glasses"
|
||||
color={habitColors.water}
|
||||
>
|
||||
<Counter
|
||||
label="glasses of water"
|
||||
value={water}
|
||||
target={8}
|
||||
onChange={setWater}
|
||||
/>
|
||||
</HabitChart>
|
||||
<HabitChart
|
||||
name="Read a little"
|
||||
method="Manual checkbox"
|
||||
value={Number(read)}
|
||||
target={1}
|
||||
unit="reading session"
|
||||
color={habitColors.reading}
|
||||
>
|
||||
<Checkbox
|
||||
label="Mark reading done"
|
||||
className="ds-checkbox--icon"
|
||||
checked={read}
|
||||
onChange={(event) => setRead(event.target.checked)}
|
||||
/>
|
||||
</HabitChart>
|
||||
<HabitChart
|
||||
name="Evening reset"
|
||||
method="Task-based"
|
||||
value={taskCount}
|
||||
target={3}
|
||||
unit="tasks"
|
||||
color={habitColors.tasks}
|
||||
tasks={["Clear desk", "Plan tomorrow", "Stretch"].map(
|
||||
(label, index) => (
|
||||
<Checkbox
|
||||
key={label}
|
||||
label={label}
|
||||
checked={tasks[index]}
|
||||
onChange={(event) =>
|
||||
setTasks((current) =>
|
||||
current.map((done, i) =>
|
||||
i === index ? event.target.checked : done,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
/>
|
||||
<HabitChart
|
||||
name="Move a little"
|
||||
method="Count target"
|
||||
value={movement}
|
||||
target={20}
|
||||
unit="minutes"
|
||||
color={habitColors.movement}
|
||||
>
|
||||
<Counter
|
||||
label="minutes of movement"
|
||||
value={movement}
|
||||
target={20}
|
||||
onChange={setMovement}
|
||||
/>
|
||||
</HabitChart>
|
||||
<p className="ds-footnote ds-habit-chart-grid-note">
|
||||
Each habit has its own rhythm. Expand tasks to log your day;
|
||||
only fully completed habits count in a combined chart.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ds-chart-preview">
|
||||
{isIndividual ? (
|
||||
<div className="ds-detail-count">
|
||||
<span className="ds-big-value">
|
||||
{water}
|
||||
<span className="ds-muted"> / 8</span>
|
||||
<small>glasses</small>
|
||||
</span>
|
||||
<Counter
|
||||
label="detail glasses"
|
||||
value={water}
|
||||
target={8}
|
||||
onChange={setWater}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="ds-chart-selection"
|
||||
role="group"
|
||||
aria-label="Included habits"
|
||||
>
|
||||
{[
|
||||
"Drink water",
|
||||
"Read a little",
|
||||
"Evening reset",
|
||||
"Move a little",
|
||||
].map((label, index) => (
|
||||
<Checkbox
|
||||
key={label}
|
||||
label={label}
|
||||
checked={included[index]}
|
||||
onChange={(event) =>
|
||||
setIncluded((current) =>
|
||||
current.map((checked, i) =>
|
||||
i === index ? event.target.checked : checked,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<CalendarHeatmap
|
||||
key={view}
|
||||
days={chartDays}
|
||||
unit={isIndividual ? "glasses" : "habits complete"}
|
||||
label={
|
||||
isIndividual
|
||||
? "Drink water progress calendar"
|
||||
: "Combined habit calendar"
|
||||
}
|
||||
color={isIndividual ? habitColors.water : "#111111"}
|
||||
/>
|
||||
<p className="ds-footnote">
|
||||
{isIndividual
|
||||
? "Partial progress is visible here. Only 8 of 8 glasses counts as complete in a combined chart."
|
||||
: "Equal weight. Only fully completed, due habits count. Nothing scheduled means no score, not a missed day."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<EditingWorkbench onColorsChange={setHabitColors} />
|
||||
|
||||
<section className="ds-section ds-split-section" id="foundations">
|
||||
<SectionHeading number="03 / FOUNDATIONS" title="The essentials.">
|
||||
A small vocabulary.
|
||||
<br />
|
||||
Used with intention.
|
||||
</SectionHeading>
|
||||
<div className="ds-section-content">
|
||||
<div className="ds-spec-heading">
|
||||
<h3>Typography</h3>
|
||||
<span className="ds-code">Instrument Serif + system sans</span>
|
||||
</div>
|
||||
<div className="ds-type-specimen">
|
||||
<span className="ds-type-display">
|
||||
Small steps.
|
||||
<br />
|
||||
<em>Lasting rhythm.</em>
|
||||
</span>
|
||||
<p>Instrument Serif · Regular & italic · Display</p>
|
||||
</div>
|
||||
<div className="ds-body-specimen">
|
||||
<span>Room to breathe. A clear next step.</span>
|
||||
<p>System sans · 14–16px · Body & controls</p>
|
||||
</div>
|
||||
<div className="ds-spec-heading ds-spec-heading--spaced">
|
||||
<h3>Monochrome</h3>
|
||||
<span className="ds-code">The interface stays neutral</span>
|
||||
</div>
|
||||
<div className="ds-palette">
|
||||
{[
|
||||
{ name: "Ink", value: "#111111" },
|
||||
{ name: "Secondary", value: "#666666" },
|
||||
{ name: "Rule", value: "#DEDEDE" },
|
||||
{ name: "Paper", value: "#FFFFFF" },
|
||||
].map((color) => (
|
||||
<div key={color.name}>
|
||||
<div
|
||||
className="ds-swatch"
|
||||
style={{ background: color.value }}
|
||||
/>
|
||||
<p>
|
||||
{color.name}
|
||||
<span className="ds-code">{color.value}</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="ds-spec-heading ds-spec-heading--spaced">
|
||||
<h3>Habit colors</h3>
|
||||
<span className="ds-code">Identity, not decoration</span>
|
||||
</div>
|
||||
<div className="ds-habit-color-key">
|
||||
{Object.entries(HABIT_COLORS).map(([name, color]) => (
|
||||
<span key={name}>
|
||||
<i style={{ backgroundColor: color }} aria-hidden="true" />
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="ds-spec-heading ds-spec-heading--spaced">
|
||||
<h3>Space & structure</h3>
|
||||
<span className="ds-code">4px base</span>
|
||||
</div>
|
||||
<div className="ds-spacing">
|
||||
{[4, 8, 16, 24, 32, 48].map((space) => (
|
||||
<div key={space}>
|
||||
<span style={{ width: space }} />
|
||||
<code>{space}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="ds-footnote">
|
||||
Square corners. One-pixel rules. No cards, shadows, or decorative
|
||||
surfaces.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ds-section ds-split-section" id="components">
|
||||
<SectionHeading number="04 / COMPONENTS" title="Only what matters.">
|
||||
Purpose-built primitives.
|
||||
<br />
|
||||
Try them for yourself.
|
||||
</SectionHeading>
|
||||
<div className="ds-section-content">
|
||||
<div className="ds-spec-heading">
|
||||
<h3>Actions</h3>
|
||||
<span className="ds-code">Button</span>
|
||||
</div>
|
||||
<div className="ds-actions">
|
||||
<Button
|
||||
onClick={() => {
|
||||
document.getElementById("habit-name")?.focus();
|
||||
}}
|
||||
>
|
||||
Add a habit <span aria-hidden="true">+</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setExampleCount(3);
|
||||
setExampleChecked(false);
|
||||
setHabitName("");
|
||||
setSavedName("");
|
||||
}}
|
||||
>
|
||||
Reset examples
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
setView("Habit detail");
|
||||
document.getElementById("playground")?.scrollIntoView();
|
||||
}}
|
||||
>
|
||||
View habit <span aria-hidden="true">↗</span>
|
||||
</Button>
|
||||
<Button disabled>Unavailable</Button>
|
||||
</div>
|
||||
<div className="ds-component-row">
|
||||
<div>
|
||||
<h3>A simple yes</h3>
|
||||
<span className="ds-code">Checkbox</span>
|
||||
</div>
|
||||
<Checkbox
|
||||
label={exampleChecked ? "Done for today" : "Mark as done"}
|
||||
checked={exampleChecked}
|
||||
onChange={(event) => setExampleChecked(event.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ds-component-row">
|
||||
<div>
|
||||
<h3>A little more</h3>
|
||||
<span className="ds-code">Counter · bounded 0–8</span>
|
||||
</div>
|
||||
<Counter
|
||||
label="example count"
|
||||
value={exampleCount}
|
||||
target={8}
|
||||
onChange={setExampleCount}
|
||||
/>
|
||||
</div>
|
||||
<form
|
||||
className="ds-example-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (habitName.trim()) setSavedName(habitName.trim());
|
||||
}}
|
||||
>
|
||||
<div className="ds-spec-heading">
|
||||
<label htmlFor="habit-name">Give it a name</label>
|
||||
<span className="ds-code">Text input</span>
|
||||
</div>
|
||||
<div className="ds-input-row">
|
||||
<input
|
||||
id="habit-name"
|
||||
required
|
||||
maxLength={80}
|
||||
placeholder="e.g. Read ten pages"
|
||||
value={habitName}
|
||||
onChange={(event) => {
|
||||
setHabitName(event.target.value);
|
||||
setSavedName("");
|
||||
}}
|
||||
aria-describedby="name-hint"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
disabled={!habitName.trim()}
|
||||
>
|
||||
Try it <span aria-hidden="true">↗</span>
|
||||
</Button>
|
||||
</div>
|
||||
<p id="name-hint" className="ds-footnote" role="status">
|
||||
{savedName
|
||||
? `“${savedName}” — preview accepted. No habit was created.`
|
||||
: "Keep it short and personal. This is a form preview, not a new habit."}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ds-section ds-split-section" id="calendar-states">
|
||||
<SectionHeading
|
||||
number="05 / PROGRESS LANGUAGE"
|
||||
title="Every shade counts."
|
||||
>
|
||||
Progress is a spectrum.
|
||||
<br />
|
||||
Completion is a clear rule.
|
||||
</SectionHeading>
|
||||
<div className="ds-section-content">
|
||||
<div className="ds-spec-heading">
|
||||
<h3>One glass at a time</h3>
|
||||
<span className="ds-code">CalendarHeatmap</span>
|
||||
</div>
|
||||
<div className="ds-shade-scale">
|
||||
{PROGRESS_SHADES.map((color, index) => (
|
||||
<div key={color}>
|
||||
<span style={{ backgroundColor: color }} />
|
||||
<small>{index}/8</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="ds-scale-caption">
|
||||
<span>Due · no progress</span>
|
||||
<span>Partial progress</span>
|
||||
<span>Complete</span>
|
||||
</div>
|
||||
<div className="ds-neutral-states">
|
||||
<div>
|
||||
<span className="ds-state-square ds-state-square--not-due" />
|
||||
<div>
|
||||
<h4>Nothing scheduled</h4>
|
||||
<p>A neutral dot, not a missed day. Excluded from the score.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="ds-state-square ds-state-square--future" />
|
||||
<div>
|
||||
<h4>Upcoming</h4>
|
||||
<p>Same fill as zero. Labeled Upcoming on inspection.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="ds-footnote">
|
||||
Calendar cells expose exact values and status on selection, hover,
|
||||
and keyboard focus. Shade is never the only signal.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ds-section" id="views">
|
||||
<div className="ds-section-top">
|
||||
<div>
|
||||
<span className="ds-eyebrow">06 / VISUAL DIRECTION</span>
|
||||
<h2>From idea to interface.</h2>
|
||||
</div>
|
||||
<p className="ds-muted">
|
||||
Two imagegen studies, grounded in the PRD.
|
||||
</p>
|
||||
</div>
|
||||
<div className="ds-reference-grid">
|
||||
<figure>
|
||||
<a href={todayImage} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
src={todayImage}
|
||||
alt="Generated Today view: four colored habit charts in a two-column grid with an expanded task accordion, serif headings, and no cards"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
<figcaption>
|
||||
<span>01 — Today</span>
|
||||
<span className="ds-muted">
|
||||
Daily progress <span aria-hidden="true">↗</span>
|
||||
</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<a href={habitImage} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
src={habitImage}
|
||||
alt="Generated Drink water detail view: large seven-of-eight count and monochromatic calendar"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
<figcaption>
|
||||
<span>02 — Habit detail</span>
|
||||
<span className="ds-muted">
|
||||
A closer look <span aria-hidden="true">↗</span>
|
||||
</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<p className="ds-footnote">
|
||||
Visual references, not authoritative calendar data. The interactive
|
||||
components above implement exact counts and distinct date states.
|
||||
</p>
|
||||
</section>
|
||||
<footer className="ds-footer">
|
||||
<span className="ds-wordmark">minabot.</span>
|
||||
<span>A little, every day.</span>
|
||||
<a href="#top">Back to top ↑</a>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,744 @@
|
||||
export function Home() {
|
||||
return <h1>Home</h1>;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
105
src/pages/Landing.tsx
Normal file
105
src/pages/Landing.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user