Add new styles for home and landing pages

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

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

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

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
src/assets/design/today.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

Binary file not shown.

93
src/assets/fonts/OFL.txt Normal file
View 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.

View File

@@ -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>
);
}

View 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;
}

View 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 dont 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 isnt 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>
);
}

View 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",
);
});

View 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 habits 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>
);
}

View 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");
});

View 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",
};
});
}

View 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);
});
});

View 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;
}

View 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>
);
}

View File

@@ -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');

View File

@@ -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');

View File

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

View File

@@ -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 Minabots interactive habit calendars and connect with Discord." />
<script type="module" src="./frontend.tsx" async></script>
</head>
<body>

48
src/lib/dashboard.ts Normal file
View 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
View 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 · 1416px · 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 08</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>
);
}

View File

@@ -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. Lets 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" : "TODAYS 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 its 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="Todays 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."
: "Youre all caught up."}
</h3>
<p>
{today.due === 0
? "No habits are scheduled for today. A day off isnt a missed day."
: "All of todays 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 arent included in todays 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
View 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 isnt 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 dont 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 arent 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>
);
}