Merge frontend performance improvements

This commit is contained in:
syntaxbullet
2026-09-08 16:35:45 +02:00
24 changed files with 388 additions and 66 deletions

View File

@@ -10,8 +10,9 @@ const result = await Bun.build({
outdir, outdir,
plugins: [tailwind], plugins: [tailwind],
minify: true, minify: true,
splitting: true,
target: "bun", target: "bun",
external: ["@napi-rs/canvas"], external: ["@napi-rs/canvas", "/fonts/browser/*"],
sourcemap: "linked", sourcemap: "linked",
define: { define: {
"process.env.NODE_ENV": JSON.stringify("production"), "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"]) { 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 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`));
}

View File

@@ -3,5 +3,5 @@
env = false env = false
[serve.static] [serve.static]
plugins = ["bun-plugin-tailwind"] plugins = ["bun-plugin-tailwind", "./scripts/browser-fonts-plugin.ts"]
env = "BUN_PUBLIC_*" env = "BUN_PUBLIC_*"

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

View File

@@ -1,3 +1,4 @@
import { browserFont } from "../src/browser-fonts";
// Isolated browser QA server. Never opens or changes the user's database. // Isolated browser QA server. Never opens or changes the user's database.
import { sendDueReminders } from "../src/reminders/worker"; import { sendDueReminders } from "../src/reminders/worker";
import type { DiscordFetch } from "../src/sharing/routes"; import type { DiscordFetch } from "../src/sharing/routes";
@@ -55,6 +56,7 @@ const server = Bun.serve({
hostname: "127.0.0.1", hostname: "127.0.0.1",
port, port,
routes: { routes: {
"/fonts/browser/*": browserFont,
"/__preview/reminder": () => new Response(reminderMessage ?? "No reminder sent"), "/__preview/reminder": () => new Response(reminderMessage ?? "No reminder sent"),
"/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }), "/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }),
"/__preview/login": () => "/__preview/login": () =>

View File

@@ -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") { async function render(path = "/design-system") {
await act(async () => await act(async () =>
root.render( root.render(
@@ -100,6 +106,7 @@ async function render(path = "/design-system") {
</MemoryRouter>, </MemoryRouter>,
), ),
); );
await settleDeferred();
} }
function connectAccount() { function connectAccount() {
@@ -131,6 +138,7 @@ describe("homepage", () => {
const opener = buttonNamed("Settings"); const opener = buttonNamed("Settings");
opener.focus(); opener.focus();
await act(async () => opener.click()); await act(async () => opener.click());
await settleDeferred();
const dialog = container.querySelector<HTMLDialogElement>("dialog#account-settings")!; const dialog = container.querySelector<HTMLDialogElement>("dialog#account-settings")!;
expect(dialog.open).toBe(true); expect(dialog.open).toBe(true);
expect(dialog.contains(document.activeElement)).toBe(true); expect(dialog.contains(document.activeElement)).toBe(true);
@@ -158,6 +166,7 @@ describe("homepage", () => {
expect(inner.open).toBe(true); expect(inner.open).toBe(true);
expect(inner.querySelector<HTMLButtonElement>('[aria-label="Close dialog"]')?.disabled).toBe(true); expect(inner.querySelector<HTMLButtonElement>('[aria-label="Close dialog"]')?.disabled).toBe(true);
await act(async () => buttonNamed("Finish save").click()); await act(async () => buttonNamed("Finish save").click());
await settleDeferred();
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event)); await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
expect(container.querySelector("#inner")).toBeNull(); expect(container.querySelector("#inner")).toBeNull();
expect(container.querySelector<HTMLDialogElement>("#outer")?.open).toBe(true); expect(container.querySelector<HTMLDialogElement>("#outer")?.open).toBe(true);
@@ -175,10 +184,13 @@ describe("homepage", () => {
await f.request(`/habits/${h.id}`, "DELETE"); await f.request(`/habits/${h.id}`, "DELETE");
await render("/"); await render("/");
await act(async () => buttonNamed("Archived habits").click()); await act(async () => buttonNamed("Archived habits").click());
await settleDeferred();
expect(buttonNamed("Restore Reading")).toBeDefined(); expect(buttonNamed("Restore Reading")).toBeDefined();
await act(async () => buttonNamed("View history").click()); await act(async () => buttonNamed("View history").click());
await settleDeferred();
expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull(); expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
await act(async () => buttonNamed("Restore Reading").click()); await act(async () => buttonNamed("Restore Reading").click());
await settleDeferred();
expect(container.textContent).toContain("No archived habits."); expect(container.textContent).toContain("No archived habits.");
expect((await f.json("/today")).habits[0].name).toBe("Reading"); expect((await f.json("/today")).habits[0].name).toBe("Reading");
expect((await f.json(`/habits/${h.id}/days/2026-09-03`)).complete).toBe(true); 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 f.request(`/habits/${habit.id}`, "DELETE");
await render("/"); await render("/");
await act(async () => buttonNamed("Archived habits").click()); await act(async () => buttonNamed("Archived habits").click());
await settleDeferred();
await act(async () => buttonNamed("Delete permanently Discard me").click()); await act(async () => buttonNamed("Delete permanently Discard me").click());
await settleDeferred();
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("This cannot be undone."); expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("This cannot be undone.");
expect(document.activeElement).toBe(buttonNamed("Keep habit")); expect(document.activeElement).toBe(buttonNamed("Keep habit"));
await act(async () => buttonNamed("Keep habit").click()); await act(async () => buttonNamed("Keep habit").click());
await settleDeferred();
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200); expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
await act(async () => buttonNamed("Delete permanently Discard me").click()); 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")!; const confirm = () => container.querySelector<HTMLButtonElement>("#delete-archived-habit .ds-action-row button:last-child")!;
fetchMock.mockResolvedValueOnce(Response.json({ error: "Temporary failure" }, { status: 500 })); fetchMock.mockResolvedValueOnce(Response.json({ error: "Temporary failure" }, { status: 500 }));
await act(async () => confirm().click()); 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 () => { test("historical check-ins use the selected date's tracking method and preserve today", async () => {
const f = connectAccount(); 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); const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
f.setTime("2026-09-04T12:00:00Z"); f.setTime("2026-09-04T12:00:00Z");
await f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" }); 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()); 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"); 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 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")!; const past = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!;
expect(past).toBeDefined(); expect(past).toBeDefined();
await act(async () => past.click()); 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-03`)).complete).toBe(true);
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).value).toBe(0); 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"); 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 () => { 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(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm.");
expect(fetchMock.mock.calls.map(call => call[0])).toEqual(["/api/me"]); expect(fetchMock.mock.calls.map(call => call[0])).toEqual(["/api/me"]);
await act(async () => buttonNamed("Increase glasses of water").click()); 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(container.querySelector<HTMLInputElement>('[aria-label="Total glasses of water"]')?.value).toBe("4");
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
}); });
@@ -264,6 +290,7 @@ describe("homepage", () => {
expect(container.textContent).toContain("Account unavailable"); expect(container.textContent).toContain("Account unavailable");
expect(container.querySelector("#welcome-title")).toBeNull(); expect(container.querySelector("#welcome-title")).toBeNull();
await act(async () => buttonNamed("Try again").click()); await act(async () => buttonNamed("Try again").click());
await settleDeferred();
expect(container.querySelector("#welcome-title")).not.toBeNull(); expect(container.querySelector("#welcome-title")).not.toBeNull();
}); });
@@ -273,6 +300,7 @@ describe("homepage", () => {
expect(container.textContent).toContain("Welcome back, alice."); expect(container.textContent).toContain("Welcome back, alice.");
expect(container.textContent).toContain("Start with one habit."); expect(container.textContent).toContain("Start with one habit.");
await act(async () => buttonNamed("Add a habit +").click()); await act(async () => buttonNamed("Add a habit +").click());
await settleDeferred();
const input = container.querySelector<HTMLInputElement>("form input")!; const input = container.querySelector<HTMLInputElement>("form input")!;
expect(document.activeElement).toBe(input); expect(document.activeElement).toBe(input);
await act(async () => { await act(async () => {
@@ -285,6 +313,7 @@ describe("homepage", () => {
expect(container.textContent).not.toContain("Read a little created."); expect(container.textContent).not.toContain("Read a little created.");
expect(container.querySelector(".ds-habit-chart")).not.toBeNull(); expect(container.querySelector(".ds-habit-chart")).not.toBeNull();
await act(async () => buttonNamed("Sign out").click()); await act(async () => buttonNamed("Sign out").click());
await settleDeferred();
expect(container.querySelector("#welcome-title")).not.toBeNull(); expect(container.querySelector("#welcome-title")).not.toBeNull();
expect((await f.request("/me")).status).toBe(401); 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); expect(container.querySelectorAll('.ds-goal--tasks .ds-routine-items > li')).toHaveLength(1);
await act(async () => buttonNamed("Increase Water").click()); await act(async () => buttonNamed("Increase Water").click());
await settleDeferred();
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3"); expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3");
expect(container.querySelectorAll(".ds-goal-graph .ds-calendar")).toHaveLength(3); expect(container.querySelectorAll(".ds-goal-graph .ds-calendar")).toHaveLength(3);
expect(countGoal.querySelector(".ds-goal-status")?.textContent).toBe("Complete"); 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.querySelector(`#history-${water.id} .ds-date-inspector`)?.textContent).toContain("8 of 8 glasses");
expect(container.textContent).not.toContain("Sunday walk"); expect(container.textContent).not.toContain("Sunday walk");
await act(async () => buttonNamed("All habits · 4").click()); 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)!; 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); expect(checkbox("Sunday walk done").disabled).toBe(true);
await act(async () => checkbox("Reading done").click()); await act(async () => checkbox("Reading done").click());
@@ -323,9 +354,81 @@ describe("homepage", () => {
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("3 / 3"); expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("3 / 3");
expect((await f.json("/today")).completed).toBe(3); expect((await f.json("/today")).completed).toBe(3);
await act(async () => buttonNamed("Decrease Water").click()); await act(async () => buttonNamed("Decrease Water").click());
await settleDeferred();
expect((await f.json("/today")).completed).toBe(2); 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 () => { test("a pending counter save leaves other habits usable and refreshes only its history", async () => {
const f = connectAccount(); const f = connectAccount();
const water = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201); 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 })); }, { preconnect: fetch.preconnect }));
fetchMock.mockClear(); fetchMock.mockClear();
await act(async () => buttonNamed("Increase Water").click()); await act(async () => buttonNamed("Increase Water").click());
await settleDeferred();
expect(buttonNamed("Increase Water").disabled).toBe(true); expect(buttonNamed("Increase Water").disabled).toBe(true);
const check = container.querySelector<HTMLInputElement>('[aria-label="Reading done"]')!; const check = container.querySelector<HTMLInputElement>('[aria-label="Reading done"]')!;
expect(check.disabled).toBe(false); expect(check.disabled).toBe(false);
@@ -367,6 +471,7 @@ describe("homepage", () => {
f.setTime("2026-09-04T12:00:00Z"); f.setTime("2026-09-04T12:00:00Z");
await render("/"); await render("/");
await act(async () => buttonNamed("Edit habit Water").click()); await act(async () => buttonNamed("Edit habit Water").click());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit habit Water"]')!; const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit habit Water"]')!;
expect(document.activeElement).toBe(form.querySelector("input")); expect(document.activeElement).toBe(form.querySelector("input"));
await act(async () => { await act(async () => {
@@ -384,11 +489,15 @@ describe("homepage", () => {
expect(container.querySelector("form")).toBeNull(); expect(container.querySelector("form")).toBeNull();
expect(container.querySelector("h3")?.textContent).toContain("Daily water"); expect(container.querySelector("h3")?.textContent).toContain("Daily water");
await act(async () => buttonNamed("Archive habit Daily water").click()); await act(async () => buttonNamed("Archive habit Daily water").click());
await settleDeferred();
expect((await f.json("/habits")).habits).toHaveLength(1); expect((await f.json("/habits")).habits).toHaveLength(1);
await act(async () => buttonNamed("Cancel").click()); await act(async () => buttonNamed("Cancel").click());
await settleDeferred();
expect((await f.json("/habits")).habits).toHaveLength(1); expect((await f.json("/habits")).habits).toHaveLength(1);
await act(async () => buttonNamed("Archive habit Daily water").click()); await act(async () => buttonNamed("Archive habit Daily water").click());
await settleDeferred();
await act(async () => buttonNamed("Archive habit").click()); await act(async () => buttonNamed("Archive habit").click());
await settleDeferred();
expect((await f.json("/habits")).habits).toHaveLength(0); expect((await f.json("/habits")).habits).toHaveLength(0);
expect(container.querySelector(".ds-habit-chart")).toBeNull(); expect(container.querySelector(".ds-habit-chart")).toBeNull();
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`); 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 f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${task.id}`, "PUT", { done: true });
await render("/"); await render("/");
await act(async () => buttonNamed("Edit task Stretch").click()); await act(async () => buttonNamed("Edit task Stretch").click());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Stretch"]')!; const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Stretch"]')!;
await act(async () => { await act(async () => {
const input = form.querySelector("input")!; const input = form.querySelector("input")!;
@@ -419,7 +529,9 @@ describe("homepage", () => {
expect(saved.schedule).toEqual({ type: "weekdays", days: [4, 5] }); expect(saved.schedule).toEqual({ type: "weekdays", days: [4, 5] });
expect((await f.json("/today")).habits[0].value).toBe(1); 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 Stretch gently").click());
await settleDeferred();
await act(async () => buttonNamed("Delete task").click()); await act(async () => buttonNamed("Delete task").click());
await settleDeferred();
const today = (await f.json("/today")).habits[0]; const today = (await f.json("/today")).habits[0];
expect(today.target).toBe(1); expect(today.target).toBe(1);
expect(today.value).toBe(0); expect(today.value).toBe(0);
@@ -435,9 +547,11 @@ describe("homepage", () => {
await render("/"); await render("/");
expect(container.querySelector(".ds-habit-chart")).toBeNull(); expect(container.querySelector(".ds-habit-chart")).toBeNull();
await act(async () => buttonNamed("All habits · 1").click()); await act(async () => buttonNamed("All habits · 1").click());
await settleDeferred();
expect(container.textContent).toContain("Not scheduled today"); expect(container.textContent).toContain("Not scheduled today");
expect(container.querySelector<HTMLInputElement>('.ds-editable-task input[type="checkbox"]')?.disabled).toBe(true); expect(container.querySelector<HTMLInputElement>('.ds-editable-task input[type="checkbox"]')?.disabled).toBe(true);
await act(async () => buttonNamed("Edit task Walk").click()); await act(async () => buttonNamed("Edit task Walk").click());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!; const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
await act(async () => { await act(async () => {
const input = form.querySelector("input")!; const input = form.querySelector("input")!;
@@ -477,6 +591,7 @@ describe("homepage", () => {
{ type: "daily" }, { type: "daily" },
] as const) { ] as const) {
await act(async () => buttonNamed("Edit task Walk").click()); await act(async () => buttonNamed("Edit task Walk").click());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!; const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
await selectValue(form.querySelector("select")!, schedule.type); await selectValue(form.querySelector("select")!, schedule.type);
if (schedule.type === "interval" || schedule.type === "weekly") { 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"); if (schedule.type === "interval") expect(buttonNamed("All habits · 1").getAttribute("aria-pressed")).toBe("true");
expect(container.querySelector("form")).toBeNull(); expect(container.querySelector("form")).toBeNull();
await act(async () => buttonNamed("Edit task Walk").click()); await act(async () => buttonNamed("Edit task Walk").click());
await settleDeferred();
const reopened = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!; const reopened = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
expect(reopened.querySelector("select")?.value).toBe(schedule.type); expect(reopened.querySelector("select")?.value).toBe(schedule.type);
if (schedule.type === "interval" || schedule.type === "weekly") { if (schedule.type === "interval" || schedule.type === "weekly") {
@@ -496,6 +612,7 @@ describe("homepage", () => {
expect(reopened.querySelector<HTMLInputElement>('input[type="date"]')?.value).toBe(schedule.anchor); expect(reopened.querySelector<HTMLInputElement>('input[type="date"]')?.value).toBe(schedule.anchor);
} }
await act(async () => buttonNamed("Cancel").click()); await act(async () => buttonNamed("Cancel").click());
await settleDeferred();
} }
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`); const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
expect(previous.tasks[0].done).toBe(true); 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)); 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 act(async () => buttonNamed("Edit habit Reading").click());
await settleDeferred();
await selectMethod("count"); await selectMethod("count");
await act(async () => container.querySelector<HTMLInputElement>('form input[type="checkbox"]')!.click()); await act(async () => container.querySelector<HTMLInputElement>('form input[type="checkbox"]')!.click());
await submit(); await submit();
expect((await f.json(`/habits/${habit.id}`)).method).toBe("count"); expect((await f.json(`/habits/${habit.id}`)).method).toBe("count");
expect((await f.json(`/habits/${habit.id}`)).carryPartialProgress).toBe(true); expect((await f.json(`/habits/${habit.id}`)).carryPartialProgress).toBe(true);
await act(async () => buttonNamed("Edit habit Reading").click()); await act(async () => buttonNamed("Edit habit Reading").click());
await settleDeferred();
expect(container.querySelector<HTMLInputElement>('form input[type="checkbox"]')?.checked).toBe(true); expect(container.querySelector<HTMLInputElement>('form input[type="checkbox"]')?.checked).toBe(true);
await selectMethod("tasks"); await selectMethod("tasks");
await submit(); await submit();
expect((await f.json(`/habits/${habit.id}`)).method).toBe("tasks"); expect((await f.json(`/habits/${habit.id}`)).method).toBe("tasks");
await act(async () => buttonNamed("Add task").click()); await act(async () => buttonNamed("Add task").click());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Add task"]')!; const form = container.querySelector<HTMLFormElement>('form[aria-label="Add task"]')!;
await act(async () => { await act(async () => {
const input = form.querySelector("input")!; const input = form.querySelector("input")!;
@@ -545,16 +665,19 @@ describe("homepage", () => {
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201); const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await render("/"); await render("/");
await act(async () => buttonNamed("Archive habit Reading").click()); await act(async () => buttonNamed("Archive habit Reading").click());
await settleDeferred();
fetchMock.mockImplementationOnce((async (input, init) => { fetchMock.mockImplementationOnce((async (input, init) => {
return f.request(String(input).replace("/api", ""), init?.method); return f.request(String(input).replace("/api", ""), init?.method);
}) as typeof fetch); }) as typeof fetch);
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 })); fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }));
await act(async () => buttonNamed("Archive habit").click()); await act(async () => buttonNamed("Archive habit").click());
await settleDeferred();
expect(container.querySelector("form")).toBeNull(); expect(container.querySelector("form")).toBeNull();
expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh"); expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh");
expect(buttonNamed("Archive habit Reading").disabled).toBe(true); expect(buttonNamed("Archive habit Reading").disabled).toBe(true);
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true); expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
await act(async () => buttonNamed("Try again").click()); await act(async () => buttonNamed("Try again").click());
await settleDeferred();
expect(container.querySelector(".ds-habit-chart")).toBeNull(); expect(container.querySelector(".ds-habit-chart")).toBeNull();
expect(fetchMock.mock.calls.filter(call => call[1]?.method === "DELETE")).toHaveLength(1); expect(fetchMock.mock.calls.filter(call => call[1]?.method === "DELETE")).toHaveLength(1);
}); });
@@ -565,10 +688,12 @@ describe("homepage", () => {
await render("/"); await render("/");
fetchMock.mockResolvedValueOnce(Response.json({ error: "Save unavailable" }, { status: 500 })); fetchMock.mockResolvedValueOnce(Response.json({ error: "Save unavailable" }, { status: 500 }));
await act(async () => buttonNamed("Increase Water").click()); await act(async () => buttonNamed("Increase Water").click());
await settleDeferred();
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save unavailable"); expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save unavailable");
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("0"); expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("0");
expect(buttonNamed("Increase Water").disabled).toBe(false); expect(buttonNamed("Increase Water").disabled).toBe(false);
await act(async () => buttonNamed("Increase Water").click()); await act(async () => buttonNamed("Increase Water").click());
await settleDeferred();
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("1"); expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("1");
expect((await f.json("/today")).habits[0].value).toBe(1); expect((await f.json("/today")).habits[0].value).toBe(1);
}); });

View File

@@ -1,5 +1,6 @@
import { Navigate, Route, Routes } from "react-router"; 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 { Home } from "./pages/Home";
import { AuthProvider } from "./components/AuthProvider"; import { AuthProvider } from "./components/AuthProvider";

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -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> <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> </div>
</header> </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>)} </article>)}
</div> </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)}> {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)}>

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

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

View File

@@ -1,15 +1,17 @@
import { useCalendarCache } from "./CalendarCache";
import { Modal } from "./design-system/Modal"; import { Modal } from "./design-system/Modal";
import { Pencil } from "lucide-react"; 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 type { CalendarResponse } from "../shared/calendar";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard"; import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
import { CalendarHeatmap } from "./design-system/CalendarHeatmap"; 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 { Button } from "./design-system/primitives";
import { HabitChart } from "./design-system/HabitChart"; import { HabitChart } from "./design-system/HabitChart";
import { CalendarLegend } from "./design-system/CalendarLegend"; import { CalendarLegend } from "./design-system/CalendarLegend";
import { shade } from "../habits/calendar"; 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 { Field } from "./design-system/Field";
import { ItemActions } from "./design-system/ItemActions"; import { ItemActions } from "./design-system/ItemActions";
@@ -40,7 +42,29 @@ export function HabitHistory({
onHistorySaved?: () => void; onHistorySaved?: () => void;
calendarOnly?: boolean; 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>(); 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 [editingDay, setEditingDay] = useState(false);
const [savingDay, setSavingDay] = useState(false); const [savingDay, setSavingDay] = useState(false);
const [calendar, setCalendar] = useState<CalendarResponse | null>(null); const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
@@ -49,26 +73,20 @@ export function HabitHistory({
const expired = useRef(onExpired); const expired = useRef(onExpired);
expired.current = onExpired; expired.current = onExpired;
useEffect(() => { useEffect(() => {
const controller = new AbortController(); let active = true;
setError(""); setError("");
const range = monthWindow(date, 12); setHistoryLoading(true);
habitRequest<CalendarResponse>( cache.load(habit.habitId, `${date}:${revision}:${attempt}`, range)
`/habits/${habit.habitId}/calendar?${new URLSearchParams(range)}`, .then(result => { if (active) setCalendar(result); })
{ signal: controller.signal }, .catch(error => {
) if (!active) return;
.then((result) => { if (error.message.includes("session has expired") && expired.current) expired.current();
if (!controller.signal.aborted) setCalendar(result);
})
.catch((error) => {
if (controller.signal.aborted) return;
if (error.message.includes("session has expired") && expired.current)
expired.current();
else setError(error.message); else setError(error.message);
}); }).finally(() => { if (active) setHistoryLoading(false); });
return () => controller.abort(); return () => { active = false; };
}, [habit.habitId, date, revision, attempt]); }, [cache, habit.habitId, date, revision, attempt, range.from, range.to]);
const days = const days = useMemo(() =>
calendar?.days.map((day) => ({ calendar?.days.map((day) => ({
date: day.date, date: day.date,
color: day.color, color: day.color,
@@ -80,14 +98,14 @@ export function HabitHistory({
: day.due : day.due
? ("due" as const) ? ("due" as const)
: ("not-due" as const), : ("not-due" as const),
})) ?? []; })) ?? [], [calendar]);
const steps = Math.max( const steps = Math.max(
1, 1,
calendar?.days.find((day) => day.date === date)?.shadeCount ?? calendar?.days.find((day) => day.date === date)?.shadeCount ??
habit.target ?? habit.target ??
1, 1,
); );
const shades = calendar const shades = useMemo(() => calendar
? [ ? [
calendar.settings.emptyColor, calendar.settings.emptyColor,
...Array.from( ...Array.from(
@@ -101,7 +119,7 @@ export function HabitHistory({
).color, ).color,
), ),
] ]
: []; : [], [calendar, steps]);
const chart = error ? ( const chart = error ? (
<div className="ds-form-feedback"> <div className="ds-form-feedback">
<p role="alert">{error}</p> <p role="alert">{error}</p>
@@ -119,17 +137,20 @@ export function HabitHistory({
) : ( ) : (
<> <>
<CalendarHeatmap <CalendarHeatmap
onSelectDate={setSelectedDate} onSelectDate={selectDate}
selectedDate={selectedDate} selectedDate={selectedDate}
compact compact
initialMonths={3} initialMonths={3}
availableRange={monthWindow(date, 12)}
onRangeChange={requestRange}
loading={historyLoading}
historyLabel="Your recorded progress" historyLabel="Your recorded progress"
label={`${habit.name} progress calendar`} label={`${habit.name} progress calendar`}
unit={habit.unit} unit={habit.unit}
color={calendar.settings.mainColor} color={calendar.settings.mainColor}
emptyColor={calendar.settings.emptyColor} emptyColor={calendar.settings.emptyColor}
days={days} 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={ legend={
<CalendarLegend <CalendarLegend
label={`${habit.name} progress calendar`} 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}> {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-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} <CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} /> onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
</div></Modal>} </div></Modal>}

View File

@@ -1,6 +1,6 @@
import { useTheme } from "../ThemeProvider"; import { useTheme } from "../ThemeProvider";
import { habitProgressColor, themedHabitShade } from "../../lib/habit-palette"; 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 { CalendarLegend } from "./CalendarLegend";
import { Field } from "./Field"; import { Field } from "./Field";
import { Button } from "./primitives"; import { Button } from "./primitives";
@@ -10,6 +10,7 @@ import {
calendarTimeline, calendarTimeline,
MONTH_VIEWS, MONTH_VIEWS,
visibleCalendarDays, visibleCalendarDays,
monthWindow,
type CalendarDay, type CalendarDay,
type MonthView, type MonthView,
} from "./calendar-model"; } from "./calendar-model";
@@ -26,7 +27,13 @@ export function CalendarHeatmap({
historyLabel, historyLabel,
legend, legend,
selectedDayAction, selectedDayAction,
availableRange,
onRangeChange,
loading = false,
}: { }: {
availableRange?: { from: string; to: string };
onRangeChange?: (range: { from: string; to: string }, months: MonthView | "custom") => void;
loading?: boolean;
days: CalendarDay[]; days: CalendarDay[];
unit: string; unit: string;
label: string; label: string;
@@ -65,15 +72,15 @@ export function CalendarHeatmap({
if (onSelectDate) onSelectDate(date); if (onSelectDate) onSelectDate(date);
else setLocalSelectedDate(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) ? 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 = const selected =
days.find((day) => day.date === selectedDate) ?? days.find((day) => day.date === selectedDate) ??
days.find((day) => day.date === anchor) ?? days.find((day) => day.date === anchor) ??
days[0]; days[0];
// Date keys and the shared timeline remain stable when the chart resizes. // 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 buttons = useRef<(HTMLButtonElement | null)[]>([]);
const inspectorId = useId(); const inspectorId = useId();
const instructionsId = useId(); const instructionsId = useId();
@@ -100,6 +107,7 @@ export function CalendarHeatmap({
<div <div
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`} className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
data-months={months} data-months={months}
aria-busy={loading}
style={{ "--calendar-weeks": timeline.weeks, "--calendar-color": color } as CSSProperties} style={{ "--calendar-weeks": timeline.weeks, "--calendar-color": color } as CSSProperties}
> >
<div className="ds-calendar-range-toolbar"> <div className="ds-calendar-range-toolbar">
@@ -130,7 +138,7 @@ export function CalendarHeatmap({
type="button" type="button"
aria-label={`${value} ${value === 1 ? "month" : "months"}`} aria-label={`${value} ${value === 1 ? "month" : "months"}`}
aria-pressed={months === value} aria-pressed={months === value}
onClick={() => setMonths(value)} onClick={() => { setMonths(value); if (anchor) onRangeChange?.(monthWindow(anchor, value), value); }}
> >
{value}m {value}m
</button> </button>
@@ -142,6 +150,7 @@ export function CalendarHeatmap({
if (!customRange) setCustomRange({ from: days[0]!.date, to: days.at(-1)!.date }); if (!customRange) setCustomRange({ from: days[0]!.date, to: days.at(-1)!.date });
setRangeError(""); setRangeError("");
setMonths("custom"); setMonths("custom");
onRangeChange?.(customRange ?? { from: days[0]!.date, to: days.at(-1)!.date }, "custom");
}} }}
> >
Custom Custom
@@ -160,7 +169,7 @@ export function CalendarHeatmap({
const end = form.elements.namedItem("to") as HTMLInputElement; const end = form.elements.namedItem("to") as HTMLInputElement;
const isAvailable = (input: HTMLInputElement) => const isAvailable = (input: HTMLInputElement) =>
input.validity.valid && input.value !== "" && 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)) { if (!isAvailable(start) || !isAvailable(end)) {
setRangeError("Choose both dates within the available history."); setRangeError("Choose both dates within the available history.");
(!isAvailable(start) ? start : end).focus(); (!isAvailable(start) ? start : end).focus();
@@ -171,11 +180,12 @@ export function CalendarHeatmap({
end.focus(); end.focus();
return; 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."); setRangeError("There are no calendar dates in this range.");
return; return;
} }
setCustomRange({ from: start.value, to: end.value }); setCustomRange({ from: start.value, to: end.value });
onRangeChange?.({ from: start.value, to: end.value }, "custom");
setRangeError(""); setRangeError("");
}} }}
> >
@@ -187,8 +197,8 @@ export function CalendarHeatmap({
name={name} name={name}
type="date" type="date"
required required
min={allDays[0]!.date} min={availableRange?.from ?? allDays[0]!.date}
max={allDays.at(-1)!.date} max={availableRange?.to ?? allDays.at(-1)!.date}
defaultValue={customRange[name]} defaultValue={customRange[name]}
aria-invalid={rangeError ? true : undefined} aria-invalid={rangeError ? true : undefined}
aria-describedby={rangeError ? rangeErrorId : 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>} {rangeError && <p id={rangeErrorId} className="ds-calendar-range-error" role="alert">{rangeError}</p>}
</form> </form>
)} )}
{loading && <p role="status" className="ds-supporting-copy">Loading history…</p>}
<div <div
className="ds-calendar-viewport" className="ds-calendar-viewport"
role="group" role="group"

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

View File

@@ -1,3 +1,4 @@
import { browserFont } from "./browser-fonts";
import { startReminders } from "./reminders/worker"; import { startReminders } from "./reminders/worker";
import { backupConfig, startBackups } from "./ops/backups"; import { backupConfig, startBackups } from "./ops/backups";
import { databasePath } from "./db/config"; import { databasePath } from "./db/config";
@@ -29,6 +30,7 @@ const server = Bun.serve({
hostname: process.env.HOST ?? "127.0.0.1", hostname: process.env.HOST ?? "127.0.0.1",
port: Number(process.env.PORT ?? 3000), port: Number(process.env.PORT ?? 3000),
routes: { routes: {
"/fonts/browser/*": browserFont,
"/api": req => app.fetch(req), "/api": req => app.fetch(req),
"/api/*": req => app.fetch(req), "/api/*": req => app.fetch(req),
"/*": index, "/*": index,

View File

@@ -46,3 +46,15 @@ export function formatTrackingDate(date: string) {
timeZone: "UTC", 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 };
}

View File

@@ -1,7 +1,9 @@
import { deferred } from "../components/design-system/deferred";
import { CalendarCacheProvider } from "../components/CalendarCache";
import { Archive, Plus, Settings2 } from "lucide-react"; import { Archive, Plus, Settings2 } from "lucide-react";
import { ArchivedHabits } from "../components/ArchivedHabits"; const ArchivedHabits = deferred(() => import("../components/ArchivedHabits").then(module => module.ArchivedHabits));
import { AccountSettings } from "../components/AccountSettings"; const AccountSettings = deferred(() => import("../components/AccountSettings").then(module => module.AccountSettings));
import { useCallback, useEffect, useRef, useState } from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "../components/AuthProvider"; import { useAuth } from "../components/AuthProvider";
import { DiscordSignInButton } from "../components/DiscordSignInButton"; import { DiscordSignInButton } from "../components/DiscordSignInButton";
import { PageLayout } from "../components/design-system/PageLayout"; import { PageLayout } from "../components/design-system/PageLayout";
@@ -15,17 +17,17 @@ import {
import { Card, CardGrid } from "../components/design-system/Card"; import { Card, CardGrid } from "../components/design-system/Card";
import { HabitChart } from "../components/design-system/HabitChart"; import { HabitChart } from "../components/design-system/HabitChart";
import { HABIT_COLORS } from "../components/design-system/calendar-model"; import { HABIT_COLORS } from "../components/design-system/calendar-model";
import { HabitForm } from "../components/HabitForm"; const HabitForm = deferred(() => import("../components/HabitForm").then(module => module.HabitForm));
import { habitRequest, scheduleLabel, type TodayHabit, type TodayResponse } from "../lib/dashboard"; import { habitRequest, reconcileToday, scheduleLabel, type TodayHabit, type TodayResponse } from "../lib/dashboard";
import { HabitHistory } from "../components/HabitHistory"; import { HabitHistory } from "../components/HabitHistory";
import { WelcomePanel } from "../components/design-system/WelcomePanel"; import { WelcomePanel } from "../components/design-system/WelcomePanel";
import type { PublicUser } from "../shared/user"; import type { PublicUser } from "../shared/user";
import { ItemActions } from "../components/design-system/ItemActions"; import { ItemActions } from "../components/design-system/ItemActions";
import { type ItemMode } from "../components/InlineItemForm"; import { type ItemMode } from "../components/InlineItemForm";
import { InlineHabitEditor } from "../components/InlineHabitEditor"; const InlineHabitEditor = deferred(() => import("../components/InlineHabitEditor").then(module => module.InlineHabitEditor));
import { InlineTaskEditor } from "../components/InlineTaskEditor"; const InlineTaskEditor = deferred(() => import("../components/InlineTaskEditor").then(module => module.InlineTaskEditor));
import type { Schedule } from "../habits/contracts"; import type { Schedule } from "../habits/contracts";
import { ShareProgress } from "../components/ShareProgress"; const ShareProgress = deferred(() => import("../components/ShareProgress").then(module => module.ShareProgress));
export function Home() { export function Home() {
const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth(); const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();
@@ -63,7 +65,7 @@ export function Home() {
</Card> </Card>
</section> </section>
) : user ? ( ) : user ? (
<Dashboard key={user.id} user={user} onExpired={retry} /> <CalendarCacheProvider key={`${user.id}:${user.timezone}`}><Dashboard user={user} onExpired={retry} /></CalendarCacheProvider>
) : ( ) : (
<Welcome onSignIn={signIn} /> <Welcome onSignIn={signIn} />
)} )}
@@ -161,9 +163,10 @@ function Welcome({ onSignIn }: { onSignIn: () => void }) {
function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => void }) { function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => void }) {
const [today, setToday] = useState<TodayResponse | null>(null); const [today, setToday] = useState<TodayResponse | null>(null);
const todaySnapshot = useRef(today);
todaySnapshot.current = today;
const [error, setError] = useState(""); const [error, setError] = useState("");
const [revision, setRevision] = useState(0); const [revision, setRevision] = useState(0);
const [calendarRevision, setCalendarRevision] = useState(0);
const [habitRevisions, setHabitRevisions] = useState<Record<string, number>>({}); const [habitRevisions, setHabitRevisions] = useState<Record<string, number>>({});
const [pendingHabits, setPendingHabits] = useState<Set<string>>(new Set()); const [pendingHabits, setPendingHabits] = useState<Set<string>>(new Set());
const progressSaves = useRef(new Set<string>()); const progressSaves = useRef(new Set<string>());
@@ -203,10 +206,10 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
habitRequest<TodayResponse>("/today", { signal: controller.signal }) habitRequest<TodayResponse>("/today", { signal: controller.signal })
.then((data) => { .then((data) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
setToday(data); const next = reconcileToday(todaySnapshot.current, data);
setToday(next);
setNeedsRefresh(false); setNeedsRefresh(false);
setRevision((value) => value + 1); if (next !== todaySnapshot.current) setRevision((value) => value + 1);
setCalendarRevision((value) => value + 1);
} }
}) })
.catch((error) => { .catch((error) => {
@@ -302,7 +305,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
try { try {
const refreshed = await habitRequest<TodayResponse>("/today"); const refreshed = await habitRequest<TodayResponse>("/today");
if (mounted.current) { if (mounted.current) {
setToday(refreshed); setToday(current => reconcileToday(current, refreshed));
// Keep a habit reachable when an edit makes it unscheduled (including an empty routine). // 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"); if (refreshed.habits.some(item => item.habitId === habit.habitId && !item.due)) setHabitView("all");
setNeedsRefresh(false); 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 ( return (
<> <>
<WelcomePanel <WelcomePanel
@@ -457,12 +467,12 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
key={habit.habitId} key={habit.habitId}
habit={habit} habit={habit}
date={today.date} date={today.date}
revision={calendarRevision + (habitRevisions[habit.habitId] ?? 0)} revision={habit.calendarRevision ?? habitRevisions[habit.habitId] ?? 0}
disabled={busy || pendingHabits.has(habit.habitId) || loading || needsRefresh} disabled={busy || pendingHabits.has(habit.habitId) || loading || needsRefresh}
onHistorySaved={() => setAttempt(value => value + 1)} onHistorySaved={refreshHistory}
onUpdate={update} onUpdate={updateHabit}
onManage={manage} onManage={manageHabit}
onExpired={() => expired.current()} onExpired={expire}
/> />
))} ))}
</div> </div>
@@ -474,7 +484,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
); );
} }
function SavedHabit({ const SavedHabit = memo(function SavedHabit({
habit, habit,
date, date,
revision, revision,
@@ -604,7 +614,7 @@ function SavedHabit({
) : undefined} ) : undefined}
</HabitHistory> </HabitHistory>
); );
} });
function SavedTask({ function SavedTask({
task, task,

View File

@@ -1,34 +1,34 @@
@font-face { @font-face {
font-family: "Mina Sans"; 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-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "Mina Sans"; 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-style: normal;
font-weight: 500; font-weight: 500;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "Mina Sans"; 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-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "Instrument Serif"; 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-weight: 400;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "Instrument Serif"; 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-weight: 400;
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;