Merge frontend performance improvements
This commit is contained in:
8
build.ts
8
build.ts
@@ -10,8 +10,9 @@ const result = await Bun.build({
|
||||
outdir,
|
||||
plugins: [tailwind],
|
||||
minify: true,
|
||||
splitting: true,
|
||||
target: "bun",
|
||||
external: ["@napi-rs/canvas"],
|
||||
external: ["@napi-rs/canvas", "/fonts/browser/*"],
|
||||
sourcemap: "linked",
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||
@@ -30,3 +31,8 @@ await mkdir(path.join(outdir, "fonts"), { recursive: true });
|
||||
for (const file of ["InstrumentSerif-Regular.ttf", "NotoSans-Regular.ttf", "NotoSans-Medium.ttf", "NotoSans-SemiBold.ttf"]) {
|
||||
await copyFile(`src/assets/fonts/${file}`, path.join(outdir, "fonts", file));
|
||||
}
|
||||
|
||||
await mkdir(path.join(outdir, "fonts/browser"), { recursive: true });
|
||||
for (const file of ["InstrumentSerif-Regular", "InstrumentSerif-Italic", "NotoSans-Regular", "NotoSans-Medium", "NotoSans-SemiBold"]) {
|
||||
await copyFile(`src/assets/fonts/${file}.woff2`, path.join(outdir, `fonts/browser/${file}.woff2`));
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
env = false
|
||||
|
||||
[serve.static]
|
||||
plugins = ["bun-plugin-tailwind"]
|
||||
plugins = ["bun-plugin-tailwind", "./scripts/browser-fonts-plugin.ts"]
|
||||
env = "BUN_PUBLIC_*"
|
||||
|
||||
9
scripts/browser-fonts-plugin.ts
Normal file
9
scripts/browser-fonts-plugin.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { BunPlugin } from "bun";
|
||||
|
||||
/** Match the production bundler's external font URLs in the HTML dev server. */
|
||||
export default {
|
||||
name: "external-browser-fonts",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^\/fonts\/browser\// }, args => ({ path: args.path, external: true }));
|
||||
},
|
||||
} satisfies BunPlugin;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { browserFont } from "../src/browser-fonts";
|
||||
// Isolated browser QA server. Never opens or changes the user's database.
|
||||
import { sendDueReminders } from "../src/reminders/worker";
|
||||
import type { DiscordFetch } from "../src/sharing/routes";
|
||||
@@ -55,6 +56,7 @@ const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
routes: {
|
||||
"/fonts/browser/*": browserFont,
|
||||
"/__preview/reminder": () => new Response(reminderMessage ?? "No reminder sent"),
|
||||
"/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }),
|
||||
"/__preview/login": () =>
|
||||
|
||||
127
src/App.test.tsx
127
src/App.test.tsx
@@ -91,6 +91,12 @@ function LocationProbe() {
|
||||
</>;
|
||||
}
|
||||
|
||||
async function settleDeferred() {
|
||||
for (let attempt = 0; attempt < 100 && [...container.querySelectorAll('[role="status"]')].some(node => node.textContent === "Loading…"); attempt++) {
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 1)); });
|
||||
}
|
||||
}
|
||||
|
||||
async function render(path = "/design-system") {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
@@ -100,6 +106,7 @@ async function render(path = "/design-system") {
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
await settleDeferred();
|
||||
}
|
||||
|
||||
function connectAccount() {
|
||||
@@ -131,6 +138,7 @@ describe("homepage", () => {
|
||||
const opener = buttonNamed("Settings");
|
||||
opener.focus();
|
||||
await act(async () => opener.click());
|
||||
await settleDeferred();
|
||||
const dialog = container.querySelector<HTMLDialogElement>("dialog#account-settings")!;
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||
@@ -158,6 +166,7 @@ describe("homepage", () => {
|
||||
expect(inner.open).toBe(true);
|
||||
expect(inner.querySelector<HTMLButtonElement>('[aria-label="Close dialog"]')?.disabled).toBe(true);
|
||||
await act(async () => buttonNamed("Finish save").click());
|
||||
await settleDeferred();
|
||||
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
|
||||
expect(container.querySelector("#inner")).toBeNull();
|
||||
expect(container.querySelector<HTMLDialogElement>("#outer")?.open).toBe(true);
|
||||
@@ -175,10 +184,13 @@ describe("homepage", () => {
|
||||
await f.request(`/habits/${h.id}`, "DELETE");
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Archived habits").click());
|
||||
await settleDeferred();
|
||||
expect(buttonNamed("Restore Reading")).toBeDefined();
|
||||
await act(async () => buttonNamed("View history").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
|
||||
await act(async () => buttonNamed("Restore Reading").click());
|
||||
await settleDeferred();
|
||||
expect(container.textContent).toContain("No archived habits.");
|
||||
expect((await f.json("/today")).habits[0].name).toBe("Reading");
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-03`)).complete).toBe(true);
|
||||
@@ -190,12 +202,16 @@ describe("homepage", () => {
|
||||
await f.request(`/habits/${habit.id}`, "DELETE");
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Archived habits").click());
|
||||
await settleDeferred();
|
||||
await act(async () => buttonNamed("Delete permanently Discard me").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("This cannot be undone.");
|
||||
expect(document.activeElement).toBe(buttonNamed("Keep habit"));
|
||||
await act(async () => buttonNamed("Keep habit").click());
|
||||
await settleDeferred();
|
||||
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
|
||||
await act(async () => buttonNamed("Delete permanently Discard me").click());
|
||||
await settleDeferred();
|
||||
const confirm = () => container.querySelector<HTMLButtonElement>("#delete-archived-habit .ds-action-row button:last-child")!;
|
||||
fetchMock.mockResolvedValueOnce(Response.json({ error: "Temporary failure" }, { status: 500 }));
|
||||
await act(async () => confirm().click());
|
||||
@@ -210,7 +226,7 @@ describe("homepage", () => {
|
||||
|
||||
test("historical check-ins use the selected date's tracking method and preserve today", async () => {
|
||||
const f = connectAccount();
|
||||
f.setTime("2026-09-03T12:00:00Z");
|
||||
f.setTime("2026-01-03T12:00:00Z");
|
||||
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||
f.setTime("2026-09-04T12:00:00Z");
|
||||
await f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" });
|
||||
@@ -220,12 +236,21 @@ describe("homepage", () => {
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label^="2026-09-03:"]')!.click());
|
||||
expect(buttonNamed("Edit selected check-in").closest(".ds-date-inspector")?.textContent).toContain("September 3, 2026");
|
||||
await act(async () => buttonNamed("Edit selected check-in").click());
|
||||
await settleDeferred();
|
||||
const past = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!;
|
||||
expect(past).toBeDefined();
|
||||
await act(async () => past.click());
|
||||
expect((await f.json(`/habits/${habit.id}/days/2026-09-03`)).complete).toBe(true);
|
||||
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).value).toBe(0);
|
||||
expect(container.textContent).toContain("Saved check-in for 2026-09-03");
|
||||
fetchMock.mockClear();
|
||||
await act(async () => {
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="date"]')!;
|
||||
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "2026-01-03");
|
||||
input.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event);
|
||||
});
|
||||
expect(container.querySelector('[aria-label^="2026-01-03:"]')).not.toBeNull();
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).includes("from=2025-11-01") && String(url).includes("compact=true"))).toBe(true);
|
||||
});
|
||||
|
||||
test("signed-out visitors can try progress without calling private habit APIs", async () => {
|
||||
@@ -233,6 +258,7 @@ describe("homepage", () => {
|
||||
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());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector<HTMLInputElement>('[aria-label="Total glasses of water"]')?.value).toBe("4");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -264,6 +290,7 @@ describe("homepage", () => {
|
||||
expect(container.textContent).toContain("Account unavailable");
|
||||
expect(container.querySelector("#welcome-title")).toBeNull();
|
||||
await act(async () => buttonNamed("Try again").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector("#welcome-title")).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -273,6 +300,7 @@ describe("homepage", () => {
|
||||
expect(container.textContent).toContain("Welcome back, alice.");
|
||||
expect(container.textContent).toContain("Start with one habit.");
|
||||
await act(async () => buttonNamed("Add a habit +").click());
|
||||
await settleDeferred();
|
||||
const input = container.querySelector<HTMLInputElement>("form input")!;
|
||||
expect(document.activeElement).toBe(input);
|
||||
await act(async () => {
|
||||
@@ -285,6 +313,7 @@ describe("homepage", () => {
|
||||
expect(container.textContent).not.toContain("Read a little created.");
|
||||
expect(container.querySelector(".ds-habit-chart")).not.toBeNull();
|
||||
await act(async () => buttonNamed("Sign out").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector("#welcome-title")).not.toBeNull();
|
||||
expect((await f.request("/me")).status).toBe(401);
|
||||
});
|
||||
@@ -310,12 +339,14 @@ describe("homepage", () => {
|
||||
expect(container.querySelectorAll('.ds-goal--tasks .ds-routine-items > li')).toHaveLength(1);
|
||||
|
||||
await act(async () => buttonNamed("Increase Water").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3");
|
||||
expect(container.querySelectorAll(".ds-goal-graph .ds-calendar")).toHaveLength(3);
|
||||
expect(countGoal.querySelector(".ds-goal-status")?.textContent).toBe("Complete");
|
||||
expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)?.textContent).toContain("8 of 8 glasses");
|
||||
expect(container.textContent).not.toContain("Sunday walk");
|
||||
await act(async () => buttonNamed("All habits · 4").click());
|
||||
await settleDeferred();
|
||||
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());
|
||||
@@ -323,9 +354,81 @@ describe("homepage", () => {
|
||||
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());
|
||||
await settleDeferred();
|
||||
expect((await f.json("/today")).completed).toBe(2);
|
||||
});
|
||||
|
||||
test("focus retains unchanged calendars, detects remote historical edits and loads expanded history on demand", async () => {
|
||||
const f = connectAccount();
|
||||
f.setTime("2026-09-03T12:00:00Z");
|
||||
const reading = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||
await f.json("/habits", "POST", { name: "Walking", method: "manual" }, 201);
|
||||
f.setTime("2026-09-04T12:00:00Z");
|
||||
await render("/");
|
||||
const calendarRequests = () => fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?"));
|
||||
expect(calendarRequests()).toHaveLength(2);
|
||||
expect(calendarRequests()[0]).toContain("from=2026-07-01");
|
||||
expect(calendarRequests()[0]).toContain("compact=true");
|
||||
const parse = spyOn(Date, "parse");
|
||||
await act(async () => buttonNamed("All habits · 2").click());
|
||||
await settleDeferred();
|
||||
expect(parse).not.toHaveBeenCalled();
|
||||
parse.mockRestore();
|
||||
fetchMock.mockClear();
|
||||
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
|
||||
expect(calendarRequests()).toHaveLength(0);
|
||||
await f.json(`/habits/${reading.id}/days/2026-09-03/progress`, "PUT", { done: true });
|
||||
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
|
||||
expect(calendarRequests()).toHaveLength(1);
|
||||
expect(calendarRequests()[0]).toContain(reading.id);
|
||||
const history = container.querySelector(`#history-${reading.id}`)!;
|
||||
fetchMock.mockClear();
|
||||
await act(async () => history.querySelector<HTMLButtonElement>('[aria-label="12 months"]')!.click());
|
||||
expect(calendarRequests()).toHaveLength(1);
|
||||
expect(calendarRequests()[0]).toContain("from=2025-10-01");
|
||||
expect(history.querySelectorAll(".ds-day")).toHaveLength(365);
|
||||
fetchMock.mockClear();
|
||||
await act(async () => history.querySelector<HTMLButtonElement>('[aria-label="3 months"]')!.click());
|
||||
expect(calendarRequests()).toHaveLength(0);
|
||||
expect(history.querySelectorAll(".ds-day")).toHaveLength(92);
|
||||
expect(history.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
|
||||
fetchMock.mockClear();
|
||||
f.setTime("2026-09-05T12:00:00Z");
|
||||
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
|
||||
expect(calendarRequests()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("month rollover preserves a twelve-month preset and an explicit custom window", async () => {
|
||||
const f = connectAccount();
|
||||
f.setTime("2026-09-30T12:00:00Z");
|
||||
const annual = await f.json("/habits", "POST", { name: "Annual", method: "manual" }, 201);
|
||||
const custom = await f.json("/habits", "POST", { name: "Custom", method: "manual" }, 201);
|
||||
await render("/");
|
||||
const annualHistory = container.querySelector(`#history-${annual.id}`)!;
|
||||
const customHistory = container.querySelector(`#history-${custom.id}`)!;
|
||||
await act(async () => annualHistory.querySelector<HTMLButtonElement>('[aria-label="12 months"]')!.click());
|
||||
await act(async () => [...customHistory.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Custom")!.click());
|
||||
await act(async () => {
|
||||
for (const [name, value] of [["from", "2026-08-05"], ["to", "2026-09-10"]]) {
|
||||
const input = customHistory.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
|
||||
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, value);
|
||||
input.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event);
|
||||
}
|
||||
customHistory.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event);
|
||||
});
|
||||
fetchMock.mockClear();
|
||||
f.setTime("2026-10-01T12:00:00Z");
|
||||
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
|
||||
const requests = fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?"));
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests.find(url => url.includes(annual.id))).toContain("from=2025-11-01&to=2026-10-31");
|
||||
expect(requests.find(url => url.includes(custom.id))).toContain("from=2026-08-05&to=2026-09-10");
|
||||
expect(annualHistory.querySelector(".ds-calendar")?.getAttribute("data-months")).toBe("12");
|
||||
expect(annualHistory.querySelectorAll(".ds-day")).toHaveLength(365);
|
||||
expect(customHistory.querySelector(".ds-calendar")?.getAttribute("data-months")).toBe("custom");
|
||||
expect(customHistory.querySelectorAll(".ds-day")).toHaveLength(37);
|
||||
});
|
||||
|
||||
test("a pending counter save leaves other habits usable and refreshes only its history", async () => {
|
||||
const f = connectAccount();
|
||||
const water = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201);
|
||||
@@ -341,6 +444,7 @@ describe("homepage", () => {
|
||||
}, { preconnect: fetch.preconnect }));
|
||||
fetchMock.mockClear();
|
||||
await act(async () => buttonNamed("Increase Water").click());
|
||||
await settleDeferred();
|
||||
expect(buttonNamed("Increase Water").disabled).toBe(true);
|
||||
const check = container.querySelector<HTMLInputElement>('[aria-label="Reading done"]')!;
|
||||
expect(check.disabled).toBe(false);
|
||||
@@ -367,6 +471,7 @@ describe("homepage", () => {
|
||||
f.setTime("2026-09-04T12:00:00Z");
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Edit habit Water").click());
|
||||
await settleDeferred();
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit habit Water"]')!;
|
||||
expect(document.activeElement).toBe(form.querySelector("input"));
|
||||
await act(async () => {
|
||||
@@ -384,11 +489,15 @@ describe("homepage", () => {
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
expect(container.querySelector("h3")?.textContent).toContain("Daily water");
|
||||
await act(async () => buttonNamed("Archive habit Daily water").click());
|
||||
await settleDeferred();
|
||||
expect((await f.json("/habits")).habits).toHaveLength(1);
|
||||
await act(async () => buttonNamed("Cancel").click());
|
||||
await settleDeferred();
|
||||
expect((await f.json("/habits")).habits).toHaveLength(1);
|
||||
await act(async () => buttonNamed("Archive habit Daily water").click());
|
||||
await settleDeferred();
|
||||
await act(async () => buttonNamed("Archive habit").click());
|
||||
await settleDeferred();
|
||||
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`);
|
||||
@@ -407,6 +516,7 @@ describe("homepage", () => {
|
||||
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());
|
||||
await settleDeferred();
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Stretch"]')!;
|
||||
await act(async () => {
|
||||
const input = form.querySelector("input")!;
|
||||
@@ -419,7 +529,9 @@ describe("homepage", () => {
|
||||
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 settleDeferred();
|
||||
await act(async () => buttonNamed("Delete task").click());
|
||||
await settleDeferred();
|
||||
const today = (await f.json("/today")).habits[0];
|
||||
expect(today.target).toBe(1);
|
||||
expect(today.value).toBe(0);
|
||||
@@ -435,9 +547,11 @@ describe("homepage", () => {
|
||||
await render("/");
|
||||
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
||||
await act(async () => buttonNamed("All habits · 1").click());
|
||||
await settleDeferred();
|
||||
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());
|
||||
await settleDeferred();
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
|
||||
await act(async () => {
|
||||
const input = form.querySelector("input")!;
|
||||
@@ -477,6 +591,7 @@ describe("homepage", () => {
|
||||
{ type: "daily" },
|
||||
] as const) {
|
||||
await act(async () => buttonNamed("Edit task Walk").click());
|
||||
await settleDeferred();
|
||||
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") {
|
||||
@@ -489,6 +604,7 @@ describe("homepage", () => {
|
||||
if (schedule.type === "interval") expect(buttonNamed("All habits · 1").getAttribute("aria-pressed")).toBe("true");
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
await act(async () => buttonNamed("Edit task Walk").click());
|
||||
await settleDeferred();
|
||||
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") {
|
||||
@@ -496,6 +612,7 @@ describe("homepage", () => {
|
||||
expect(reopened.querySelector<HTMLInputElement>('input[type="date"]')?.value).toBe(schedule.anchor);
|
||||
}
|
||||
await act(async () => buttonNamed("Cancel").click());
|
||||
await settleDeferred();
|
||||
}
|
||||
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
|
||||
expect(previous.tasks[0].done).toBe(true);
|
||||
@@ -515,17 +632,20 @@ describe("homepage", () => {
|
||||
};
|
||||
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 settleDeferred();
|
||||
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());
|
||||
await settleDeferred();
|
||||
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());
|
||||
await settleDeferred();
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Add task"]')!;
|
||||
await act(async () => {
|
||||
const input = form.querySelector("input")!;
|
||||
@@ -545,16 +665,19 @@ describe("homepage", () => {
|
||||
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Archive habit Reading").click());
|
||||
await settleDeferred();
|
||||
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("Archive habit").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh");
|
||||
expect(buttonNamed("Archive habit Reading").disabled).toBe(true);
|
||||
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
|
||||
await act(async () => buttonNamed("Try again").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
||||
expect(fetchMock.mock.calls.filter(call => call[1]?.method === "DELETE")).toHaveLength(1);
|
||||
});
|
||||
@@ -565,10 +688,12 @@ describe("homepage", () => {
|
||||
await render("/");
|
||||
fetchMock.mockResolvedValueOnce(Response.json({ error: "Save unavailable" }, { status: 500 }));
|
||||
await act(async () => buttonNamed("Increase Water").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save unavailable");
|
||||
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("0");
|
||||
expect(buttonNamed("Increase Water").disabled).toBe(false);
|
||||
await act(async () => buttonNamed("Increase Water").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("1");
|
||||
expect((await f.json("/today")).habits[0].value).toBe(1);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Navigate, Route, Routes } from "react-router";
|
||||
import { DesignSystem } from "./pages/DesignSystem";
|
||||
import { deferred } from "./components/design-system/deferred";
|
||||
const DesignSystem = deferred(() => import("./pages/DesignSystem").then(module => module.DesignSystem));
|
||||
import { Home } from "./pages/Home";
|
||||
import { AuthProvider } from "./components/AuthProvider";
|
||||
|
||||
|
||||
BIN
src/assets/fonts/InstrumentSerif-Italic.woff2
Normal file
BIN
src/assets/fonts/InstrumentSerif-Italic.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/InstrumentSerif-Regular.woff2
Normal file
BIN
src/assets/fonts/InstrumentSerif-Regular.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/NotoSans-Medium.woff2
Normal file
BIN
src/assets/fonts/NotoSans-Medium.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/NotoSans-Regular.woff2
Normal file
BIN
src/assets/fonts/NotoSans-Regular.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/NotoSans-SemiBold.woff2
Normal file
BIN
src/assets/fonts/NotoSans-SemiBold.woff2
Normal file
Binary file not shown.
@@ -12,3 +12,24 @@ Downloaded September 7, 2026. The files are versioned in this repository and cop
|
||||
into the production image. Browser and native canvas renderers use the alias
|
||||
`Mina Sans`; no system sans-serif font is selected for card text. The native font
|
||||
loader fails startup if a required font cannot be registered.
|
||||
|
||||
Browser WOFF2 copies are lossless conversions of these exact TTF files using
|
||||
FontTools with Brotli; no subsetting is applied. All glyphs and Unicode mappings
|
||||
are retained (3,748 glyphs for each Noto Sans weight, 377 for each Instrument
|
||||
Serif style). Native canvas keeps using the original TTFs. CSS uses separately
|
||||
cacheable `/fonts/browser/*.woff2` URLs so font bytes do not block CSS parsing.
|
||||
|
||||
Regenerate with `uv run --with fonttools --with brotli python`:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from fontTools.ttLib import TTFont
|
||||
for path in Path("src/assets/fonts").glob("*.ttf"):
|
||||
font = TTFont(path)
|
||||
cmap, glyphs = font.getBestCmap(), font.getGlyphOrder()
|
||||
font.flavor = "woff2"
|
||||
font.save(path.with_suffix(".woff2"))
|
||||
restored = TTFont(path.with_suffix(".woff2"))
|
||||
assert restored.getBestCmap() == cmap
|
||||
assert restored.getGlyphOrder() == glyphs
|
||||
```
|
||||
|
||||
14
src/browser-fonts.test.ts
Normal file
14
src/browser-fonts.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { browserFont } from "./browser-fonts";
|
||||
test("browser fonts are external WOFF2 files with conditional caching and a fixed allowlist", async () => {
|
||||
const url = "http://localhost/fonts/browser/InstrumentSerif-Regular.woff2";
|
||||
const response = browserFont(new Request(url));
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("font/woff2");
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
expect(new TextDecoder().decode(bytes.slice(0, 4))).toBe("wOF2");
|
||||
expect(browserFont(new Request(url, { headers: { "If-None-Match": response.headers.get("ETag")! } })).status).toBe(304);
|
||||
expect(browserFont(new Request("http://localhost/fonts/browser/.env")).status).toBe(404);
|
||||
expect(browserFont(new Request("http://localhost/fonts/browser/%2e%2e%2f.env")).status).toBe(404);
|
||||
expect(await browserFont(new Request(url, { method: "HEAD" })).text()).toBe("");
|
||||
});
|
||||
18
src/browser-fonts.ts
Normal file
18
src/browser-fonts.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import path from "node:path";
|
||||
|
||||
const names = ["InstrumentSerif-Regular", "InstrumentSerif-Italic", "NotoSans-Regular", "NotoSans-Medium", "NotoSans-SemiBold"];
|
||||
const directory = process.env.NODE_ENV === "production" ? "fonts/browser" : "src/assets/fonts";
|
||||
const fonts: Map<string, { file: ReturnType<typeof Bun.file>; etag: string }> = new Map(names.map(name => {
|
||||
const file = Bun.file(path.resolve(directory, `${name}.woff2`));
|
||||
return [`${name}.woff2`, { file, etag: `"${file.size.toString(16)}-${file.lastModified.toString(16)}"` }] as const;
|
||||
}));
|
||||
|
||||
/** Explicit allowlist: font URLs never grant access to other filesystem paths. */
|
||||
export function browserFont(request: Request): Response {
|
||||
const name = new URL(request.url).pathname.slice("/fonts/browser/".length);
|
||||
const font = fonts.get(name);
|
||||
if (!font || !["GET", "HEAD"].includes(request.method)) return new Response(null, { status: 404 });
|
||||
const headers = { "Content-Type": "font/woff2", "Cache-Control": "public, max-age=3600", ETag: font.etag };
|
||||
if (request.headers.get("If-None-Match") === font.etag) return new Response(null, { status: 304, headers });
|
||||
return new Response(request.method === "HEAD" ? null : font.file, { headers });
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
|
||||
<Button variant="text" disabled={busy || loading} aria-label={`Delete permanently ${habit.name}`} aria-haspopup="dialog" onClick={() => { setDeleteError(""); setDeleting(habit); }}><Trash2 size={16} aria-hidden="true" />Delete permanently</Button>
|
||||
</div>
|
||||
</header>
|
||||
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
|
||||
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={habit.calendarRevision ?? revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
|
||||
</article>)}
|
||||
</div>
|
||||
{deleting && <Modal id="delete-archived-habit" size="compact" eyebrow="PERMANENT DELETION" title="Delete this habit?" description={`“${deleting.name ?? "Habit"}” and all its check-ins, tasks, and progress history will be permanently deleted. It will be removed from combined charts; charts with no remaining habits will also be deleted. This cannot be undone.`} busy={busy} initialFocus="[data-cancel-delete]" returnFocus={() => archiveFocus.current} onClose={() => setDeleting(null)}>
|
||||
|
||||
27
src/components/CalendarCache.test.ts
Normal file
27
src/components/CalendarCache.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { afterAll, afterEach, expect, spyOn, test } from "bun:test";
|
||||
import { CalendarCache } from "./CalendarCache";
|
||||
const fetchSpy = spyOn(globalThis, "fetch");
|
||||
afterEach(() => fetchSpy.mockReset());
|
||||
afterAll(() => fetchSpy.mockRestore());
|
||||
const range = { from: "2026-07-01", to: "2026-09-30" };
|
||||
test("calendar cache shares in-flight reads, retains wider history and invalidates changed versions", async () => {
|
||||
fetchSpy.mockImplementation(Object.assign(async () => Response.json({ from: range.from, to: range.to, days: [{ date: "2026-07-01" }, { date: "2026-09-01" }] }), { preconnect: fetch.preconnect }));
|
||||
const cache = new CalendarCache();
|
||||
await Promise.all([cache.load("habit", "v1", range), cache.load("habit", "v1", range)]);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchSpy.mock.calls[0]![0])).toContain("compact=true");
|
||||
const subset = await cache.load("habit", "v1", { from: "2026-09-01", to: range.to });
|
||||
expect(subset.days).toHaveLength(1);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
await cache.load("habit", "v2", range);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
await new CalendarCache().load("habit", "v2", range);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
test("a failed history request can be retried without retaining a rejected cache entry", async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new Error("offline")).mockResolvedValue(Response.json({ ...range, days: [] }));
|
||||
const cache = new CalendarCache();
|
||||
await expect(cache.load("habit", "v1", range)).rejects.toThrow("offline");
|
||||
await expect(cache.load("habit", "v1", range)).resolves.toMatchObject({ days: [] });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
34
src/components/CalendarCache.tsx
Normal file
34
src/components/CalendarCache.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createContext, useContext, useRef, type ReactNode } from "react";
|
||||
import type { CalendarResponse } from "../shared/calendar";
|
||||
import { habitRequest } from "../lib/dashboard";
|
||||
|
||||
type Range = { from: string; to: string };
|
||||
type Entry = Range & { version: string; value: Promise<CalendarResponse> };
|
||||
/** Account-scoped, bounded cache; never survives signing out or changing accounts. */
|
||||
export class CalendarCache {
|
||||
private entries = new Map<string, Entry[]>();
|
||||
load(habitId: string, version: string, range: Range) {
|
||||
const entries = (this.entries.get(habitId) ?? []).filter(entry => entry.version === version);
|
||||
const cached = entries.find(entry => entry.from <= range.from && entry.to >= range.to);
|
||||
if (cached) return cached.value.then(value => ({ ...value, ...range, days: value.days.filter(day => day.date >= range.from && day.date <= range.to) }));
|
||||
const entry: Entry = { ...range, version, value: habitRequest<CalendarResponse>(`/habits/${habitId}/calendar?${new URLSearchParams({ ...range, compact: "true" })}`) };
|
||||
entries.push(entry);
|
||||
this.entries.delete(habitId);
|
||||
this.entries.set(habitId, entries.slice(-8));
|
||||
if (this.entries.size > 100) this.entries.delete(this.entries.keys().next().value!);
|
||||
entry.value.catch(() => { const current = this.entries.get(habitId); if (current) this.entries.set(habitId, current.filter(item => item !== entry)); });
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
const Context = createContext<CalendarCache | null>(null);
|
||||
export function CalendarCacheProvider({ children }: { children: ReactNode }) {
|
||||
const cache = useRef<CalendarCache | null>(null);
|
||||
cache.current ??= new CalendarCache();
|
||||
return <Context.Provider value={cache.current}>{children}</Context.Provider>;
|
||||
}
|
||||
export function useCalendarCache() {
|
||||
const shared = useContext(Context);
|
||||
const local = useRef<CalendarCache | null>(null);
|
||||
local.current ??= new CalendarCache();
|
||||
return shared ?? local.current;
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useCalendarCache } from "./CalendarCache";
|
||||
import { Modal } from "./design-system/Modal";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, 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";
|
||||
import { monthWindow } from "./design-system/calendar-model";
|
||||
import { monthWindow, type MonthView } from "./design-system/calendar-model";
|
||||
import { Button } from "./design-system/primitives";
|
||||
import { HabitChart } from "./design-system/HabitChart";
|
||||
import { CalendarLegend } from "./design-system/CalendarLegend";
|
||||
import { shade } from "../habits/calendar";
|
||||
import { CheckInEditor } from "./CheckInEditor";
|
||||
import { deferred } from "./design-system/deferred";
|
||||
const CheckInEditor = deferred(() => import("./CheckInEditor").then(module => module.CheckInEditor));
|
||||
import { Field } from "./design-system/Field";
|
||||
import { ItemActions } from "./design-system/ItemActions";
|
||||
|
||||
@@ -40,7 +42,29 @@ export function HabitHistory({
|
||||
onHistorySaved?: () => void;
|
||||
calendarOnly?: boolean;
|
||||
}) {
|
||||
const cache = useCalendarCache();
|
||||
const [rangeSelection, setRangeSelection] = useState(() => ({ date, months: 3 as MonthView | "custom", range: monthWindow(date, 3) }));
|
||||
const range = useMemo(() => {
|
||||
if (rangeSelection.date === date || rangeSelection.months === "custom") return rangeSelection.range;
|
||||
// A preset follows the current month across midnight; an explicitly inspected
|
||||
// older window and custom dates retain the user's chosen historical period.
|
||||
const previousCurrent = monthWindow(rangeSelection.date, rangeSelection.months);
|
||||
const anchor = rangeSelection.range.to < previousCurrent.to ? rangeSelection.range.to : date;
|
||||
return monthWindow(anchor, rangeSelection.months);
|
||||
}, [date, rangeSelection]);
|
||||
function requestRange(next: { from: string; to: string }, months: MonthView | "custom" = rangeSelection.months) {
|
||||
setRangeSelection({ date, months, range: next });
|
||||
}
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<string>();
|
||||
function selectDate(selected: string) {
|
||||
setSelectedDate(selected);
|
||||
const available = monthWindow(date, 12);
|
||||
if (rangeSelection.months !== "custom" && selected >= available.from && selected <= available.to && (selected < range.from || selected > range.to)) {
|
||||
const requested = monthWindow(selected, rangeSelection.months);
|
||||
requestRange({ from: requested.from < available.from ? available.from : requested.from, to: requested.to });
|
||||
}
|
||||
}
|
||||
const [editingDay, setEditingDay] = useState(false);
|
||||
const [savingDay, setSavingDay] = useState(false);
|
||||
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
|
||||
@@ -49,26 +73,20 @@ export function HabitHistory({
|
||||
const expired = useRef(onExpired);
|
||||
expired.current = onExpired;
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let active = true;
|
||||
setError("");
|
||||
const range = monthWindow(date, 12);
|
||||
habitRequest<CalendarResponse>(
|
||||
`/habits/${habit.habitId}/calendar?${new URLSearchParams(range)}`,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((result) => {
|
||||
if (!controller.signal.aborted) setCalendar(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (error.message.includes("session has expired") && expired.current)
|
||||
expired.current();
|
||||
setHistoryLoading(true);
|
||||
cache.load(habit.habitId, `${date}:${revision}:${attempt}`, range)
|
||||
.then(result => { if (active) setCalendar(result); })
|
||||
.catch(error => {
|
||||
if (!active) return;
|
||||
if (error.message.includes("session has expired") && expired.current) expired.current();
|
||||
else setError(error.message);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [habit.habitId, date, revision, attempt]);
|
||||
}).finally(() => { if (active) setHistoryLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, [cache, habit.habitId, date, revision, attempt, range.from, range.to]);
|
||||
|
||||
const days =
|
||||
const days = useMemo(() =>
|
||||
calendar?.days.map((day) => ({
|
||||
date: day.date,
|
||||
color: day.color,
|
||||
@@ -80,14 +98,14 @@ export function HabitHistory({
|
||||
: day.due
|
||||
? ("due" as const)
|
||||
: ("not-due" as const),
|
||||
})) ?? [];
|
||||
})) ?? [], [calendar]);
|
||||
const steps = Math.max(
|
||||
1,
|
||||
calendar?.days.find((day) => day.date === date)?.shadeCount ??
|
||||
habit.target ??
|
||||
1,
|
||||
);
|
||||
const shades = calendar
|
||||
const shades = useMemo(() => calendar
|
||||
? [
|
||||
calendar.settings.emptyColor,
|
||||
...Array.from(
|
||||
@@ -101,7 +119,7 @@ export function HabitHistory({
|
||||
).color,
|
||||
),
|
||||
]
|
||||
: [];
|
||||
: [], [calendar, steps]);
|
||||
const chart = error ? (
|
||||
<div className="ds-form-feedback">
|
||||
<p role="alert">{error}</p>
|
||||
@@ -119,17 +137,20 @@ export function HabitHistory({
|
||||
) : (
|
||||
<>
|
||||
<CalendarHeatmap
|
||||
onSelectDate={setSelectedDate}
|
||||
onSelectDate={selectDate}
|
||||
selectedDate={selectedDate}
|
||||
compact
|
||||
initialMonths={3}
|
||||
availableRange={monthWindow(date, 12)}
|
||||
onRangeChange={requestRange}
|
||||
loading={historyLoading}
|
||||
historyLabel="Your recorded progress"
|
||||
label={`${habit.name} progress calendar`}
|
||||
unit={habit.unit}
|
||||
color={calendar.settings.mainColor}
|
||||
emptyColor={calendar.settings.emptyColor}
|
||||
days={days}
|
||||
selectedDayAction={selected => <Button variant="secondary" disabled={disabled} aria-label="Edit selected check-in" aria-haspopup="dialog" onClick={() => { setSelectedDate(selected); setEditingDay(true); }}><Pencil size={16} aria-hidden="true" />Edit check-in</Button>}
|
||||
selectedDayAction={selected => <Button variant="secondary" disabled={disabled} aria-label="Edit selected check-in" aria-haspopup="dialog" onClick={() => { selectDate(selected); setEditingDay(true); }}><Pencil size={16} aria-hidden="true" />Edit check-in</Button>}
|
||||
legend={
|
||||
<CalendarLegend
|
||||
label={`${habit.name} progress calendar`}
|
||||
@@ -141,7 +162,7 @@ export function HabitHistory({
|
||||
/>
|
||||
{editingDay && <Modal id={`check-in-${habit.habitId}`} title="Edit check-in" description={habit.name ?? "Habit"} closeLabel="Close check-in editor" onClose={() => setEditingDay(false)} busy={savingDay}>
|
||||
<div className="ds-history-editor">
|
||||
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" disabled={savingDay} max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) setSelectedDate(event.target.value); }} />}</Field></div>
|
||||
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" disabled={savingDay} max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) selectDate(event.target.value); }} />}</Field></div>
|
||||
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
|
||||
onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
|
||||
</div></Modal>}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
import { habitProgressColor, themedHabitShade } from "../../lib/habit-palette";
|
||||
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { useId, useMemo, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { CalendarLegend } from "./CalendarLegend";
|
||||
import { Field } from "./Field";
|
||||
import { Button } from "./primitives";
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
calendarTimeline,
|
||||
MONTH_VIEWS,
|
||||
visibleCalendarDays,
|
||||
monthWindow,
|
||||
type CalendarDay,
|
||||
type MonthView,
|
||||
} from "./calendar-model";
|
||||
@@ -26,7 +27,13 @@ export function CalendarHeatmap({
|
||||
historyLabel,
|
||||
legend,
|
||||
selectedDayAction,
|
||||
availableRange,
|
||||
onRangeChange,
|
||||
loading = false,
|
||||
}: {
|
||||
availableRange?: { from: string; to: string };
|
||||
onRangeChange?: (range: { from: string; to: string }, months: MonthView | "custom") => void;
|
||||
loading?: boolean;
|
||||
days: CalendarDay[];
|
||||
unit: string;
|
||||
label: string;
|
||||
@@ -65,15 +72,15 @@ export function CalendarHeatmap({
|
||||
if (onSelectDate) onSelectDate(date);
|
||||
else setLocalSelectedDate(date);
|
||||
}
|
||||
const days = months === "custom" && customRange
|
||||
const days = useMemo(() => months === "custom" && customRange
|
||||
? allDays.filter((day) => day.date >= customRange.from && day.date <= customRange.to)
|
||||
: anchor ? visibleCalendarDays(allDays, months === "custom" ? 12 : months, anchor) : [];
|
||||
: anchor ? visibleCalendarDays(allDays, months === "custom" ? 12 : months, anchor) : [], [allDays, months, customRange, anchor]);
|
||||
const selected =
|
||||
days.find((day) => day.date === selectedDate) ??
|
||||
days.find((day) => day.date === anchor) ??
|
||||
days[0];
|
||||
// Date keys and the shared timeline remain stable when the chart resizes.
|
||||
const timeline = calendarTimeline(days);
|
||||
const timeline = useMemo(() => calendarTimeline(days), [days]);
|
||||
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const inspectorId = useId();
|
||||
const instructionsId = useId();
|
||||
@@ -100,6 +107,7 @@ export function CalendarHeatmap({
|
||||
<div
|
||||
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
|
||||
data-months={months}
|
||||
aria-busy={loading}
|
||||
style={{ "--calendar-weeks": timeline.weeks, "--calendar-color": color } as CSSProperties}
|
||||
>
|
||||
<div className="ds-calendar-range-toolbar">
|
||||
@@ -130,7 +138,7 @@ export function CalendarHeatmap({
|
||||
type="button"
|
||||
aria-label={`${value} ${value === 1 ? "month" : "months"}`}
|
||||
aria-pressed={months === value}
|
||||
onClick={() => setMonths(value)}
|
||||
onClick={() => { setMonths(value); if (anchor) onRangeChange?.(monthWindow(anchor, value), value); }}
|
||||
>
|
||||
{value}m
|
||||
</button>
|
||||
@@ -142,6 +150,7 @@ export function CalendarHeatmap({
|
||||
if (!customRange) setCustomRange({ from: days[0]!.date, to: days.at(-1)!.date });
|
||||
setRangeError("");
|
||||
setMonths("custom");
|
||||
onRangeChange?.(customRange ?? { from: days[0]!.date, to: days.at(-1)!.date }, "custom");
|
||||
}}
|
||||
>
|
||||
Custom
|
||||
@@ -160,7 +169,7 @@ export function CalendarHeatmap({
|
||||
const end = form.elements.namedItem("to") as HTMLInputElement;
|
||||
const isAvailable = (input: HTMLInputElement) =>
|
||||
input.validity.valid && input.value !== "" &&
|
||||
input.value >= allDays[0]!.date && input.value <= allDays.at(-1)!.date;
|
||||
input.value >= (availableRange?.from ?? allDays[0]!.date) && input.value <= (availableRange?.to ?? allDays.at(-1)!.date);
|
||||
if (!isAvailable(start) || !isAvailable(end)) {
|
||||
setRangeError("Choose both dates within the available history.");
|
||||
(!isAvailable(start) ? start : end).focus();
|
||||
@@ -171,11 +180,12 @@ export function CalendarHeatmap({
|
||||
end.focus();
|
||||
return;
|
||||
}
|
||||
if (!allDays.some((day) => day.date >= start.value && day.date <= end.value)) {
|
||||
if (!availableRange && !allDays.some((day) => day.date >= start.value && day.date <= end.value)) {
|
||||
setRangeError("There are no calendar dates in this range.");
|
||||
return;
|
||||
}
|
||||
setCustomRange({ from: start.value, to: end.value });
|
||||
onRangeChange?.({ from: start.value, to: end.value }, "custom");
|
||||
setRangeError("");
|
||||
}}
|
||||
>
|
||||
@@ -187,8 +197,8 @@ export function CalendarHeatmap({
|
||||
name={name}
|
||||
type="date"
|
||||
required
|
||||
min={allDays[0]!.date}
|
||||
max={allDays.at(-1)!.date}
|
||||
min={availableRange?.from ?? allDays[0]!.date}
|
||||
max={availableRange?.to ?? allDays.at(-1)!.date}
|
||||
defaultValue={customRange[name]}
|
||||
aria-invalid={rangeError ? true : undefined}
|
||||
aria-describedby={rangeError ? rangeErrorId : undefined}
|
||||
@@ -201,6 +211,7 @@ export function CalendarHeatmap({
|
||||
{rangeError && <p id={rangeErrorId} className="ds-calendar-range-error" role="alert">{rangeError}</p>}
|
||||
</form>
|
||||
)}
|
||||
{loading && <p role="status" className="ds-supporting-copy">Loading history…</p>}
|
||||
<div
|
||||
className="ds-calendar-viewport"
|
||||
role="group"
|
||||
|
||||
9
src/components/design-system/deferred.tsx
Normal file
9
src/components/design-system/deferred.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { lazy, Suspense, type ComponentProps, type ComponentType } from "react";
|
||||
|
||||
/** Keep the surrounding workspace usable while an optional surface loads. */
|
||||
export function deferred<Component extends ComponentType<any>>(load: () => Promise<Component>) {
|
||||
const Surface = lazy(async () => ({ default: await load() })) as ComponentType<ComponentProps<Component>>;
|
||||
return function Deferred(props: ComponentProps<Component>) {
|
||||
return <Suspense fallback={<p className="ds-supporting-copy" role="status">Loading…</p>}><Surface {...props} /></Suspense>;
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { browserFont } from "./browser-fonts";
|
||||
import { startReminders } from "./reminders/worker";
|
||||
import { backupConfig, startBackups } from "./ops/backups";
|
||||
import { databasePath } from "./db/config";
|
||||
@@ -29,6 +30,7 @@ const server = Bun.serve({
|
||||
hostname: process.env.HOST ?? "127.0.0.1",
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
routes: {
|
||||
"/fonts/browser/*": browserFont,
|
||||
"/api": req => app.fetch(req),
|
||||
"/api/*": req => app.fetch(req),
|
||||
"/*": index,
|
||||
|
||||
@@ -46,3 +46,15 @@ export function formatTrackingDate(date: string) {
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
/** Preserve unchanged row references when a background refresh returns new JSON objects. */
|
||||
export function reconcileToday(current: TodayResponse | null, next: TodayResponse): TodayResponse {
|
||||
if (!current || current.date !== next.date || current.timezone !== next.timezone) return next;
|
||||
const previous = new Map(current.habits.map(habit => [habit.habitId, habit]));
|
||||
const habits = next.habits.map(habit => {
|
||||
const old = previous.get(habit.habitId);
|
||||
return old && JSON.stringify(old) === JSON.stringify(habit) ? old : habit;
|
||||
});
|
||||
if (current.due === next.due && current.completed === next.completed && habits.length === current.habits.length && habits.every((habit, index) => habit === current.habits[index])) return current;
|
||||
return { ...next, habits };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { deferred } from "../components/design-system/deferred";
|
||||
import { CalendarCacheProvider } from "../components/CalendarCache";
|
||||
import { Archive, Plus, Settings2 } from "lucide-react";
|
||||
import { ArchivedHabits } from "../components/ArchivedHabits";
|
||||
import { AccountSettings } from "../components/AccountSettings";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
const ArchivedHabits = deferred(() => import("../components/ArchivedHabits").then(module => module.ArchivedHabits));
|
||||
const AccountSettings = deferred(() => import("../components/AccountSettings").then(module => module.AccountSettings));
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "../components/AuthProvider";
|
||||
import { DiscordSignInButton } from "../components/DiscordSignInButton";
|
||||
import { PageLayout } from "../components/design-system/PageLayout";
|
||||
@@ -15,17 +17,17 @@ import {
|
||||
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";
|
||||
const HabitForm = deferred(() => import("../components/HabitForm").then(module => module.HabitForm));
|
||||
import { habitRequest, reconcileToday, 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";
|
||||
const InlineHabitEditor = deferred(() => import("../components/InlineHabitEditor").then(module => module.InlineHabitEditor));
|
||||
const InlineTaskEditor = deferred(() => import("../components/InlineTaskEditor").then(module => module.InlineTaskEditor));
|
||||
import type { Schedule } from "../habits/contracts";
|
||||
import { ShareProgress } from "../components/ShareProgress";
|
||||
const ShareProgress = deferred(() => import("../components/ShareProgress").then(module => module.ShareProgress));
|
||||
|
||||
export function Home() {
|
||||
const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();
|
||||
@@ -63,7 +65,7 @@ export function Home() {
|
||||
</Card>
|
||||
</section>
|
||||
) : user ? (
|
||||
<Dashboard key={user.id} user={user} onExpired={retry} />
|
||||
<CalendarCacheProvider key={`${user.id}:${user.timezone}`}><Dashboard user={user} onExpired={retry} /></CalendarCacheProvider>
|
||||
) : (
|
||||
<Welcome onSignIn={signIn} />
|
||||
)}
|
||||
@@ -161,9 +163,10 @@ function Welcome({ onSignIn }: { onSignIn: () => void }) {
|
||||
|
||||
function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => void }) {
|
||||
const [today, setToday] = useState<TodayResponse | null>(null);
|
||||
const todaySnapshot = useRef(today);
|
||||
todaySnapshot.current = today;
|
||||
const [error, setError] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [calendarRevision, setCalendarRevision] = useState(0);
|
||||
const [habitRevisions, setHabitRevisions] = useState<Record<string, number>>({});
|
||||
const [pendingHabits, setPendingHabits] = useState<Set<string>>(new Set());
|
||||
const progressSaves = useRef(new Set<string>());
|
||||
@@ -203,10 +206,10 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
habitRequest<TodayResponse>("/today", { signal: controller.signal })
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setToday(data);
|
||||
const next = reconcileToday(todaySnapshot.current, data);
|
||||
setToday(next);
|
||||
setNeedsRefresh(false);
|
||||
setRevision((value) => value + 1);
|
||||
setCalendarRevision((value) => value + 1);
|
||||
if (next !== todaySnapshot.current) setRevision((value) => value + 1);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -302,7 +305,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
try {
|
||||
const refreshed = await habitRequest<TodayResponse>("/today");
|
||||
if (mounted.current) {
|
||||
setToday(refreshed);
|
||||
setToday(current => reconcileToday(current, refreshed));
|
||||
// Keep a habit reachable when an edit makes it unscheduled (including an empty routine).
|
||||
if (refreshed.habits.some(item => item.habitId === habit.habitId && !item.due)) setHabitView("all");
|
||||
setNeedsRefresh(false);
|
||||
@@ -337,6 +340,13 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
}
|
||||
}
|
||||
|
||||
const actions = useRef({ update, manage });
|
||||
actions.current = { update, manage };
|
||||
const updateHabit = useCallback((...args: Parameters<typeof update>) => actions.current.update(...args), []);
|
||||
const manageHabit = useCallback((...args: Parameters<typeof manage>) => actions.current.manage(...args), []);
|
||||
const refreshHistory = useCallback(() => setAttempt(value => value + 1), []);
|
||||
const expire = useCallback(() => expired.current(), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WelcomePanel
|
||||
@@ -457,12 +467,12 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
key={habit.habitId}
|
||||
habit={habit}
|
||||
date={today.date}
|
||||
revision={calendarRevision + (habitRevisions[habit.habitId] ?? 0)}
|
||||
revision={habit.calendarRevision ?? habitRevisions[habit.habitId] ?? 0}
|
||||
disabled={busy || pendingHabits.has(habit.habitId) || loading || needsRefresh}
|
||||
onHistorySaved={() => setAttempt(value => value + 1)}
|
||||
onUpdate={update}
|
||||
onManage={manage}
|
||||
onExpired={() => expired.current()}
|
||||
onHistorySaved={refreshHistory}
|
||||
onUpdate={updateHabit}
|
||||
onManage={manageHabit}
|
||||
onExpired={expire}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -474,7 +484,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
);
|
||||
}
|
||||
|
||||
function SavedHabit({
|
||||
const SavedHabit = memo(function SavedHabit({
|
||||
habit,
|
||||
date,
|
||||
revision,
|
||||
@@ -604,7 +614,7 @@ function SavedHabit({
|
||||
) : undefined}
|
||||
</HabitHistory>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function SavedTask({
|
||||
task,
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
@font-face {
|
||||
font-family: "Mina Sans";
|
||||
src: url("../src/assets/fonts/NotoSans-Regular.ttf") format("truetype");
|
||||
src: url("/fonts/browser/NotoSans-Regular.woff2") format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mina Sans";
|
||||
src: url("../src/assets/fonts/NotoSans-Medium.ttf") format("truetype");
|
||||
src: url("/fonts/browser/NotoSans-Medium.woff2") format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mina Sans";
|
||||
src: url("../src/assets/fonts/NotoSans-SemiBold.ttf") format("truetype");
|
||||
src: url("/fonts/browser/NotoSans-SemiBold.woff2") format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Instrument Serif";
|
||||
src: url("../src/assets/fonts/InstrumentSerif-Regular.ttf") format("truetype");
|
||||
src: url("/fonts/browser/InstrumentSerif-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Instrument Serif";
|
||||
src: url("../src/assets/fonts/InstrumentSerif-Italic.ttf") format("truetype");
|
||||
src: url("/fonts/browser/InstrumentSerif-Italic.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
|
||||
Reference in New Issue
Block a user