feat: add Home page with user dashboard and habit tracking features
- Implemented Home component with user authentication and loading states. - Created Welcome component for unauthenticated users with a sign-in option. - Developed Dashboard component to display user's habits and progress. - Added functionality for habit management, including adding, updating, and deleting habits. - Integrated HabitChart and HabitHistory components for visual representation of habits. - Introduced sharing functionality for progress via Discord integration. feat: establish Discord sharing configuration and routes - Added DiscordSharingConfig type and readDiscordSharingConfig function for environment variable management. - Created sharing contracts for input validation and data structure. - Implemented sharing routes for previewing and sending progress images to Discord. - Added tests for sharing routes to ensure authentication and proper error handling.
This commit is contained in:
321
src/App.test.tsx
321
src/App.test.tsx
@@ -14,6 +14,7 @@ import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { CalendarHeatmap } from "./components/design-system/CalendarHeatmap";
|
||||
import { fixture } from "./habits/test-fixture";
|
||||
|
||||
// Keep the design system independent of authentication and the local database.
|
||||
const dom = new Window({ url: "http://localhost:3000/" });
|
||||
@@ -22,6 +23,7 @@ let createRoot: typeof import("react-dom/client").createRoot;
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let fetchMock: ReturnType<typeof spyOn<typeof globalThis, "fetch">>;
|
||||
let apiFixture: ReturnType<typeof fixture> | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
for (const key of [
|
||||
@@ -64,6 +66,8 @@ afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
fetchMock.mockRestore();
|
||||
apiFixture?.close();
|
||||
apiFixture = undefined;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -85,7 +89,7 @@ function LocationProbe() {
|
||||
</>;
|
||||
}
|
||||
|
||||
async function render(path = "/") {
|
||||
async function render(path = "/design-system") {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
@@ -96,6 +100,317 @@ async function render(path = "/") {
|
||||
);
|
||||
}
|
||||
|
||||
function connectAccount() {
|
||||
apiFixture = fixture();
|
||||
const f = apiFixture;
|
||||
fetchMock.mockImplementation((async (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Cookie", `minabot_session=${"a".repeat(43)}`);
|
||||
headers.set("Origin", f.origin);
|
||||
return f.app.request(new Request(new URL(String(input), f.origin), { ...init, headers }));
|
||||
}) as typeof fetch);
|
||||
return f;
|
||||
}
|
||||
|
||||
const buttonNamed = (name: string) => [...container.querySelectorAll<HTMLButtonElement>("button")]
|
||||
.find(button => button.textContent === name || button.getAttribute("aria-label") === name)!;
|
||||
|
||||
describe("homepage", () => {
|
||||
test("signed-out visitors can try progress without calling private habit APIs", async () => {
|
||||
await render("/");
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm.");
|
||||
expect(fetchMock.mock.calls.map(call => call[0])).toEqual(["/api/me"]);
|
||||
await act(async () => buttonNamed("Increase glasses of water").click());
|
||||
expect(container.querySelector(".ds-counter output")?.textContent).toBe("4 / 8");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("OAuth errors are retained at the homepage and unknown routes go home", async () => {
|
||||
await render("/?auth_error=denied");
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("cancelled");
|
||||
// A new router is needed when changing initialEntries after mount.
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
await render("/missing");
|
||||
expect(container.querySelector('[data-testid="pathname"]')?.textContent).toBe("/");
|
||||
expect(container.querySelector("#welcome-title")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("does not flash the signed-out page while the account is loading", async () => {
|
||||
let resolve!: (response: Response) => void;
|
||||
fetchMock.mockImplementation(Object.assign(() => new Promise<Response>(done => { resolve = done; }), { preconnect: fetch.preconnect }));
|
||||
await render("/");
|
||||
expect(container.textContent).toContain("Getting things ready");
|
||||
expect(container.querySelector("#welcome-title")).toBeNull();
|
||||
await act(async () => resolve(new Response(null, { status: 401 })));
|
||||
expect(container.querySelector("#welcome-title")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("account failures offer retry without pretending the user is signed out", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }));
|
||||
await render("/");
|
||||
expect(container.textContent).toContain("Account unavailable");
|
||||
expect(container.querySelector("#welcome-title")).toBeNull();
|
||||
await act(async () => buttonNamed("Try again").click());
|
||||
expect(container.querySelector("#welcome-title")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("signed-in users create a habit from the empty state and sign out", async () => {
|
||||
const f = connectAccount();
|
||||
await render("/");
|
||||
expect(container.textContent).toContain("Welcome back, alice.");
|
||||
expect(container.textContent).toContain("Start with one habit.");
|
||||
await act(async () => buttonNamed("Add a habit +").click());
|
||||
const input = container.querySelector<HTMLInputElement>("form input")!;
|
||||
expect(document.activeElement).toBe(input);
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "Read a little");
|
||||
input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
|
||||
});
|
||||
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||
expect((await f.json("/habits")).habits[0].name).toBe("Read a little");
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
expect(container.textContent).toContain("Read a little created.");
|
||||
expect(container.querySelector(".ds-habit-chart")).not.toBeNull();
|
||||
await act(async () => buttonNamed("Sign out").click());
|
||||
expect(container.querySelector("#welcome-title")).not.toBeNull();
|
||||
expect((await f.request("/me")).status).toBe(401);
|
||||
});
|
||||
|
||||
test("count, manual, and task progress persist and update today's completion", async () => {
|
||||
const f = connectAccount();
|
||||
const water = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201);
|
||||
await f.json(`/habits/${water.id}/days/2026-09-04/progress`, "PUT", { count: 7 });
|
||||
await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||
await f.json("/habits", "POST", { name: "Evening", method: "tasks", tasks: [{ name: "Stretch" }] }, 201);
|
||||
await f.json("/habits", "POST", { name: "Sunday walk", method: "manual", schedule: { type: "weekdays", days: [0] } }, 201);
|
||||
await render("/");
|
||||
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("0 / 3");
|
||||
await act(async () => buttonNamed("Increase Water").click());
|
||||
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3");
|
||||
expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)?.textContent).toContain("8 of 8 glasses");
|
||||
const checkbox = (name: string) => [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => (input.getAttribute("aria-label") || input.closest("label")?.textContent) === name)!;
|
||||
expect(checkbox("Sunday walk done").disabled).toBe(true);
|
||||
await act(async () => checkbox("Reading done").click());
|
||||
await act(async () => checkbox("Stretch").click());
|
||||
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("3 / 3");
|
||||
expect((await f.json("/today")).completed).toBe(3);
|
||||
await act(async () => buttonNamed("Decrease Water").click());
|
||||
expect((await f.json("/today")).completed).toBe(2);
|
||||
});
|
||||
|
||||
test("inline habit edits persist configuration and delete archives without losing earlier history", async () => {
|
||||
const f = connectAccount();
|
||||
f.setTime("2026-09-03T12:00:00Z");
|
||||
const habit = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201);
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-03/progress`, "PUT", { count: 5 });
|
||||
f.setTime("2026-09-04T12:00:00Z");
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Edit habit Water").click());
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit habit Water"]')!;
|
||||
expect(document.activeElement).toBe(form.querySelector("input"));
|
||||
await act(async () => {
|
||||
const name = form.querySelector<HTMLInputElement>("input")!;
|
||||
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(name, "Daily water");
|
||||
name.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
|
||||
const target = form.querySelector<HTMLInputElement>('input[type="number"]')!;
|
||||
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(target, "10");
|
||||
target.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
|
||||
});
|
||||
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||
const saved = await f.json(`/habits/${habit.id}`);
|
||||
expect(saved.name).toBe("Daily water");
|
||||
expect(saved.target).toBe(10);
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
expect(container.querySelector("h3")?.textContent).toContain("Daily water");
|
||||
await act(async () => buttonNamed("Delete habit Daily water").click());
|
||||
expect((await f.json("/habits")).habits).toHaveLength(1);
|
||||
await act(async () => buttonNamed("Cancel").click());
|
||||
expect((await f.json("/habits")).habits).toHaveLength(1);
|
||||
await act(async () => buttonNamed("Delete habit Daily water").click());
|
||||
await act(async () => buttonNamed("Delete habit").click());
|
||||
expect((await f.json("/habits")).habits).toHaveLength(0);
|
||||
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
||||
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
|
||||
expect(previous.name).toBe("Water");
|
||||
expect(previous.target).toBe(8);
|
||||
expect(previous.value).toBe(5);
|
||||
});
|
||||
|
||||
test("inline task rename keeps its completion and schedule; deletion updates totals and preserves yesterday", async () => {
|
||||
const f = connectAccount();
|
||||
f.setTime("2026-09-03T12:00:00Z");
|
||||
const habit = await f.json("/habits", "POST", { name: "Evening", method: "tasks", tasks: [{ name: "Stretch", schedule: { type: "weekdays", days: [4, 5] } }, { name: "Clear desk" }] }, 201);
|
||||
const task = habit.tasks[0];
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-03/tasks/${task.id}`, "PUT", { done: true });
|
||||
f.setTime("2026-09-04T12:00:00Z");
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${task.id}`, "PUT", { done: true });
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Edit task Stretch").click());
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Stretch"]')!;
|
||||
await act(async () => {
|
||||
const input = form.querySelector("input")!;
|
||||
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "Stretch gently");
|
||||
input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
|
||||
});
|
||||
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||
const saved = await f.json(`/habits/${habit.id}/tasks/${task.id}`);
|
||||
expect(saved.name).toBe("Stretch gently");
|
||||
expect(saved.schedule).toEqual({ type: "weekdays", days: [4, 5] });
|
||||
expect((await f.json("/today")).habits[0].value).toBe(1);
|
||||
await act(async () => buttonNamed("Delete task Stretch gently").click());
|
||||
await act(async () => buttonNamed("Delete task").click());
|
||||
const today = (await f.json("/today")).habits[0];
|
||||
expect(today.target).toBe(1);
|
||||
expect(today.value).toBe(0);
|
||||
expect(container.textContent).not.toContain("Stretch gently");
|
||||
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
|
||||
expect(previous.tasks.find((item: { taskId: string }) => item.taskId === task.id).done).toBe(true);
|
||||
expect(previous.tasks.find((item: { taskId: string }) => item.taskId === task.id).name).toBe("Stretch");
|
||||
});
|
||||
|
||||
test("task edits retain failed drafts for retry and remain available on days off", async () => {
|
||||
const f = connectAccount();
|
||||
const habit = await f.json("/habits", "POST", { name: "Sunday", method: "tasks", schedule: { type: "weekdays", days: [0] }, tasks: [{ name: "Walk" }] }, 201);
|
||||
await render("/");
|
||||
expect(container.textContent).toContain("Not scheduled today");
|
||||
expect(container.querySelector<HTMLInputElement>('.ds-editable-task input[type="checkbox"]')?.disabled).toBe(true);
|
||||
await act(async () => buttonNamed("Edit task Walk").click());
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
|
||||
await act(async () => {
|
||||
const input = form.querySelector("input")!;
|
||||
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "Long walk");
|
||||
input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Please try again" }), { status: 500 }));
|
||||
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||
expect(form.querySelector("input")?.value).toBe("Long walk");
|
||||
expect(form.querySelector('[role="alert"]')?.textContent).toBe("Please try again");
|
||||
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks[0].name).toBe("Walk");
|
||||
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks[0].name).toBe("Long walk");
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
});
|
||||
|
||||
test("task recurrence edits round-trip every schedule type and preserve earlier check-ins", async () => {
|
||||
const f = connectAccount();
|
||||
f.setTime("2026-09-03T12:00:00Z");
|
||||
const habit = await f.json("/habits", "POST", { name: "Routine", method: "tasks", tasks: [{ name: "Walk" }] }, 201);
|
||||
const id = habit.tasks[0].id;
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-03/tasks/${id}`, "PUT", { done: true });
|
||||
f.setTime("2026-09-04T12:00:00Z");
|
||||
await render("/");
|
||||
const inputValue = async (input: HTMLInputElement, value: string) => act(async () => {
|
||||
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, value);
|
||||
input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
|
||||
});
|
||||
const selectValue = async (select: HTMLSelectElement, value: string) => act(async () => {
|
||||
select.value = value;
|
||||
select.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event);
|
||||
});
|
||||
for (const schedule of [
|
||||
{ type: "interval", every: 3, anchor: "2026-09-05" },
|
||||
{ type: "weekly", every: 2, anchor: "2026-09-04", weekday: 5 },
|
||||
{ type: "weekdays", days: [1, 2, 3, 4, 5] },
|
||||
{ type: "daily" },
|
||||
] as const) {
|
||||
await act(async () => buttonNamed("Edit task Walk").click());
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
|
||||
await selectValue(form.querySelector("select")!, schedule.type);
|
||||
if (schedule.type === "interval" || schedule.type === "weekly") {
|
||||
await inputValue(form.querySelector('input[type="number"]')!, String(schedule.every));
|
||||
await inputValue(form.querySelector('input[type="date"]')!, schedule.anchor);
|
||||
}
|
||||
if (schedule.type === "weekly") await selectValue(form.querySelectorAll("select")[1]!, String(schedule.weekday));
|
||||
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||
expect((await f.json(`/habits/${habit.id}/tasks/${id}`)).schedule).toEqual(schedule);
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
await act(async () => buttonNamed("Edit task Walk").click());
|
||||
const reopened = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
|
||||
expect(reopened.querySelector("select")?.value).toBe(schedule.type);
|
||||
if (schedule.type === "interval" || schedule.type === "weekly") {
|
||||
expect(reopened.querySelector<HTMLInputElement>('input[type="number"]')?.value).toBe(String(schedule.every));
|
||||
expect(reopened.querySelector<HTMLInputElement>('input[type="date"]')?.value).toBe(schedule.anchor);
|
||||
}
|
||||
await act(async () => buttonNamed("Cancel").click());
|
||||
}
|
||||
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
|
||||
expect(previous.tasks[0].done).toBe(true);
|
||||
expect(previous.requirements.tasks[0].schedule).toEqual({ type: "daily" });
|
||||
});
|
||||
|
||||
test("habit method and carryover controls save, and task conversion supports adding recurring tasks", async () => {
|
||||
const f = connectAccount();
|
||||
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||
await render("/");
|
||||
const selectMethod = async (method: string) => {
|
||||
const select = container.querySelector<HTMLSelectElement>('form select')!;
|
||||
await act(async () => {
|
||||
select.value = method;
|
||||
select.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event);
|
||||
});
|
||||
};
|
||||
const submit = async () => act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||
await act(async () => buttonNamed("Edit habit Reading").click());
|
||||
await selectMethod("count");
|
||||
await act(async () => container.querySelector<HTMLInputElement>('form input[type="checkbox"]')!.click());
|
||||
await submit();
|
||||
expect((await f.json(`/habits/${habit.id}`)).method).toBe("count");
|
||||
expect((await f.json(`/habits/${habit.id}`)).carryPartialProgress).toBe(true);
|
||||
await act(async () => buttonNamed("Edit habit Reading").click());
|
||||
expect(container.querySelector<HTMLInputElement>('form input[type="checkbox"]')?.checked).toBe(true);
|
||||
await selectMethod("tasks");
|
||||
await submit();
|
||||
expect((await f.json(`/habits/${habit.id}`)).method).toBe("tasks");
|
||||
await act(async () => buttonNamed("Add task +").click());
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Add task"]')!;
|
||||
await act(async () => {
|
||||
const input = form.querySelector("input")!;
|
||||
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "Read chapter");
|
||||
input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
|
||||
});
|
||||
await submit();
|
||||
const task = (await f.json(`/habits/${habit.id}/tasks`)).tasks[0];
|
||||
expect(task.name).toBe("Read chapter");
|
||||
expect(task.schedule).toEqual({ type: "daily" });
|
||||
expect(buttonNamed("Edit task Read chapter")).toBeDefined();
|
||||
expect((await f.json("/today")).habits[0].target).toBe(1);
|
||||
});
|
||||
|
||||
test("a successful delete is not retried when the subsequent refresh fails", async () => {
|
||||
const f = connectAccount();
|
||||
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Delete habit Reading").click());
|
||||
fetchMock.mockImplementationOnce((async (input, init) => {
|
||||
return f.request(String(input).replace("/api", ""), init?.method);
|
||||
}) as typeof fetch);
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }));
|
||||
await act(async () => buttonNamed("Delete habit").click());
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh");
|
||||
expect(buttonNamed("Delete habit Reading").disabled).toBe(true);
|
||||
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
|
||||
await act(async () => buttonNamed("Try again").click());
|
||||
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
||||
expect(fetchMock.mock.calls.filter(call => call[1]?.method === "DELETE")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("failed saves retain recorded progress and allow another attempt", async () => {
|
||||
const f = connectAccount();
|
||||
await f.json("/habits", "POST", { name: "Water", method: "count", target: 8 }, 201);
|
||||
await render("/");
|
||||
fetchMock.mockResolvedValueOnce(Response.json({ error: "Save unavailable" }, { status: 500 }));
|
||||
await act(async () => buttonNamed("Increase Water").click());
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save unavailable");
|
||||
expect(container.querySelector(".ds-counter output")?.textContent).toBe("0 / 8");
|
||||
expect(buttonNamed("Increase Water").disabled).toBe(false);
|
||||
await act(async () => buttonNamed("Increase Water").click());
|
||||
expect(container.querySelector(".ds-counter output")?.textContent).toBe("1 / 8");
|
||||
expect((await f.json("/today")).habits[0].value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test("unscheduled dates stay inspectable without a progress-square fill", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
@@ -163,8 +478,8 @@ test("unscheduled dates stay inspectable without a progress-square fill", async
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
describe("design-system-only routing", () => {
|
||||
for (const path of ["/design-system", "/design-system/", "/", "/about", "/settings", "/missing", "/?auth_error=denied"]) {
|
||||
describe("independent design-system routing", () => {
|
||||
for (const path of ["/design-system", "/design-system/"]) {
|
||||
test(`renders only the public design system at ${path}`, async () => {
|
||||
await render(path);
|
||||
expect(container.querySelector("#ds-title")).not.toBeNull();
|
||||
|
||||
12
src/App.tsx
12
src/App.tsx
@@ -1,11 +1,21 @@
|
||||
import { Navigate, Route, Routes } from "react-router";
|
||||
import { DesignSystem } from "./pages/DesignSystem";
|
||||
import { Home } from "./pages/Home";
|
||||
import { AuthProvider } from "./components/AuthProvider";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/design-system" element={<DesignSystem />} />
|
||||
<Route path="*" element={<Navigate to="/design-system" replace />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<AuthProvider>
|
||||
<Home />
|
||||
</AuthProvider>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import { sql } from "drizzle-orm";
|
||||
import { createAuth, type AppDatabase, type AuthEnv } from "./auth";
|
||||
import type { AuthConfig } from "./auth/config";
|
||||
import type { FetchDiscord } from "./auth/discord";
|
||||
import { createSharingRoutes, type DiscordFetch } from "./sharing/routes";
|
||||
import type { DiscordSharingConfig } from "./sharing/config";
|
||||
|
||||
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number) {
|
||||
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }) {
|
||||
const app = new Hono<AuthEnv>();
|
||||
const auth = createAuth(db, config, request, now);
|
||||
app.get("/api/health", c => {
|
||||
@@ -16,6 +18,7 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
|
||||
app.route("/api/auth", auth.routes);
|
||||
app.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
|
||||
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
|
||||
app.route("/api/sharing", createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest));
|
||||
app.notFound(c => c.json({ error: "Not found" }, 404));
|
||||
app.onError((_error, c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
|
||||
228
src/components/HabitForm.tsx
Normal file
228
src/components/HabitForm.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { Button, SectionHeading } from "./design-system/primitives";
|
||||
import { Field } from "./design-system/Field";
|
||||
import { ScheduleEditor } from "./design-system/EditingWorkbench";
|
||||
import { HabitColorPicker } from "./design-system/HabitColorPicker";
|
||||
import { habitInput, type Schedule } from "../habits/contracts";
|
||||
import { habitRequest } from "../lib/dashboard";
|
||||
|
||||
/** Account behavior composed from the design system's existing form components. */
|
||||
export function HabitForm({
|
||||
date,
|
||||
onCancel,
|
||||
onCreated,
|
||||
onExpired,
|
||||
}: {
|
||||
date: string;
|
||||
onCancel: () => void;
|
||||
onCreated: (name: string) => void;
|
||||
onExpired: () => void;
|
||||
}) {
|
||||
const form = useRef<HTMLFormElement>(null);
|
||||
const saving = useRef(false);
|
||||
const [name, setName] = useState("");
|
||||
const [method, setMethod] = useState<"manual" | "count" | "tasks">("manual");
|
||||
const [target, setTarget] = useState(8);
|
||||
const [unit, setUnit] = useState("glasses");
|
||||
const [tasks, setTasks] = useState([""]);
|
||||
const [schedule, setSchedule] = useState<Schedule>({ type: "daily" });
|
||||
const [color, setColor] = useState("#58765b");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
form.current?.querySelector<HTMLInputElement>("input")?.focus();
|
||||
return () => previous?.focus();
|
||||
}, []);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (saving.current) return;
|
||||
const parsed = habitInput.safeParse({
|
||||
name,
|
||||
method,
|
||||
schedule,
|
||||
color,
|
||||
...(method === "count" ? { target, unit } : {}),
|
||||
...(method === "tasks" ? { tasks: tasks.map((name) => ({ name })) } : {}),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues.map((issue) => issue.message).join(" "));
|
||||
return;
|
||||
}
|
||||
saving.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await habitRequest("/habits", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(parsed.data),
|
||||
});
|
||||
onCreated(parsed.data.name);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("session has expired")
|
||||
)
|
||||
onExpired();
|
||||
else
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Could not create your habit.",
|
||||
);
|
||||
} finally {
|
||||
saving.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="ds-section ds-split-section"
|
||||
aria-labelledby="new-habit-title"
|
||||
id="new-habit"
|
||||
>
|
||||
<SectionHeading
|
||||
number="A NEW HABIT"
|
||||
id="new-habit-title"
|
||||
title={
|
||||
<>
|
||||
Make it <em>yours.</em>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Choose what counts as complete. Your schedule follows your account’s
|
||||
timezone.
|
||||
</SectionHeading>
|
||||
<form ref={form} className="ds-edit-content" onSubmit={submit}>
|
||||
<fieldset className="ds-task-editor-fieldset" disabled={busy}>
|
||||
<Field label="Habit name">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
required
|
||||
maxLength={200}
|
||||
placeholder="e.g. Read ten pages"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="How will you track it?">
|
||||
{(id) => (
|
||||
<select
|
||||
id={id}
|
||||
value={method}
|
||||
onChange={(event) =>
|
||||
setMethod(event.target.value as typeof 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>
|
||||
)}
|
||||
</Field>
|
||||
{method === "count" && (
|
||||
<div className="ds-form-grid">
|
||||
<Field label="Daily target">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
max={10000}
|
||||
step={1}
|
||||
value={Number.isNaN(target) ? "" : target}
|
||||
onChange={(event) => setTarget(event.target.valueAsNumber)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Unit">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
required
|
||||
maxLength={80}
|
||||
value={unit}
|
||||
onChange={(event) => setUnit(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{method === "tasks" && (
|
||||
<div className="ds-form-section">
|
||||
{tasks.map((task, index) => (
|
||||
<Field key={index} label={`Task ${index + 1}`}>
|
||||
{(id) => (
|
||||
<div className="ds-input-row">
|
||||
<input
|
||||
id={id}
|
||||
required
|
||||
maxLength={200}
|
||||
value={task}
|
||||
onChange={(event) =>
|
||||
setTasks((current) =>
|
||||
current.map((value, i) =>
|
||||
i === index ? event.target.value : value,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label={`Remove task ${index + 1}`}
|
||||
disabled={tasks.length === 1}
|
||||
onClick={() =>
|
||||
setTasks((current) =>
|
||||
current.filter((_, i) => i !== index),
|
||||
)
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={tasks.length >= 100}
|
||||
onClick={() => setTasks((current) => [...current, ""])}
|
||||
>
|
||||
Add task +
|
||||
</Button>
|
||||
<p className="ds-form-feedback">
|
||||
Tasks repeat on each scheduled day.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ScheduleEditor
|
||||
value={schedule}
|
||||
onChange={setSchedule}
|
||||
anchorDate={date}
|
||||
/>
|
||||
<HabitColorPicker value={color} onChange={setColor} mode="create" />
|
||||
</fieldset>
|
||||
{error && (
|
||||
<p className="ds-form-feedback" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="ds-form-actions">
|
||||
<Button type="submit" disabled={busy}>
|
||||
{busy ? "Creating…" : "Create habit"}
|
||||
</Button>
|
||||
<Button variant="text" disabled={busy} onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import type { CalendarResponse } from "../shared/calendar";
|
||||
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
||||
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
|
||||
@@ -7,6 +7,7 @@ import { Button } from "./design-system/primitives";
|
||||
import { HabitChart } from "./design-system/HabitChart";
|
||||
import { CalendarLegend } from "./design-system/CalendarLegend";
|
||||
import { shade } from "../habits/calendar";
|
||||
import { ItemActions } from "./design-system/ItemActions";
|
||||
|
||||
export function HabitHistory({
|
||||
habit,
|
||||
@@ -15,17 +16,27 @@ export function HabitHistory({
|
||||
onEdit,
|
||||
onDelete,
|
||||
disabled = false,
|
||||
children,
|
||||
tasks,
|
||||
editor,
|
||||
onExpired,
|
||||
}: {
|
||||
habit: TodayHabit;
|
||||
date: string;
|
||||
revision: number;
|
||||
onEdit: (color: string) => void;
|
||||
onDelete: () => void;
|
||||
onEdit?: (color: string) => void;
|
||||
onDelete?: () => void;
|
||||
disabled?: boolean;
|
||||
children?: ReactNode;
|
||||
tasks?: ReactNode;
|
||||
editor?: ReactNode;
|
||||
onExpired?: () => void;
|
||||
}) {
|
||||
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const expired = useRef(onExpired);
|
||||
expired.current = onExpired;
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setError("");
|
||||
@@ -38,7 +49,10 @@ export function HabitHistory({
|
||||
if (!controller.signal.aborted) setCalendar(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) setError(error.message);
|
||||
if (controller.signal.aborted) return;
|
||||
if (error.message.includes("session has expired") && expired.current)
|
||||
expired.current();
|
||||
else setError(error.message);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [habit.habitId, date, revision, attempt]);
|
||||
@@ -78,7 +92,7 @@ export function HabitHistory({
|
||||
]
|
||||
: [];
|
||||
const chart = error ? (
|
||||
<div className="home-state">
|
||||
<div className="ds-form-feedback">
|
||||
<p role="alert">{error}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -88,7 +102,7 @@ export function HabitHistory({
|
||||
</Button>
|
||||
</div>
|
||||
) : !calendar ? (
|
||||
<p className="home-state" role="status">
|
||||
<p className="ds-form-feedback" role="status">
|
||||
Loading your habit history…
|
||||
</p>
|
||||
) : (
|
||||
@@ -128,29 +142,44 @@ export function HabitHistory({
|
||||
unit={habit.unit}
|
||||
color={calendar?.settings.mainColor ?? "#196127"}
|
||||
calendar={chart}
|
||||
tasks={tasks}
|
||||
tasksLabel="Tasks"
|
||||
headingLevel={3}
|
||||
editor={editor}
|
||||
headingActions={onEdit && onDelete ? (
|
||||
<ItemActions name={habit.name ?? "Habit"} kind="habit" disabled={disabled || !calendar}
|
||||
onEdit={() => onEdit(calendar!.settings.mainColor)} onDelete={onDelete} />
|
||||
) : undefined}
|
||||
>
|
||||
<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)}
|
||||
{children}
|
||||
{(onEdit || onDelete) && !(onEdit && onDelete) && (
|
||||
<div
|
||||
className="ds-actions"
|
||||
role="group"
|
||||
aria-label={`${habit.name} actions`}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label={`Delete ${habit.name}`}
|
||||
disabled={disabled}
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label={`Edit ${habit.name}`}
|
||||
disabled={disabled || !calendar}
|
||||
onClick={() => onEdit(calendar!.settings.mainColor)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label={`Delete ${habit.name}`}
|
||||
disabled={disabled}
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</HabitChart>
|
||||
);
|
||||
}
|
||||
|
||||
54
src/components/InlineHabitEditor.tsx
Normal file
54
src/components/InlineHabitEditor.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useState } from "react";
|
||||
import type { HabitConfig } from "../habits/contracts";
|
||||
import { habitPatch } from "../habits/contracts";
|
||||
import { Field } from "./design-system/Field";
|
||||
import { ScheduleEditor } from "./design-system/EditingWorkbench";
|
||||
import { HabitColorPicker } from "./design-system/HabitColorPicker";
|
||||
import { InlineItemForm, type ItemMode } from "./InlineItemForm";
|
||||
import { Checkbox } from "./design-system/primitives";
|
||||
|
||||
export function InlineHabitEditor({ config, color: initialColor, date, mode, disabled, onSave, onDelete, onClose }: {
|
||||
config: HabitConfig;
|
||||
color: string;
|
||||
date: string;
|
||||
mode: ItemMode;
|
||||
disabled: boolean;
|
||||
onSave: (patch: Record<string, unknown>) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [schedule, setSchedule] = useState(config.schedule);
|
||||
const [method, setMethod] = useState(config.method);
|
||||
const [carryPartialProgress, setCarryPartialProgress] = useState(config.method === "count" && config.carryPartialProgress);
|
||||
const [color, setColor] = useState(initialColor);
|
||||
const [target, setTarget] = useState(config.method === "count" ? config.target : 1);
|
||||
const [unit, setUnit] = useState(config.method === "count" ? config.unit : "times");
|
||||
return (
|
||||
<InlineItemForm name={config.name} kind="habit" mode={mode} disabled={disabled} onClose={onClose} onDelete={onDelete}
|
||||
onSave={async (name) => {
|
||||
const parsed = habitPatch.safeParse({ name, method, schedule, color, ...(method === "count" ? { target, unit, carryPartialProgress } : {}) });
|
||||
if (!parsed.success) throw new Error(parsed.error.issues.map((issue) => issue.message).join(" "));
|
||||
await onSave(parsed.data);
|
||||
}}>
|
||||
<Field label="How will you track it?">
|
||||
{(id) => <select id={id} value={method} onChange={(event) => setMethod(event.target.value as HabitConfig["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>}
|
||||
</Field>
|
||||
{method !== config.method && <p className="ds-footnote">Changing the tracking method starts today’s progress over. Earlier history is kept.{method === "tasks" ? " After saving, add tasks below." : config.method === "tasks" ? " Existing tasks will leave this habit." : ""}</p>}
|
||||
{method === "count" && (<>
|
||||
<div className="ds-form-grid">
|
||||
<Field label="Daily target">{(id) => <input id={id} type="number" min={1} max={10000} step={1} required value={Number.isNaN(target) ? "" : target} onChange={(event) => setTarget(event.target.valueAsNumber)} />}</Field>
|
||||
<Field label="Unit">{(id) => <input id={id} required maxLength={80} value={unit} onChange={(event) => setUnit(event.target.value)} />}</Field>
|
||||
</div>
|
||||
<Checkbox label="Carry unfinished counts to the next scheduled day" checked={carryPartialProgress} onChange={(event) => setCarryPartialProgress(event.target.checked)} />
|
||||
<p className="ds-footnote">Completed counts reset. Explicitly recorded counts are kept.</p>
|
||||
</>)}
|
||||
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
|
||||
<HabitColorPicker value={color} onChange={setColor} mode="edit" />
|
||||
<p className="ds-footnote">Changes start today. Earlier targets and schedules stay as they were.</p>
|
||||
</InlineItemForm>
|
||||
);
|
||||
}
|
||||
78
src/components/InlineItemForm.tsx
Normal file
78
src/components/InlineItemForm.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Button } from "./design-system/primitives";
|
||||
import { Field } from "./design-system/Field";
|
||||
|
||||
export type ItemMode = "edit" | "delete";
|
||||
|
||||
/** In-place editing and confirmation, shared by habit headings and task rows. */
|
||||
export function InlineItemForm({ name, kind, mode, disabled, children, onSave, onDelete, onClose, submitLabel, formLabel }: {
|
||||
name: string;
|
||||
kind: "habit" | "task";
|
||||
mode: ItemMode;
|
||||
disabled?: boolean;
|
||||
children?: ReactNode;
|
||||
submitLabel?: string;
|
||||
formLabel?: string;
|
||||
onSave: (name: string) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const form = useRef<HTMLFormElement>(null);
|
||||
const saving = useRef(false);
|
||||
const [draft, setDraft] = useState(name);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
form.current?.querySelector<HTMLElement>(mode === "edit" ? "input" : "button")?.focus();
|
||||
return () => { if (previous?.isConnected) previous.focus(); };
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<form ref={form} className={`ds-inline-item-form ds-inline-item-form--${kind}`} aria-label={formLabel ?? `${mode === "edit" ? "Edit" : "Delete"} ${kind} ${name}`}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && !saving.current) { event.preventDefault(); onClose(); }
|
||||
}}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (saving.current || disabled) return;
|
||||
if (mode === "edit" && !draft.trim()) { setError("Enter a name."); return; }
|
||||
saving.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (mode === "delete") await onDelete();
|
||||
else await onSave(draft.trim());
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : "Could not save your change. Try again.");
|
||||
} finally {
|
||||
saving.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}}>
|
||||
{mode === "edit" ? (
|
||||
<fieldset className="ds-inline-item-fields" disabled={busy || disabled}>
|
||||
<Field label={kind === "habit" ? "Habit name" : "Task name"}>
|
||||
{(id) => <input id={id} value={draft} required maxLength={200} onChange={(event) => setDraft(event.target.value)} />}
|
||||
</Field>
|
||||
{children}
|
||||
</fieldset>
|
||||
) : (
|
||||
<p className="ds-inline-delete-copy">
|
||||
Delete <strong>{name}</strong>? {kind === "habit"
|
||||
? "This removes the habit from your dashboard. Earlier history is kept."
|
||||
: "This removes the task from today and future check-ins. Earlier history is kept."}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="ds-form-feedback" role="alert">{error}</p>}
|
||||
<div className="ds-inline-form-actions">
|
||||
{mode === "delete" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
|
||||
<Button type="submit" disabled={busy || disabled}>
|
||||
{busy ? "Saving…" : mode === "delete" ? `Delete ${kind}` : submitLabel ?? "Save changes"}
|
||||
</Button>
|
||||
{mode === "edit" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
33
src/components/InlineTaskEditor.tsx
Normal file
33
src/components/InlineTaskEditor.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState } from "react";
|
||||
import { taskPatch, type Schedule } from "../habits/contracts";
|
||||
import { scheduleLabel } from "../lib/dashboard";
|
||||
import { ScheduleEditor } from "./design-system/EditingWorkbench";
|
||||
import { InlineItemForm, type ItemMode } from "./InlineItemForm";
|
||||
|
||||
export function InlineTaskEditor({ name, schedule: savedSchedule, habitSchedule, date, mode, disabled, creating = false, onSave, onDelete, onClose }: {
|
||||
name: string;
|
||||
schedule: Schedule;
|
||||
habitSchedule: Schedule;
|
||||
date: string;
|
||||
mode: ItemMode;
|
||||
disabled: boolean;
|
||||
creating?: boolean;
|
||||
onSave: (patch: { name?: string; schedule?: Schedule }) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [schedule, setSchedule] = useState(savedSchedule);
|
||||
return (
|
||||
<InlineItemForm name={name} kind="task" mode={mode} disabled={disabled} onDelete={onDelete} onClose={onClose}
|
||||
formLabel={creating ? "Add task" : undefined} submitLabel={creating ? "Add task" : undefined}
|
||||
onSave={async (name) => {
|
||||
const parsed = taskPatch.safeParse({ name, schedule });
|
||||
if (!parsed.success) throw new Error(parsed.error.issues.map((issue) => issue.message).join(" "));
|
||||
await onSave(parsed.data);
|
||||
}}>
|
||||
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
|
||||
<p className="ds-footnote">This task is due when both its recurrence and the habit’s schedule match. Habit: {scheduleLabel(habitSchedule)}.</p>
|
||||
{!creating && <p className="ds-footnote">Changes start today. Earlier check-ins keep their original schedule.</p>}
|
||||
</InlineItemForm>
|
||||
);
|
||||
}
|
||||
108
src/components/ShareProgress.tsx
Normal file
108
src/components/ShareProgress.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, Send, X } from "lucide-react";
|
||||
import { Button, Checkbox } from "./design-system/primitives";
|
||||
import { Field } from "./design-system/Field";
|
||||
import { habitRequest, type TodayResponse } from "../lib/dashboard";
|
||||
import { addDays } from "../habits/calendar";
|
||||
import { renderProgressCard, type CardPrivacy } from "../lib/progress-card";
|
||||
import type { PublicUser } from "../shared/user";
|
||||
import type { Delivery, DiscordConnection, ShareData } from "../sharing/contracts";
|
||||
|
||||
export function ShareProgress({ user, today, revision, onClose }: { user: PublicUser; today: TodayResponse; revision: number; onClose: () => void }) {
|
||||
const [ids, setIds] = useState(() => today.habits.slice(0, 1).map(h => h.habitId));
|
||||
const [range, setRange] = useState("30");
|
||||
const [from, setFrom] = useState(addDays(today.date, -29));
|
||||
const [to, setTo] = useState(today.date);
|
||||
const [privacy, setPrivacy] = useState<CardPrivacy>({ name: true, avatar: true, habitNames: true, timezone: false });
|
||||
const [card, setCard] = useState<{ key: string; url: string; blob: Blob; alt: string; deliveryId: string; avatarMissing: boolean } | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [renderAttempt, setRenderAttempt] = useState(0);
|
||||
const [connection, setConnection] = useState<DiscordConnection | null>(null);
|
||||
const [connectionError, setConnectionError] = useState("");
|
||||
const [connectionAttempt, setConnectionAttempt] = useState(0);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [delivery, setDelivery] = useState<(Delivery & { id: string }) | null>(null);
|
||||
const [sendError, setSendError] = useState("");
|
||||
const lock = useRef(false);
|
||||
const lastImage = useRef<{ hash: string; deliveryId: string } | null>(null);
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
const key = JSON.stringify({ ids, from, to, privacy, revision, user });
|
||||
const ready = card?.key === key ? card : null;
|
||||
const sent = ready && delivery?.id === ready.deliveryId ? delivery : null;
|
||||
const busy = sending;
|
||||
useEffect(() => { heading.current?.focus(); }, []);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setConnectionError("");
|
||||
setConnection(null);
|
||||
habitRequest<DiscordConnection>("/sharing/discord", { signal: controller.signal }).then(value => {
|
||||
if (!controller.signal.aborted) setConnection(value);
|
||||
}).catch(e => { if (!controller.signal.aborted) setConnectionError(e.message); });
|
||||
return () => controller.abort();
|
||||
}, [connectionAttempt]);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let objectUrl: string | undefined;
|
||||
setError(""); setSendError("");
|
||||
const timer = setTimeout(async () => {
|
||||
if (!ids.length) { setError("Choose at least one habit for your card."); return; }
|
||||
try {
|
||||
const data = await habitRequest<ShareData>("/sharing/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ habitIds: ids, from, to }), signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
const image = await renderProgressCard(data, user, privacy);
|
||||
const hash = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await image.blob.arrayBuffer())), byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
if (controller.signal.aborted) return;
|
||||
objectUrl = URL.createObjectURL(image.blob);
|
||||
const deliveryId = lastImage.current?.hash === hash ? lastImage.current.deliveryId : crypto.randomUUID();
|
||||
lastImage.current = { hash, deliveryId };
|
||||
setCard({ ...image, key, url: objectUrl, deliveryId });
|
||||
} catch (e) { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : "Could not create your card."); }
|
||||
}, 200);
|
||||
return () => { clearTimeout(timer); controller.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
||||
}, [key, renderAttempt]);
|
||||
|
||||
async function send() {
|
||||
if (!ready || !connection?.connected || lock.current || sent) return;
|
||||
lock.current = true; setSending(true); setSendError("");
|
||||
const form = new FormData(); form.set("deliveryId", ready.deliveryId); form.set("image", ready.blob, "minabot-progress.png");
|
||||
try { const result = await habitRequest<Delivery>("/sharing/discord/send", { method: "POST", body: form }); setDelivery({ ...result, id: ready.deliveryId }); }
|
||||
catch (e) { setSendError(e instanceof Error ? e.message : "Could not send. Retrying this card will not post it twice."); }
|
||||
finally { lock.current = false; setSending(false); }
|
||||
}
|
||||
return (
|
||||
<section id="share-progress" className="ds-share-panel" aria-labelledby="share-progress-title">
|
||||
<header className="ds-share-heading">
|
||||
<div><p className="ds-eyebrow">A LITTLE PROGRESS, WORTH SHARING</p><h2 id="share-progress-title" ref={heading} tabIndex={-1} className="type-section">Your progress, in a picture.</h2></div>
|
||||
<Button variant="text" disabled={busy} onClick={onClose} aria-label="Close sharing"><X size={20} aria-hidden="true" /></Button>
|
||||
</header>
|
||||
<div className="ds-share-layout">
|
||||
<div className="ds-share-controls">
|
||||
<fieldset className="ds-share-fieldset" disabled={busy}>
|
||||
<legend className="type-ui-heading">Choose your habits</legend>
|
||||
<p className="ds-muted type-small">Up to six per card. Task names are never included.</p>
|
||||
<div className="ds-share-habits">{today.habits.map(habit => <Checkbox key={habit.habitId} label={habit.name ?? "Habit"} checked={ids.includes(habit.habitId)} disabled={!ids.includes(habit.habitId) && ids.length >= 6} onChange={e => setIds(values => e.target.checked ? [...values, habit.habitId] : values.filter(id => id !== habit.habitId))} />)}</div>
|
||||
<Field label="Date range">{id => <select id={id} value={range} onChange={e => { const value = e.target.value; setRange(value); if (value !== "custom") { setFrom(addDays(today.date, 1 - Number(value))); setTo(today.date); } }}><option value="7">Past 7 days</option><option value="30">Past 30 days</option><option value="365">Past year</option><option value="custom">Custom dates</option></select>}</Field>
|
||||
{range === "custom" && <div className="ds-share-dates"><Field label="From">{id => <input id={id} type="date" value={from} max={to || today.date} min="1970-01-01" onChange={e => setFrom(e.target.value)} />}</Field><Field label="To">{id => <input id={id} type="date" value={to} min={from} max={today.date} onChange={e => setTo(e.target.value)} />}</Field></div>}
|
||||
</fieldset>
|
||||
<fieldset className="ds-share-fieldset" disabled={busy}><legend className="type-ui-heading">Show on the card</legend>
|
||||
{([ ["name", "Display name"], ["avatar", "Discord avatar"], ["habitNames", "Habit names"], ["timezone", "Timezone"] ] as const).map(([key, label]) => <Checkbox key={key} label={label} checked={privacy[key]} onChange={e => setPrivacy(value => ({ ...value, [key]: e.target.checked }))} />)}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="ds-share-preview-column">
|
||||
<div className="ds-share-preview" aria-busy={!ready && !error}>
|
||||
{error ? <div role="alert"><p>{error}</p><Button variant="secondary" onClick={() => setRenderAttempt(n => n + 1)}>Retry preview</Button></div> : ready ? <img src={ready.url} alt={ready.alt} /> : <p role="status">Creating your card…</p>}
|
||||
</div>
|
||||
<p className="type-small ds-muted">This exact image will be downloaded or sent. Days off are excluded from the completion rate.</p>
|
||||
{ready?.avatarMissing && <p role="status" className="type-small ds-muted">Your avatar couldn’t load. The card uses a neutral profile icon.</p>}
|
||||
<div className="ds-share-actions">
|
||||
{ready ? <a className="ds-button ds-button--secondary" href={ready.url} download={`minabot-progress-${from}-${to}.png`}><Download size={17} aria-hidden="true" />Download PNG</a> : <Button variant="secondary" disabled>Download PNG</Button>}
|
||||
<Button disabled={!ready || !connection?.connected || busy || !!sent} onClick={() => void send()}><Send size={17} aria-hidden="true" />{sending ? "Sending…" : sent?.status === "sent" ? "Sent to Discord" : sent ? "Check Discord" : "Send to Discord"}</Button>
|
||||
</div>
|
||||
{connectionError ? <div role="alert" className="ds-share-feedback"><p>{connectionError}</p><Button variant="text" disabled={busy} onClick={() => setConnectionAttempt(n => n + 1)}>Retry Discord</Button></div> : connection?.connected ? <p className="type-small ds-muted">The bot will post this card to <a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a>.</p> : <p className="type-small ds-muted" role="status">{connection?.message ?? "Loading the Discord sharing channel…"}</p>}
|
||||
{sendError && <p role="alert">{sendError}</p>}
|
||||
{sent && <p role="status">{sent.status === "sent" ? <>Your card was sent. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord ↗</a>}</> : <>Discord didn’t confirm delivery. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"} before creating another card; this attempt won’t be resent.</>}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type Reac
|
||||
import { CalendarLegend } from "./CalendarLegend";
|
||||
import { Field } from "./Field";
|
||||
import { Button } from "./primitives";
|
||||
import { CalendarDays, Keyboard } from "lucide-react";
|
||||
import {
|
||||
describeDay,
|
||||
calendarTimeline,
|
||||
@@ -87,7 +88,7 @@ export function CalendarHeatmap({
|
||||
<div
|
||||
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
|
||||
data-months={months}
|
||||
style={{ "--calendar-weeks": timeline.weeks } as CSSProperties}
|
||||
style={{ "--calendar-weeks": timeline.weeks, "--calendar-color": color } as CSSProperties}
|
||||
>
|
||||
<div className="ds-calendar-range-toolbar">
|
||||
<span className="ds-calendar-range-label" aria-live="polite">
|
||||
@@ -273,20 +274,29 @@ export function CalendarHeatmap({
|
||||
(compact ? "Demo history" : "Illustrative history")}
|
||||
</span>
|
||||
</span>
|
||||
<span id={instructionsId} className="ds-muted">
|
||||
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
|
||||
</span>
|
||||
<details className="ds-calendar-help">
|
||||
<summary><Keyboard size={16} aria-hidden="true" /> Keyboard shortcuts</summary>
|
||||
<p id={instructionsId}>
|
||||
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
|
||||
</p>
|
||||
</details>
|
||||
</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 className="ds-date-inspector-heading">
|
||||
<CalendarDays size={22} aria-hidden="true" />
|
||||
<div>
|
||||
<span className="ds-eyebrow">SELECTED DAY</span>
|
||||
<time dateTime={selected.date}>
|
||||
{new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
</time>
|
||||
<span className="ds-date-inspector-status">{describeDay(selected, unit)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useId, type ReactNode } from "react";
|
||||
import { useId, type CSSProperties, type ReactNode } from "react";
|
||||
import { ChevronDown, ListChecks } from "lucide-react";
|
||||
import { CalendarHeatmap } from "./CalendarHeatmap";
|
||||
import { demoCalendar } from "./calendar-model";
|
||||
|
||||
@@ -11,11 +12,15 @@ export function HabitChart({
|
||||
unit,
|
||||
color,
|
||||
children,
|
||||
headingActions,
|
||||
editor,
|
||||
tasks,
|
||||
tasksLabel = "Tasks for today",
|
||||
calendar,
|
||||
schedule = "Every day",
|
||||
due = true,
|
||||
id,
|
||||
headingLevel = 4,
|
||||
}: {
|
||||
name: string;
|
||||
method: string;
|
||||
@@ -24,27 +29,36 @@ export function HabitChart({
|
||||
unit: string;
|
||||
color: string;
|
||||
children?: ReactNode;
|
||||
headingActions?: ReactNode;
|
||||
editor?: ReactNode;
|
||||
tasks?: ReactNode;
|
||||
tasksLabel?: string;
|
||||
calendar?: ReactNode;
|
||||
schedule?: string;
|
||||
due?: boolean;
|
||||
id?: string;
|
||||
headingLevel?: 3 | 4;
|
||||
}) {
|
||||
const headingId = useId();
|
||||
const Heading = `h${headingLevel}` as const;
|
||||
return (
|
||||
<section className="ds-habit-chart" id={id} aria-labelledby={headingId}>
|
||||
<section className="ds-habit-chart" id={id} aria-labelledby={headingId} style={{ "--habit-color": color } as CSSProperties}>
|
||||
<header className="ds-habit-chart-heading">
|
||||
<div>
|
||||
<h4 id={headingId}>
|
||||
<span style={{ backgroundColor: color }} aria-hidden="true" />
|
||||
{name}
|
||||
</h4>
|
||||
<div className="ds-habit-title-row">
|
||||
<Heading id={headingId}>
|
||||
<span style={{ backgroundColor: color }} aria-hidden="true" />
|
||||
{name}
|
||||
</Heading>
|
||||
{headingActions}
|
||||
</div>
|
||||
<p>
|
||||
{method} · {schedule}
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
</header>
|
||||
{editor}
|
||||
<p className="ds-habit-chart-progress" aria-live="polite">
|
||||
<span>
|
||||
{due
|
||||
@@ -71,10 +85,13 @@ export function HabitChart({
|
||||
{tasks && (
|
||||
<details className="ds-task-accordion">
|
||||
<summary>
|
||||
Tasks for today{" "}
|
||||
<span>
|
||||
{value} / {target}
|
||||
<ListChecks size={20} aria-hidden="true" />
|
||||
<span className="ds-task-accordion-title">{tasksLabel}</span>
|
||||
<span className="ds-task-accordion-progress">
|
||||
<progress aria-label={`${name} tasks completed`} value={Math.max(0, Math.min(value, target))} max={Math.max(1, target)} />
|
||||
<span>{value} / {target}</span>
|
||||
</span>
|
||||
<ChevronDown className="ds-task-accordion-chevron" size={18} aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="ds-task-accordion-content">{tasks}</div>
|
||||
</details>
|
||||
|
||||
21
src/components/design-system/ItemActions.tsx
Normal file
21
src/components/design-system/ItemActions.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "./primitives";
|
||||
|
||||
export function ItemActions({ name, kind, disabled, onEdit, onDelete }: {
|
||||
name: string;
|
||||
kind: "habit" | "task";
|
||||
disabled?: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span className="ds-item-actions" role="group" aria-label={`${name} actions`}>
|
||||
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onEdit} aria-label={`Edit ${kind} ${name}`} title={`Edit ${kind}`}>
|
||||
<Pencil size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onDelete} aria-label={`Delete ${kind} ${name}`} title={`Delete ${kind}`}>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
50
src/components/design-system/PageLayout.tsx
Normal file
50
src/components/design-system/PageLayout.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Button } from "./primitives";
|
||||
|
||||
/** The existing design-system page shell, shared without page-specific styling. */
|
||||
export function PageLayout({
|
||||
children,
|
||||
header,
|
||||
mainId = "main",
|
||||
wordmarkTo = "/",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
header?: ReactNode;
|
||||
mainId?: string;
|
||||
wordmarkTo?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="ds-root" id="top">
|
||||
<a
|
||||
className="ds-skip-link"
|
||||
href={`#${mainId}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
document.getElementById(mainId)?.focus();
|
||||
}}
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<header className="ds-header">
|
||||
<Link className="ds-wordmark" to={wordmarkTo}>
|
||||
minabot<span aria-hidden="true">.</span>
|
||||
</Link>
|
||||
{header}
|
||||
</header>
|
||||
<main id={mainId} className="ds-main" tabIndex={-1}>
|
||||
{children}
|
||||
<footer className="ds-footer">
|
||||
<span className="ds-wordmark">minabot.</span>
|
||||
<span>A little, every day.</span>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "instant" })}
|
||||
>
|
||||
Back to top ↑
|
||||
</Button>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/design-system/WelcomePanel.tsx
Normal file
73
src/components/design-system/WelcomePanel.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Globe2 } from "lucide-react";
|
||||
import { formatTrackingDate } from "../../lib/dashboard";
|
||||
|
||||
export function WelcomePanel({
|
||||
name,
|
||||
avatarUrl,
|
||||
date,
|
||||
timezone,
|
||||
children,
|
||||
}: {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
date?: string;
|
||||
timezone: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [failedAvatar, setFailedAvatar] = useState<string | null>(null);
|
||||
// The API date already belongs to the account's timezone. Keep its date parts
|
||||
// intact instead of interpreting them in the browser's timezone.
|
||||
const day = date ? new Date(`${date}T12:00:00Z`) : null;
|
||||
|
||||
return (
|
||||
<section className="ds-welcome-panel" aria-labelledby="dashboard-title">
|
||||
<div className="ds-welcome-content">
|
||||
<div className="ds-welcome-identity">
|
||||
<div className="ds-avatar">
|
||||
{avatarUrl && failedAvatar !== avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${name}’s Discord avatar`}
|
||||
width={64}
|
||||
height={64}
|
||||
onError={() => setFailedAvatar(avatarUrl)}
|
||||
/>
|
||||
) : (
|
||||
<span aria-label={`${name}’s avatar`} role="img">
|
||||
{Array.from(name.trim())[0]?.toUpperCase() || "?"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="ds-eyebrow">YOUR DAILY CHECK-IN</p>
|
||||
</div>
|
||||
<h1 id="dashboard-title" className="type-display">
|
||||
Welcome back, <em>{name}.</em>
|
||||
</h1>
|
||||
<p className="ds-welcome-subtitle">Make time for today.</p>
|
||||
{children}
|
||||
</div>
|
||||
<div className="ds-welcome-calendar">
|
||||
{day && date ? (
|
||||
<time className="ds-date-sheet" dateTime={date} aria-label={formatTrackingDate(date)}>
|
||||
<span className="ds-date-month">
|
||||
{day.toLocaleDateString("en", { month: "long", timeZone: "UTC" })}
|
||||
<span className="ds-muted">{day.getUTCFullYear()}</span>
|
||||
</span>
|
||||
<span className="ds-date-number type-display">{day.getUTCDate()}</span>
|
||||
<span className="ds-date-weekday">
|
||||
{day.toLocaleDateString("en", { weekday: "long", timeZone: "UTC" })}
|
||||
</span>
|
||||
<span className="ds-date-caption ds-eyebrow">TODAY, AT YOUR OWN PACE</span>
|
||||
</time>
|
||||
) : (
|
||||
<p className="ds-muted" role="status">Your day is loading…</p>
|
||||
)}
|
||||
<p className="ds-welcome-timezone">
|
||||
<Globe2 size={16} aria-hidden="true" />
|
||||
<span>{timezone}</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
AnchorHTMLAttributes,
|
||||
@@ -46,7 +47,7 @@ export function SectionHeading({
|
||||
return (
|
||||
<header className="ds-section-heading">
|
||||
<span className="ds-eyebrow">{number}</span>
|
||||
<h2 id={id}>{title}</h2>
|
||||
<h2 id={id} tabIndex={-1}>{title}</h2>
|
||||
{children && <p>{children}</p>}
|
||||
</header>
|
||||
);
|
||||
@@ -54,13 +55,23 @@ export function SectionHeading({
|
||||
|
||||
export function Checkbox({
|
||||
label,
|
||||
description,
|
||||
className = "",
|
||||
...props
|
||||
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string }) {
|
||||
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string; description?: string }) {
|
||||
const descriptionId = useId();
|
||||
return (
|
||||
<label className={`ds-checkbox ${className}`}>
|
||||
<input type="checkbox" {...props} />
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={description ? label : undefined}
|
||||
{...props}
|
||||
aria-describedby={[props["aria-describedby"], description ? descriptionId : undefined].filter(Boolean).join(" ") || undefined}
|
||||
/>
|
||||
<span className="ds-checkbox-copy">
|
||||
<span className="ds-checkbox-label">{label}</span>
|
||||
{description && <span id={descriptionId} className="ds-checkbox-description">{description}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,15 @@ export const sessions = sqliteTable("sessions", {
|
||||
index("sessions_expires_at_idx").on(table.expiresAt),
|
||||
]);
|
||||
|
||||
export const discordDeliveries = sqliteTable("discord_deliveries", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
imageHash: text("image_hash").notNull(),
|
||||
status: text("status").notNull(),
|
||||
messageUrl: text("message_url"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
});
|
||||
|
||||
// Revisions are append-only, including multiple edits on the same local date.
|
||||
export const habits = sqliteTable('habits', {
|
||||
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { addDays, endOfDay, localDate, scheduled, schedulesOverlap, shade } from
|
||||
import { calendarSettingsSchema, habitInput, type HabitConfig, type CalendarSettings } from './contracts';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: 400 | 404 | 409 | 422, message: string) { super(message); }
|
||||
constructor(public status: 400 | 404 | 409 | 422 | 429 | 502, message: string) { super(message); }
|
||||
}
|
||||
const missing = () => new ApiError(404, 'Not found');
|
||||
export type Habit = typeof habits.$inferSelect;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { db } from "./db";
|
||||
import { createApi } from "./api";
|
||||
import { readAuthConfig } from "./auth/config";
|
||||
import { readDiscordSharingConfig } from "./sharing/config";
|
||||
import index from "./index.html";
|
||||
|
||||
const app = createApi(db, readAuthConfig());
|
||||
const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig());
|
||||
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
|
||||
138
src/lib/progress-card.ts
Normal file
138
src/lib/progress-card.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { ShareData } from "../sharing/contracts";
|
||||
import type { PublicUser } from "../shared/user";
|
||||
import { dayNumber } from "../habits/calendar";
|
||||
|
||||
export type CardPrivacy = { name: boolean; avatar: boolean; habitNames: boolean; timezone: boolean };
|
||||
export const cardDate = (date: string) => new Date(`${date}T12:00:00Z`).toLocaleDateString("en", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
|
||||
export const cardStats = (data: ShareData) => {
|
||||
const completed = data.habits.reduce((n, h) => n + h.completed, 0);
|
||||
const scheduled = data.habits.reduce((n, h) => n + h.scheduled, 0);
|
||||
return { completed, scheduled, percent: scheduled ? Math.round(completed / scheduled * 100) : null };
|
||||
};
|
||||
|
||||
async function loadAvatar(url: string): Promise<HTMLImageElement | null> {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
const finish = (value: HTMLImageElement | null) => { clearTimeout(timer); img.onload = null; img.onerror = null; resolve(value); };
|
||||
const timer = setTimeout(() => finish(null), 5000);
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => finish(img);
|
||||
img.onerror = () => finish(null);
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/** Render once: this exact PNG is previewed, downloaded, and sent to Discord. */
|
||||
export async function renderProgressCard(data: ShareData, user: PublicUser, privacy: CardPrivacy): Promise<{ blob: Blob; alt: string; avatarMissing: boolean }> {
|
||||
await document.fonts.load('76px "Instrument Serif"');
|
||||
const [avatar, botAvatar] = await Promise.all([
|
||||
privacy.avatar && user.avatarUrl ? loadAvatar(user.avatarUrl) : null,
|
||||
data.botAvatarUrl ? loadAvatar(data.botAvatarUrl) : null,
|
||||
]);
|
||||
const canvas = document.createElement("canvas");
|
||||
const longRange = data.habits[0]!.days.length > 42;
|
||||
const titleGraphGap = 12;
|
||||
const rowHeight = (longRange ? 280 : 250) + titleGraphGap;
|
||||
canvas.width = 1200; canvas.height = 380 + data.habits.length * rowHeight + 76;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Your browser could not create the image. Try another browser.");
|
||||
const left = 56;
|
||||
const right = 1144;
|
||||
const chartLeft = 80;
|
||||
const chartWidth = right - chartLeft;
|
||||
const legendColumns = [chartLeft, chartLeft + 266, chartLeft + 532, chartLeft + 798] as const;
|
||||
const sans = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
const text = (value: string, x: number, y: number, size = 22, color = "#666", serif = false, maxWidth = 1088, align: CanvasTextAlign = "left") => {
|
||||
ctx.font = `${size}px ${serif ? '"Instrument Serif", Georgia, serif' : sans}`;
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = align;
|
||||
let fitted = value;
|
||||
if (ctx.measureText(fitted).width > maxWidth) {
|
||||
const chars = Array.from(value);
|
||||
while (chars.length && ctx.measureText(`${chars.join("")}…`).width > maxWidth) chars.pop();
|
||||
fitted = `${chars.join("")}…`;
|
||||
}
|
||||
ctx.fillText(fitted, x, y);
|
||||
};
|
||||
const rect = (x: number, y: number, w: number, h: number, color: string) => { ctx.fillStyle = color; ctx.fillRect(x, y, w, h); };
|
||||
rect(0, 0, 1200, canvas.height, "#fff");
|
||||
rect(0, 0, 1200, 8, data.habits[0]?.color ?? "#111");
|
||||
let profileX = 56;
|
||||
if (privacy.avatar) {
|
||||
ctx.save(); ctx.beginPath(); ctx.arc(88, 84, 32, 0, Math.PI * 2); ctx.clip();
|
||||
rect(56, 52, 64, 64, "#f1f1f1");
|
||||
if (avatar) ctx.drawImage(avatar, 56, 52, 64, 64);
|
||||
else { ctx.fillStyle = "#888"; ctx.beginPath(); ctx.arc(88, 77, 10, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(88, 109, 22, 0, Math.PI * 2); ctx.fill(); }
|
||||
ctx.restore(); profileX = 138;
|
||||
}
|
||||
text(privacy.name ? user.displayName || user.username : "A little, every day.", profileX, 94, 26, "#111", false, 700);
|
||||
if (botAvatar) {
|
||||
ctx.font = '34px "Instrument Serif", Georgia, serif';
|
||||
const avatarX = right - ctx.measureText("minabot.").width - 12 - 40;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(avatarX, 64, 40, 40, 4);
|
||||
ctx.clip();
|
||||
ctx.drawImage(botAvatar, avatarX, 64, 40, 40);
|
||||
ctx.restore();
|
||||
}
|
||||
text("minabot.", right, 94, 34, "#111", true, 112, "right");
|
||||
text("Small steps, adding up.", left, 213, 76, "#111", true);
|
||||
text(`${cardDate(data.from)} — ${cardDate(data.to)}`, left, 260, 23);
|
||||
const stats = cardStats(data);
|
||||
text(`${stats.completed} / ${stats.scheduled} scheduled check-ins complete`, left, 317, 24, "#111", false, 800);
|
||||
text(stats.percent === null ? "No days scheduled" : `${stats.percent}% complete`, right, 317, 24, "#111", false, 240, "right");
|
||||
data.habits.forEach((habit, index) => {
|
||||
const top = 358 + index * rowHeight;
|
||||
rect(left, top, right - left, 1, "#dedede");
|
||||
rect(left, top + 27, 6, 24, habit.color);
|
||||
text(privacy.habitNames ? habit.name : `Habit ${index + 1}`, chartLeft, top + 48, 24, "#111", false, 760);
|
||||
text(`${habit.completed} / ${habit.scheduled} days`, right, top + 48, 21, "#666", false, 185, "right");
|
||||
ctx.save();
|
||||
ctx.translate(0, titleGraphGap);
|
||||
if (habit.days.length <= 42) {
|
||||
const step = (chartWidth + 5) / habit.days.length;
|
||||
habit.days.forEach((day, i) => {
|
||||
const x = chartLeft + i * step;
|
||||
if (day.due) rect(x, top + 78, Math.max(8, step - 5), 62, day.color);
|
||||
else { ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(x + (step - 5) / 2, top + 109, 3, 0, Math.PI * 2); ctx.fill(); }
|
||||
if (habit.days.length <= 7 || i % 7 === 0) text(day.date.slice(8), x + (step - 5) / 2, top + 169, 17, "#666", false, step - 5, "center");
|
||||
});
|
||||
} else {
|
||||
const offset = (new Date(`${data.from}T12:00:00Z`).getUTCDay() + 6) % 7;
|
||||
const columns = Math.ceil((offset + habit.days.length) / 7);
|
||||
const step = (chartWidth + 4) / columns;
|
||||
const startX = chartLeft;
|
||||
text("M", left, top + 95, 14); text("W", left, top + 135, 14); text("F", left, top + 175, 14);
|
||||
let previousMonthX = -100;
|
||||
habit.days.forEach((day, i) => {
|
||||
const n = offset + i, x = startX + Math.floor(n / 7) * step, y = top + 82 + n % 7 * 20;
|
||||
if ((i === 0 || day.date.endsWith("-01")) && x - previousMonthX > 45 && x < 1108) {
|
||||
text(new Date(`${day.date}T12:00:00Z`).toLocaleDateString("en", { month: "short", timeZone: "UTC" }), x, top + 70, 14);
|
||||
previousMonthX = x;
|
||||
}
|
||||
if (day.due) rect(x, y, step - 4, 16, day.color);
|
||||
else { ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(x + (step - 4) / 2, y + 8, 2, 0, Math.PI * 2); ctx.fill(); }
|
||||
});
|
||||
}
|
||||
const legendY = top + (longRange ? 241 : 209);
|
||||
rect(legendColumns[0], legendY, 16, 16, habit.legend.empty);
|
||||
text("No progress", legendColumns[0] + 26, legendY + 15, 18);
|
||||
if (habit.legend.partial.length) {
|
||||
habit.legend.partial.forEach((color, i) => rect(legendColumns[1] + i * 19, legendY, 16, 16, color));
|
||||
text("Partial", legendColumns[1] + habit.legend.partial.length * 19 + 10, legendY + 15, 18);
|
||||
}
|
||||
rect(legendColumns[2], legendY, 16, 16, habit.legend.complete);
|
||||
text("Complete", legendColumns[2] + 26, legendY + 15, 18);
|
||||
ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(legendColumns[3] + 8, legendY + 8, 3, 0, Math.PI * 2); ctx.fill();
|
||||
text("Not scheduled", legendColumns[3] + 26, legendY + 15, 18);
|
||||
ctx.restore();
|
||||
});
|
||||
const footerY = canvas.height - 46;
|
||||
text("Days off don’t count against you.", left, footerY, 19);
|
||||
text(privacy.timezone ? data.timezone : `${dayNumber(data.to) - dayNumber(data.from) + 1} days of small steps`, right, footerY, 19, "#666", false, 350, "right");
|
||||
const blob = await new Promise<Blob>((resolve, reject) => canvas.toBlob(value => value ? resolve(value) : reject(new Error("Could not export the card.")), "image/png"));
|
||||
const names = privacy.habitNames ? data.habits.map(h => h.name).join(", ") : `${data.habits.length} habits`;
|
||||
return { blob, avatarMissing: privacy.avatar && !!user.avatarUrl && !avatar,
|
||||
alt: `${privacy.name ? `${user.displayName || user.username}: ` : ""}${names}. ${cardDate(data.from)} to ${cardDate(data.to)}. ${stats.completed} of ${stats.scheduled} scheduled check-ins complete. Legend: empty squares mean no progress, intermediate shades mean partial progress, full color means complete, and dots mean not scheduled.${privacy.timezone ? ` ${data.timezone}.` : ""}` };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
HABIT_COLORS,
|
||||
} from "../components/design-system/calendar-model";
|
||||
|
||||
import { PageLayout } from "../components/design-system/PageLayout";
|
||||
|
||||
import { DesignSystemTabs } from "../components/design-system/DesignSystemTabs";
|
||||
|
||||
export function DesignSystem() {
|
||||
@@ -23,18 +25,7 @@ export function DesignSystem() {
|
||||
const [habitName, setHabitName] = useState("");
|
||||
const [savedName, setSavedName] = useState("");
|
||||
return (
|
||||
<div className="ds-root" id="top">
|
||||
<a className="ds-skip-link" href="#ds-main" onClick={(event) => {
|
||||
event.preventDefault();
|
||||
document.getElementById("ds-main")?.focus();
|
||||
}}>Skip to content</a>
|
||||
<header className="ds-header">
|
||||
<Link className="ds-wordmark" to="/design-system">
|
||||
minabot<span aria-hidden="true">.</span>
|
||||
</Link>
|
||||
<span className="ds-library-label">Interface library / 01</span>
|
||||
</header>
|
||||
<main id="ds-main" className="ds-main" tabIndex={-1}>
|
||||
<PageLayout mainId="ds-main" wordmarkTo="/design-system" header={<span className="ds-library-label">Interface library / 01</span>}>
|
||||
<header className="ds-library-intro">
|
||||
<div>
|
||||
<p className="ds-eyebrow">MINABOT — INTERFACE LANGUAGE</p>
|
||||
@@ -300,14 +291,6 @@ export function DesignSystem() {
|
||||
</>
|
||||
) },
|
||||
]} />
|
||||
<footer className="ds-footer">
|
||||
<span className="ds-wordmark">minabot.</span>
|
||||
<span>A little, every day.</span>
|
||||
<Button variant="text" onClick={() => window.scrollTo({ top: 0, behavior: "instant" })}>
|
||||
Back to top ↑
|
||||
</Button>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
632
src/pages/Home.tsx
Normal file
632
src/pages/Home.tsx
Normal file
@@ -0,0 +1,632 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "../components/AuthProvider";
|
||||
import { DiscordSignInButton } from "../components/DiscordSignInButton";
|
||||
import { PageLayout } from "../components/design-system/PageLayout";
|
||||
import {
|
||||
Button,
|
||||
ButtonLink,
|
||||
Checkbox,
|
||||
Counter,
|
||||
SectionHeading,
|
||||
} from "../components/design-system/primitives";
|
||||
import { Card, CardGrid } from "../components/design-system/Card";
|
||||
import { HabitChart } from "../components/design-system/HabitChart";
|
||||
import { HABIT_COLORS } from "../components/design-system/calendar-model";
|
||||
import { HabitForm } from "../components/HabitForm";
|
||||
import { habitRequest, scheduleLabel, type TodayHabit, type TodayResponse } from "../lib/dashboard";
|
||||
import { HabitHistory } from "../components/HabitHistory";
|
||||
import { WelcomePanel } from "../components/design-system/WelcomePanel";
|
||||
import type { PublicUser } from "../shared/user";
|
||||
import { ItemActions } from "../components/design-system/ItemActions";
|
||||
import { type ItemMode } from "../components/InlineItemForm";
|
||||
import { InlineHabitEditor } from "../components/InlineHabitEditor";
|
||||
import { InlineTaskEditor } from "../components/InlineTaskEditor";
|
||||
import type { Schedule } from "../habits/contracts";
|
||||
import { ShareProgress } from "../components/ShareProgress";
|
||||
|
||||
export function Home() {
|
||||
const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();
|
||||
return (
|
||||
<PageLayout
|
||||
header={
|
||||
loading ? (
|
||||
<span className="ds-library-label">Loading account…</span>
|
||||
) : user ? (
|
||||
<Button variant="text" disabled={busy} onClick={() => void signOut()}>
|
||||
{busy ? "Signing out…" : "Sign out"}
|
||||
</Button>
|
||||
) : (
|
||||
!accountError && <DiscordSignInButton variant="secondary" onClick={signIn} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<div className="ds-form-feedback" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<section className="ds-section" aria-busy="true">
|
||||
<p role="status">Getting things ready…</p>
|
||||
</section>
|
||||
) : accountError ? (
|
||||
<section className="ds-section">
|
||||
<h1 className="type-section">Let’s try that again.</h1>
|
||||
<Card headingLevel={2} heading="Account unavailable">
|
||||
<p>Your account couldn’t be loaded.</p>
|
||||
<div className="ds-actions">
|
||||
<Button onClick={retry}>Try again</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
) : user ? (
|
||||
<Dashboard key={user.id} user={user} onExpired={retry} />
|
||||
) : (
|
||||
<Welcome onSignIn={signIn} />
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function Welcome({ onSignIn }: { onSignIn: () => void }) {
|
||||
const [water, setWater] = useState(3);
|
||||
const [read, setRead] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<section className="ds-section ds-split-section" aria-labelledby="welcome-title">
|
||||
<div className="ds-section-heading">
|
||||
<p className="ds-eyebrow">A LITTLE, EVERY DAY</p>
|
||||
<h1 id="welcome-title" className="type-section">
|
||||
Small steps.
|
||||
<br />
|
||||
<em>Lasting rhythm.</em>
|
||||
</h1>
|
||||
</div>
|
||||
<Card headingLevel={2} heading="Make room for what matters.">
|
||||
<p>
|
||||
Check in, count a little more, or work through a few tasks. See your progress grow, one
|
||||
day at a time.
|
||||
</p>
|
||||
<div className="ds-actions">
|
||||
<DiscordSignInButton onClick={onSignIn} />
|
||||
<ButtonLink href="#try-it">Try it below ↓</ButtonLink>
|
||||
</div>
|
||||
<p className="type-small">Sign in with your Discord account to save your habits.</p>
|
||||
</Card>
|
||||
</section>
|
||||
<section className="ds-section" id="try-it" aria-labelledby="try-it-title">
|
||||
<div className="ds-section-top">
|
||||
<SectionHeading
|
||||
number="TRY A CHECK-IN"
|
||||
id="try-it-title"
|
||||
title={
|
||||
<>
|
||||
A little progress. <em>Made visible.</em>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<span className="ds-demo-note">Example · September 4, 2026 · not saved</span>
|
||||
</div>
|
||||
<div className="ds-habit-chart-grid">
|
||||
<HabitChart
|
||||
headingLevel={3}
|
||||
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
|
||||
headingLevel={3}
|
||||
name="Read a little"
|
||||
method="Simple check-in"
|
||||
value={Number(read)}
|
||||
target={1}
|
||||
unit="reading session"
|
||||
color={HABIT_COLORS.reading}
|
||||
>
|
||||
<Checkbox
|
||||
label="Reading done"
|
||||
checked={read}
|
||||
onChange={(event) => setRead(event.target.checked)}
|
||||
/>
|
||||
</HabitChart>
|
||||
</div>
|
||||
</section>
|
||||
<section className="ds-section" aria-label="Your own rhythm">
|
||||
<CardGrid>
|
||||
<Card heading="Your habits. Your pace." eyebrow="MAKE IT FIT">
|
||||
<p>
|
||||
Pick a check-in, a count target, or a task list. Repeat daily, on selected weekdays,
|
||||
or at your own interval.
|
||||
</p>
|
||||
</Card>
|
||||
<Card heading="See the days add up." eyebrow="KEEP PERSPECTIVE" variant="outlined">
|
||||
<p>
|
||||
Explore your calendar to see the progress behind each square. Days off stay distinct
|
||||
from missed days.
|
||||
</p>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => void }) {
|
||||
const [today, setToday] = useState<TodayResponse | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [needsRefresh, setNeedsRefresh] = useState(false);
|
||||
const saving = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
const expired = useRef(onExpired);
|
||||
expired.current = onExpired;
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const reportError = useCallback((error: unknown) => {
|
||||
if (error instanceof Error && error.message.includes("session has expired")) expired.current();
|
||||
else
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Could not load your habits. Please try again."
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
habitRequest<TodayResponse>("/today", { signal: controller.signal })
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setToday(data);
|
||||
setNeedsRefresh(false);
|
||||
setRevision((value) => value + 1);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) reportError(error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [attempt, reportError]);
|
||||
|
||||
// Refresh after returning to the page and across the account's local midnight.
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
if (!saving.current && document.visibilityState === "visible")
|
||||
setAttempt((value) => value + 1);
|
||||
};
|
||||
const timer = window.setInterval(refresh, 60_000);
|
||||
window.addEventListener("focus", refresh);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("focus", refresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function update(
|
||||
habit: TodayHabit,
|
||||
body: { count: number } | { done: boolean },
|
||||
taskId?: string
|
||||
) {
|
||||
if (saving.current || loading || needsRefresh || !today) return;
|
||||
saving.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
const path = `/habits/${habit.habitId}/days/${today.date}/${taskId ? `tasks/${taskId}` : "progress"}`;
|
||||
const updated = await habitRequest<TodayHabit>(path, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!mounted.current) return;
|
||||
setToday((current) => {
|
||||
if (!current) 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,
|
||||
};
|
||||
});
|
||||
setRevision((value) => value + 1);
|
||||
setNotice(`${habit.name} saved.`);
|
||||
} catch (error) {
|
||||
if (mounted.current) reportError(error);
|
||||
} finally {
|
||||
saving.current = false;
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function manage(
|
||||
habit: TodayHabit,
|
||||
patch: Record<string, unknown> | null,
|
||||
taskId?: string,
|
||||
createTask = false
|
||||
) {
|
||||
if (saving.current || loading || needsRefresh)
|
||||
throw new Error("Please wait for the dashboard to refresh.");
|
||||
saving.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
await habitRequest(
|
||||
`/habits/${habit.habitId}${createTask ? "/tasks" : taskId ? `/tasks/${taskId}` : ""}`,
|
||||
{
|
||||
method: createTask ? "POST" : patch ? "PATCH" : "DELETE",
|
||||
...(patch
|
||||
? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch) }
|
||||
: {}),
|
||||
}
|
||||
);
|
||||
// A refresh failure must never turn a successful delete into a retryable delete.
|
||||
try {
|
||||
const refreshed = await habitRequest<TodayResponse>("/today");
|
||||
if (mounted.current) {
|
||||
setToday(refreshed);
|
||||
setNeedsRefresh(false);
|
||||
setRevision((value) => value + 1);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
if (refreshError instanceof Error && refreshError.message.includes("session has expired"))
|
||||
expired.current();
|
||||
if (mounted.current) {
|
||||
setNeedsRefresh(true);
|
||||
setError(
|
||||
"Your change was saved, but the dashboard could not refresh. Try again to load the latest data."
|
||||
);
|
||||
}
|
||||
}
|
||||
if (mounted.current) {
|
||||
setNotice(
|
||||
`${taskId || createTask ? "Task" : "Habit"} ${createTask ? "added" : patch ? "updated" : "deleted"}.`
|
||||
);
|
||||
if (!patch)
|
||||
window.requestAnimationFrame(() => {
|
||||
(
|
||||
document.getElementById("habits-title") ?? document.getElementById("add-habit")
|
||||
)?.focus();
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("session has expired"))
|
||||
expired.current();
|
||||
throw error;
|
||||
} finally {
|
||||
saving.current = false;
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WelcomePanel
|
||||
name={user.displayName || user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
date={today?.date}
|
||||
timezone={today?.timezone || user.timezone}
|
||||
>
|
||||
{today && (
|
||||
<>
|
||||
<div className="ds-welcome-progress" role="status">
|
||||
{today.due > 0 ? (
|
||||
<>
|
||||
<span className="type-title">
|
||||
{today.completed} / {today.due}
|
||||
</span>
|
||||
<span>
|
||||
habits complete today
|
||||
{today.completed === today.due && (
|
||||
<span className="ds-welcome-complete">A little, all done.</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
{today.habits.length
|
||||
? "Nothing scheduled today. Enjoy a little breathing room."
|
||||
: "Start with one habit. Your first small step starts here."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="ds-actions">
|
||||
<Button
|
||||
id="add-habit"
|
||||
disabled={adding || busy || loading || needsRefresh}
|
||||
onClick={() => setAdding(true)}
|
||||
aria-expanded={adding}
|
||||
aria-controls={adding ? "new-habit" : undefined}
|
||||
>
|
||||
Add a habit +
|
||||
</Button>
|
||||
{today.habits.length > 0 && (
|
||||
<>
|
||||
<Button id="open-sharing" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button>
|
||||
<ButtonLink href="#habits-title">View your habits ↓</ButtonLink>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</WelcomePanel>
|
||||
{sharing && today && <ShareProgress user={user} today={today} revision={revision} onClose={() => { setSharing(false); requestAnimationFrame(() => document.getElementById("open-sharing")?.focus()); }} />}
|
||||
{error && (
|
||||
<div className="ds-form-feedback" role="alert">
|
||||
<p>{error}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={loading}
|
||||
onClick={() => setAttempt((value) => value + 1)}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!today && loading && (
|
||||
<section className="ds-section">
|
||||
<p role="status">Loading your habits…</p>
|
||||
</section>
|
||||
)}
|
||||
{today && (
|
||||
<>
|
||||
{adding && (
|
||||
<HabitForm
|
||||
date={today.date}
|
||||
onCancel={() => setAdding(false)}
|
||||
onExpired={() => expired.current()}
|
||||
onCreated={(name) => {
|
||||
setAdding(false);
|
||||
setNotice(`${name} created.`);
|
||||
setAttempt((value) => value + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<p className="ds-form-feedback" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
{today.habits.length > 0 && (
|
||||
<section
|
||||
className="ds-section"
|
||||
aria-labelledby="habits-title"
|
||||
aria-busy={busy || loading}
|
||||
>
|
||||
<div className="ds-section-top">
|
||||
<SectionHeading
|
||||
number="YOUR HABITS"
|
||||
id="habits-title"
|
||||
title={
|
||||
<>
|
||||
One day <em>at a time.</em>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="ds-habit-chart-grid">
|
||||
{today.habits.map((habit) => (
|
||||
<SavedHabit
|
||||
key={habit.habitId}
|
||||
habit={habit}
|
||||
date={today.date}
|
||||
revision={revision}
|
||||
disabled={busy || loading || needsRefresh}
|
||||
onUpdate={update}
|
||||
onManage={manage}
|
||||
onExpired={() => expired.current()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedHabit({
|
||||
habit,
|
||||
date,
|
||||
revision,
|
||||
disabled,
|
||||
onUpdate,
|
||||
onManage,
|
||||
onExpired,
|
||||
}: {
|
||||
habit: TodayHabit;
|
||||
date: string;
|
||||
revision: number;
|
||||
disabled: boolean;
|
||||
onUpdate: (
|
||||
habit: TodayHabit,
|
||||
body: { count: number } | { done: boolean },
|
||||
taskId?: string
|
||||
) => Promise<void>;
|
||||
onExpired: () => void;
|
||||
onManage: (
|
||||
habit: TodayHabit,
|
||||
patch: Record<string, unknown> | null,
|
||||
taskId?: string,
|
||||
createTask?: boolean
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState<{ mode: ItemMode; color: string } | null>(null);
|
||||
const [addingTask, setAddingTask] = useState(false);
|
||||
const blocked = disabled || !habit.due;
|
||||
return (
|
||||
<HabitHistory
|
||||
habit={habit}
|
||||
date={date}
|
||||
revision={revision}
|
||||
disabled={disabled}
|
||||
onExpired={onExpired}
|
||||
onEdit={(color) => setEditing({ mode: "edit", color })}
|
||||
onDelete={() => setEditing({ mode: "delete", color: "#196127" })}
|
||||
editor={
|
||||
editing && habit.requirements ? (
|
||||
<InlineHabitEditor
|
||||
key={editing.mode}
|
||||
config={habit.requirements}
|
||||
date={date}
|
||||
color={editing.color}
|
||||
mode={editing.mode}
|
||||
disabled={disabled}
|
||||
onClose={() => setEditing(null)}
|
||||
onSave={(patch) => onManage(habit, patch)}
|
||||
onDelete={() => onManage(habit, null)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
tasks={
|
||||
habit.requirements?.method === "tasks" ? (
|
||||
<>
|
||||
{habit.requirements.tasks.map((config) => {
|
||||
const occurrence = habit.tasks.find((task) => task.taskId === config.id);
|
||||
return (
|
||||
<SavedTask
|
||||
key={config.id}
|
||||
task={{ taskId: config.id, name: config.name, done: occurrence?.done ?? false }}
|
||||
disabled={disabled}
|
||||
blocked={blocked || !occurrence}
|
||||
scheduled={!!occurrence}
|
||||
schedule={config.schedule}
|
||||
habitSchedule={habit.requirements!.schedule}
|
||||
date={date}
|
||||
onCheck={(done) => void onUpdate(habit, { done }, config.id)}
|
||||
onSave={(patch) => onManage(habit, patch, config.id)}
|
||||
onDelete={() => onManage(habit, null, config.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{addingTask ? (
|
||||
<InlineTaskEditor
|
||||
name=""
|
||||
schedule={{ type: "daily" }}
|
||||
habitSchedule={habit.requirements.schedule}
|
||||
date={date}
|
||||
mode="edit"
|
||||
creating
|
||||
disabled={disabled}
|
||||
onClose={() => setAddingTask(false)}
|
||||
onSave={(patch) => onManage(habit, patch, undefined, true)}
|
||||
onDelete={async () => {}}
|
||||
/>
|
||||
) : (
|
||||
<div className="ds-task-add-action">
|
||||
{!habit.requirements.tasks.length && (
|
||||
<p className="ds-footnote">Add a task to start checking in.</p>
|
||||
)}
|
||||
<Button
|
||||
variant="text"
|
||||
disabled={disabled || habit.requirements.tasks.length >= 100}
|
||||
onClick={() => setAddingTask(true)}
|
||||
>
|
||||
Add task +
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{habit.method === "count" ? (
|
||||
<Counter
|
||||
label={habit.name ?? "habit count"}
|
||||
value={habit.value}
|
||||
target={habit.target ?? 0}
|
||||
disabled={blocked}
|
||||
onChange={(count) => void onUpdate(habit, { count })}
|
||||
/>
|
||||
) : habit.method === "manual" ? (
|
||||
<Checkbox
|
||||
label={`${habit.name} done`}
|
||||
checked={habit.complete}
|
||||
disabled={blocked}
|
||||
onChange={(event) => void onUpdate(habit, { done: event.target.checked })}
|
||||
/>
|
||||
) : undefined}
|
||||
</HabitHistory>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedTask({
|
||||
task,
|
||||
schedule,
|
||||
habitSchedule,
|
||||
date,
|
||||
disabled,
|
||||
blocked,
|
||||
scheduled,
|
||||
onCheck,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
task: Pick<TodayHabit["tasks"][number], "taskId" | "name" | "done">;
|
||||
disabled: boolean;
|
||||
blocked: boolean;
|
||||
scheduled: boolean;
|
||||
schedule: Schedule;
|
||||
habitSchedule: Schedule;
|
||||
date: string;
|
||||
onCheck: (done: boolean) => void;
|
||||
onSave: (patch: { name?: string; schedule?: Schedule }) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
}) {
|
||||
const [mode, setMode] = useState<ItemMode | null>(null);
|
||||
return (
|
||||
<div className="ds-editable-task">
|
||||
<div className="ds-editable-task-row">
|
||||
<Checkbox
|
||||
label={task.name}
|
||||
description={`${scheduleLabel(schedule)}${scheduled ? "" : " · Not scheduled today"}`}
|
||||
checked={task.done}
|
||||
disabled={blocked || !!mode}
|
||||
onChange={(event) => onCheck(event.target.checked)}
|
||||
/>
|
||||
<ItemActions
|
||||
name={task.name}
|
||||
kind="task"
|
||||
disabled={disabled || !!mode}
|
||||
onEdit={() => setMode("edit")}
|
||||
onDelete={() => setMode("delete")}
|
||||
/>
|
||||
</div>
|
||||
{mode && (
|
||||
<InlineTaskEditor
|
||||
name={task.name}
|
||||
schedule={schedule}
|
||||
habitSchedule={habitSchedule}
|
||||
date={date}
|
||||
mode={mode}
|
||||
disabled={disabled}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
onClose={() => setMode(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/sharing/config.ts
Normal file
9
src/sharing/config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type DiscordSharingConfig = { token: string; channelId: string };
|
||||
|
||||
/** Read only from the server entrypoint; never include credentials in public config. */
|
||||
export function readDiscordSharingConfig(env = process.env): DiscordSharingConfig {
|
||||
return {
|
||||
token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""),
|
||||
channelId: (env.DISCORD_SHARING_CHANNEL_ID ?? "").trim(),
|
||||
};
|
||||
}
|
||||
21
src/sharing/contracts.ts
Normal file
21
src/sharing/contracts.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
import { dateSchema } from "../habits/contracts";
|
||||
import { dayNumber } from "../habits/calendar";
|
||||
|
||||
export const shareInput = z.object({
|
||||
habitIds: z.array(z.string().uuid()).min(1).max(6).refine(ids => new Set(ids).size === ids.length),
|
||||
from: dateSchema,
|
||||
to: dateSchema,
|
||||
}).strict().refine(v => v.from <= v.to && dayNumber(v.to) - dayNumber(v.from) < 366, "Choose up to 366 days in chronological order");
|
||||
|
||||
export type ShareData = {
|
||||
from: string; to: string; timezone: string;
|
||||
botAvatarUrl?: string | null;
|
||||
habits: {
|
||||
name: string; color: string; completed: number; scheduled: number;
|
||||
legend: { empty: string; partial: string[]; complete: string };
|
||||
days: { date: string; color: string; due: boolean; complete: boolean; ratio: number | null }[];
|
||||
}[];
|
||||
};
|
||||
export type DiscordConnection = { connected: boolean; name?: string; channelUrl?: string; message?: string };
|
||||
export type Delivery = { status: "sent" | "uncertain"; messageUrl?: string };
|
||||
105
src/sharing/routes.test.ts
Normal file
105
src/sharing/routes.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { createApi } from "../api";
|
||||
import { fixture } from "../habits/test-fixture";
|
||||
import type { DiscordFetch } from "./routes";
|
||||
import type { DiscordSharingConfig } from "./config";
|
||||
import { cardStats } from "../lib/progress-card";
|
||||
|
||||
const fixtures: ReturnType<typeof fixture>[] = [];
|
||||
afterEach(() => { for (const f of fixtures.splice(0)) f.close(); });
|
||||
const botConfig = { token: "test-bot-token-do-not-expose", channelId: "223456789012345678" };
|
||||
const channel = { type: 0, id: botConfig.channelId, guild_id: "323456789012345678", name: "progress" };
|
||||
function setup(request: DiscordFetch = async () => Response.json(channel), config: DiscordSharingConfig = botConfig) {
|
||||
const f = fixture(); fixtures.push(f);
|
||||
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-with-at-least-32-characters" }, undefined, () => Date.parse("2026-09-04T12:00:00Z"), request, config);
|
||||
const call = (path: string, method = "GET", body?: unknown, user = "a", origin = f.origin) => app.request(`${f.origin}/api/sharing${path}`, { method, headers: { Cookie: `minabot_session=${user.repeat(43)}`, Origin: origin, ...(body instanceof FormData ? {} : { "Content-Type": "application/json" }) }, body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body) });
|
||||
return { f, call, connect: () => call("/discord") };
|
||||
}
|
||||
// A small real PNG is not 1200px wide; use a header fixture for transport validation.
|
||||
function upload(id = crypto.randomUUID()) {
|
||||
const header = Buffer.alloc(40); Buffer.from("89504e470d0a1a0a", "hex").copy(header); header.write("IHDR", 12); header.writeUInt32BE(1200, 16); header.writeUInt32BE(940, 20);
|
||||
const data = new FormData(); data.set("image", new Blob([header], { type: "image/png" }), "progress.png"); data.set("deliveryId", id); return data;
|
||||
}
|
||||
|
||||
test("sharing requires authentication and same-origin mutations", async () => {
|
||||
const { call } = setup();
|
||||
expect((await call("/discord", "GET", undefined, "x")).status).toBe(401);
|
||||
expect((await call("/discord/send", "POST", upload(), "a", "https://evil.example")).status).toBe(403);
|
||||
expect((await call("/preview", "POST", {}, "a", "https://evil.example")).status).toBe(403);
|
||||
});
|
||||
test("missing or invalid bot configuration cannot make outbound requests", async () => {
|
||||
for (const config of [{ token: "", channelId: channel.id }, { token: "test", channelId: "https://evil.example" }, { token: "bad\nheader", channelId: channel.id }]) {
|
||||
let requests = 0;
|
||||
const { call } = setup(async () => { requests++; return Response.json(channel); }, config);
|
||||
expect((await (await call("/discord")).json()).connected).toBe(false);
|
||||
expect((await call("/discord/send", "POST", upload())).status).toBe(422);
|
||||
expect(requests).toBe(0);
|
||||
}
|
||||
});
|
||||
test("bot channel status exposes only the configured destination and caches channel metadata", async () => {
|
||||
let requests = 0;
|
||||
const { call } = setup(async (url, init) => {
|
||||
requests++; expect(url).toBe(`https://discord.com/api/v10/channels/${channel.id}`);
|
||||
expect(init?.redirect).toBe("error"); expect(new Headers(init?.headers).get("Authorization")).toBe(`Bot ${botConfig.token}`);
|
||||
return Response.json(channel);
|
||||
});
|
||||
const expected = { connected: true, name: "#progress", channelUrl: `https://discord.com/channels/${channel.guild_id}/${channel.id}` };
|
||||
const response = await call("/discord"); expect(response.status).toBe(200); expect(await response.json()).toEqual(expected);
|
||||
expect(await (await call("/discord", "GET", undefined, "b")).json()).toEqual(expected);
|
||||
expect(requests).toBe(1);
|
||||
expect((await call("/discord", "PUT", { url: "https://evil.example" })).status).toBe(404);
|
||||
expect((await call("/discord", "DELETE")).status).toBe(404);
|
||||
});
|
||||
test("bot errors do not expose the token or provider response", async () => {
|
||||
const { call } = setup(async () => { throw new Error(botConfig.token); });
|
||||
const response = await call("/discord"); expect(response.status).toBe(502); expect(await response.text()).not.toContain(botConfig.token);
|
||||
});
|
||||
test("card data respects ownership, recurrence, privacy boundaries, and date limits", async () => {
|
||||
const { f, call } = setup();
|
||||
f.setTime("2026-09-03T12:00:00Z");
|
||||
const habit = await f.json("/habits", "POST", { name: "Evening", method: "tasks", tasks: [{ name: "private task name" }], schedule: { type: "weekdays", days: [4] } }, 201);
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-03/tasks/${habit.tasks[0].id}`, "PUT", { done: true });
|
||||
f.setTime("2026-09-04T12:00:00Z");
|
||||
const input = { habitIds: [habit.id], from: "2026-09-01", to: "2026-09-04" };
|
||||
const response = await call("/preview", "POST", input); expect(response.status).toBe(200);
|
||||
const data = await response.json(); expect(JSON.stringify(data)).not.toContain("private task name");
|
||||
expect(cardStats(data)).toEqual({ completed: 1, scheduled: 1, percent: 100 });
|
||||
expect(data.habits[0].legend).toEqual({ empty: "#ebedf0", partial: [], complete: "#196127" });
|
||||
expect(data.habits[0].days[3].due).toBe(false);
|
||||
expect((await call("/preview", "POST", input, "b")).status).toBe(404);
|
||||
for (const patch of [{ to: "2026-09-05" }, { from: "2024-01-01" }, { from: "2026-09-05" }, { habitIds: [] }, { habitIds: [habit.id, habit.id] }]) expect((await call("/preview", "POST", { ...input, ...patch })).status).toBe(422);
|
||||
});
|
||||
test("delivery sends exactly the PNG with mentions disabled and deduplicates retries", async () => {
|
||||
let posts = 0;
|
||||
const { call, connect } = setup(async (target, init) => {
|
||||
if (init?.method !== "POST") return Response.json(channel);
|
||||
posts++; expect(target).toBe(`https://discord.com/api/v10/channels/${channel.id}/messages`);
|
||||
expect(new Headers(init.headers).get("Authorization")).toBe(`Bot ${botConfig.token}`);
|
||||
const form = init.body as FormData; const payload = JSON.parse(form.get("payload_json") as string);
|
||||
expect(payload.allowed_mentions).toEqual({ parse: [] }); expect(payload.username).toBeUndefined(); expect(payload.enforce_nonce).toBe(true); expect(payload.nonce).toHaveLength(24);
|
||||
expect(payload.content).toBeUndefined(); expect((form.get("files[0]") as File).type).toBe("image/png");
|
||||
return Response.json({ id: "423456789012345678" });
|
||||
});
|
||||
await connect(); const id = crypto.randomUUID();
|
||||
const first = await call("/discord/send", "POST", upload(id)); expect(first.status).toBe(200);
|
||||
expect(await first.json()).toEqual({ status: "sent", messageUrl: "https://discord.com/channels/323456789012345678/223456789012345678/423456789012345678" });
|
||||
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("sent"); expect(posts).toBe(1);
|
||||
});
|
||||
test("uncertain delivery is not resent and a known rate-limit rejection can be retried", async () => {
|
||||
let posts = 0;
|
||||
const { call, connect } = setup(async (_, init) => {
|
||||
if (init?.method !== "POST") return Response.json(channel);
|
||||
posts++; if (posts === 1) return new Response(null, { status: 429 }); throw new Error("Network failed after sending");
|
||||
});
|
||||
await connect(); const id = crypto.randomUUID();
|
||||
expect((await call("/discord/send", "POST", upload(id))).status).toBe(429);
|
||||
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain");
|
||||
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain"); expect(posts).toBe(2);
|
||||
});
|
||||
test("invalid files and absent bot configuration cannot send", async () => {
|
||||
let posts = 0; const { call, connect } = setup(async (_, init) => { if (init?.method === "POST") posts++; return Response.json(channel); });
|
||||
const disabled = setup(undefined, { token: "", channelId: "" });
|
||||
expect((await disabled.call("/discord/send", "POST", upload())).status).toBe(422);
|
||||
await connect(); const form = upload(); form.set("image", new Blob(["not png"], { type: "image/png" }), "x.png");
|
||||
expect((await call("/discord/send", "POST", form)).status).toBe(422); expect(posts).toBe(0);
|
||||
});
|
||||
134
src/sharing/routes.ts
Normal file
134
src/sharing/routes.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Hono } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
|
||||
import { discordDeliveries } from "../db/schema";
|
||||
import { HabitService, ApiError } from "../habits/service";
|
||||
import { shade } from "../habits/calendar";
|
||||
import { shareInput, type ShareData } from "./contracts";
|
||||
import type { DiscordSharingConfig } from "./config";
|
||||
|
||||
export type DiscordFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
||||
const snowflake = /^\d{17,20}$/;
|
||||
|
||||
export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, config: DiscordSharingConfig, now: () => number, request: DiscordFetch = fetch) {
|
||||
const app = new Hono<AuthEnv>();
|
||||
const configured = Boolean(config.token && !/\s/.test(config.token) && snowflake.test(config.channelId));
|
||||
const channelEndpoint = `https://discord.com/api/v10/channels/${config.channelId}`;
|
||||
const headers = { Authorization: `Bot ${config.token}` };
|
||||
let cachedBotAvatar: { url: string | null; expiresAt: number } | null = null;
|
||||
let loadingBotAvatar: Promise<string | null> | null = null;
|
||||
async function botAvatarUrl(): Promise<string | null> {
|
||||
if (!configured) return null;
|
||||
if (cachedBotAvatar && cachedBotAvatar.expiresAt > now()) return cachedBotAvatar.url;
|
||||
if (loadingBotAvatar) return loadingBotAvatar;
|
||||
loadingBotAvatar = (async () => {
|
||||
let url: string | null = null;
|
||||
try {
|
||||
const response = await request("https://discord.com/api/v10/users/@me", { headers, redirect: "error", signal: AbortSignal.timeout(5000) });
|
||||
const bot = response.ok ? await response.json() as { id?: string; bot?: boolean; avatar?: string | null; discriminator?: string } : null;
|
||||
if (bot?.bot && snowflake.test(bot.id ?? "")) {
|
||||
if (bot.avatar && /^(?:a_)?[a-fA-F0-9]{32}$/.test(bot.avatar)) {
|
||||
url = `https://cdn.discordapp.com/avatars/${bot.id}/${bot.avatar}.png?size=128`;
|
||||
} else if (!bot.avatar) {
|
||||
const index = bot.discriminator && /^\d{4}$/.test(bot.discriminator) && bot.discriminator !== "0000"
|
||||
? Number(bot.discriminator) % 5 : Number((BigInt(bot.id!) >> 22n) % 6n);
|
||||
url = `https://cdn.discordapp.com/embed/avatars/${index}.png`;
|
||||
}
|
||||
}
|
||||
} catch { /* Keep card exports available when Discord cannot supply the avatar. */ }
|
||||
cachedBotAvatar = { url, expiresAt: now() + (url ? 300000 : 30000) };
|
||||
return url;
|
||||
})();
|
||||
try { return await loadingBotAvatar; } finally { loadingBotAvatar = null; }
|
||||
}
|
||||
let cachedChannel: { name: string; guildId: string; channelUrl: string; expiresAt: number } | null = null;
|
||||
async function channel() {
|
||||
if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");
|
||||
if (cachedChannel && cachedChannel.expiresAt > now()) return cachedChannel;
|
||||
let response: Response;
|
||||
try { response = await request(channelEndpoint, { headers, redirect: "error", signal: AbortSignal.timeout(10000) }); }
|
||||
catch { throw new ApiError(502, "Could not reach Discord. Please try again."); }
|
||||
if (!response.ok) {
|
||||
const message = response.status === 401 ? "The Discord bot token is invalid. Update the server configuration."
|
||||
: response.status === 403 || response.status === 404 ? "The bot cannot access the sharing channel. Check its channel permissions."
|
||||
: response.status === 429 ? "Discord is busy. Wait a moment before trying again." : "Could not load the Discord sharing channel.";
|
||||
throw new ApiError(response.status === 429 ? 429 : 502, message);
|
||||
}
|
||||
const data = await response.json().catch(() => null) as { id?: string; type?: number; name?: string; guild_id?: string } | null;
|
||||
if (data?.id !== config.channelId || !snowflake.test(data.guild_id ?? "") || ![0, 5, 10, 11, 12].includes(data.type ?? -1))
|
||||
throw new ApiError(422, "Set the sharing channel to a Discord server text channel or thread.");
|
||||
cachedChannel = { name: data.name ? `#${data.name}` : "Discord sharing channel", guildId: data.guild_id!, channelUrl: `https://discord.com/channels/${data.guild_id}/${config.channelId}`, expiresAt: now() + 60000 };
|
||||
return cachedChannel;
|
||||
}
|
||||
app.use("*", auth.requireAuth);
|
||||
app.use("*", async (c, next) => c.req.method === "GET" ? next() : auth.requireSameOrigin(c, next));
|
||||
app.use("*", bodyLimit({ maxSize: MAX_IMAGE_BYTES + 65536, onError: c => c.json({ error: "Choose a progress image smaller than 4 MB." }, 413) }));
|
||||
async function json<T extends z.ZodType>(req: Request, schema: T): Promise<z.output<T>> {
|
||||
if (req.headers.get("Content-Type")?.split(";")[0] !== "application/json") throw new ApiError(400, "Expected application/json");
|
||||
const value = await req.json().catch(() => { throw new ApiError(400, "Malformed JSON"); });
|
||||
const parsed = schema.safeParse(value);
|
||||
if (!parsed.success) throw new ApiError(422, "Check your selection and try again.");
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
app.post("/preview", async c => {
|
||||
const input = await json(c.req.raw, shareInput);
|
||||
const service = new HabitService(db, c.get("user"), now()); service.sync();
|
||||
if (input.to > service.today) throw new ApiError(422, "The end date cannot be after today.");
|
||||
const result: ShareData = { from: input.from, to: input.to, timezone: service.user.timezone, habits: input.habitIds.map(id => {
|
||||
const habit = service.current(id);
|
||||
const chart = service.calendar([id], input.from, input.to, service.settings(id), false);
|
||||
return { name: habit.name, color: chart.settings.mainColor,
|
||||
legend: { empty: chart.settings.emptyColor, complete: chart.settings.mainColor,
|
||||
partial: chart.days.some(d => d.shadeCount > 1) ? [0.25, 0.5, 0.75].map(ratio => shade(ratio, 4, chart.settings, false).color) : [] },
|
||||
completed: chart.days.filter(d => d.completed > 0).length, scheduled: chart.days.filter(d => d.due > 0).length,
|
||||
days: chart.days.map(d => ({ date: d.date, color: d.color, due: d.due > 0, complete: d.completed > 0, ratio: d.ratio })) };
|
||||
}) };
|
||||
result.botAvatarUrl = await botAvatarUrl();
|
||||
return c.json(result);
|
||||
});
|
||||
app.get("/discord", async c => {
|
||||
if (!configured) return c.json({ connected: false, message: "Discord sharing has not been configured on this server." });
|
||||
const destination = await channel();
|
||||
return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl });
|
||||
});
|
||||
app.post("/discord/send", async c => {
|
||||
const userId = c.get("user").id;
|
||||
if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");
|
||||
const form = await c.req.formData().catch(() => { throw new ApiError(400, "Expected a progress image."); });
|
||||
const id = z.string().uuid().safeParse(form.get("deliveryId"));
|
||||
const image = form.get("image");
|
||||
if (!id.success || !(image instanceof File) || image.type !== "image/png" || image.size > MAX_IMAGE_BYTES || image.size < 33) throw new ApiError(422, "Choose a PNG progress image smaller than 4 MB.");
|
||||
const bytes = Buffer.from(await image.arrayBuffer());
|
||||
if (bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" || bytes.toString("ascii", 12, 16) !== "IHDR" || bytes.readUInt32BE(16) !== 1200 || bytes.readUInt32BE(20) > 4000) throw new ApiError(422, "Regenerate the progress card before sending.");
|
||||
const destination = await channel();
|
||||
const imageHash = createHash("sha256").update(bytes).update(`bot:${config.channelId}`).digest("hex");
|
||||
const previous = db.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get();
|
||||
if (previous) {
|
||||
if (previous.userId !== userId || previous.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again.");
|
||||
return c.json({ status: previous.status === "sent" ? "sent" : "uncertain", messageUrl: previous.messageUrl ?? undefined });
|
||||
}
|
||||
db.insert(discordDeliveries).values({ id: id.data, userId, imageHash, status: "pending", createdAt: now() }).run();
|
||||
const attachment = new FormData();
|
||||
attachment.set("payload_json", JSON.stringify({ allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(`${userId}:${id.data}`).digest("hex").slice(0, 24), enforce_nonce: true, attachments: [{ id: 0, filename: "minabot-progress.png", description: "Progress card shared from minabot" }] }));
|
||||
attachment.set("files[0]", image, "minabot-progress.png");
|
||||
let response: Response;
|
||||
try { response = await request(`${channelEndpoint}/messages`, { method: "POST", headers, body: attachment, redirect: "error", signal: AbortSignal.timeout(15000) }); }
|
||||
catch { return c.json({ status: "uncertain" }); }
|
||||
if (!response.ok) {
|
||||
if (response.status >= 500) return c.json({ status: "uncertain" });
|
||||
db.delete(discordDeliveries).where(eq(discordDeliveries.id, id.data)).run();
|
||||
cachedChannel = null;
|
||||
throw new ApiError(response.status === 429 ? 429 : 422, response.status === 429 ? "Discord is busy. Wait a moment before trying again." : "Discord rejected the card. Check the bot token and its View Channel, Send Messages, and Attach Files permissions (Send Messages in Threads for threads).");
|
||||
}
|
||||
const message = await response.json().catch(() => null) as { id?: string } | null;
|
||||
const messageUrl = snowflake.test(message?.id ?? "") ? `${destination.channelUrl}/${message!.id}` : null;
|
||||
db.update(discordDeliveries).set({ status: "sent", messageUrl }).where(eq(discordDeliveries.id, id.data)).run();
|
||||
return c.json({ status: "sent", messageUrl: messageUrl ?? undefined });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
Reference in New Issue
Block a user