diff --git a/build.ts b/build.ts index 28e1633..808a90a 100644 --- a/build.ts +++ b/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`)); +} diff --git a/bunfig.toml b/bunfig.toml index 0d0612c..fa3b71d 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -3,5 +3,5 @@ env = false [serve.static] -plugins = ["bun-plugin-tailwind"] +plugins = ["bun-plugin-tailwind", "./scripts/browser-fonts-plugin.ts"] env = "BUN_PUBLIC_*" diff --git a/scripts/browser-fonts-plugin.ts b/scripts/browser-fonts-plugin.ts new file mode 100644 index 0000000..e142fcc --- /dev/null +++ b/scripts/browser-fonts-plugin.ts @@ -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; diff --git a/scripts/dashboard-preview.ts b/scripts/dashboard-preview.ts index 770aefb..a47a8e0 100644 --- a/scripts/dashboard-preview.ts +++ b/scripts/dashboard-preview.ts @@ -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": () => diff --git a/src/App.test.tsx b/src/App.test.tsx index 7680f54..971ac6e 100644 --- a/src/App.test.tsx +++ b/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") { , ), ); + 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("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('[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("#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("#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('[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('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('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('[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("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('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('[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('[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('[aria-label="12 months"]')!.click()); + await act(async () => [...customHistory.querySelectorAll("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(`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('[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('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('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('.ds-editable-task input[type="checkbox"]')?.disabled).toBe(true); await act(async () => buttonNamed("Edit task Walk").click()); + await settleDeferred(); const form = container.querySelector('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('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('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('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('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('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('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('[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('[aria-label="Total Water"]')?.value).toBe("1"); expect((await f.json("/today")).habits[0].value).toBe(1); }); diff --git a/src/App.tsx b/src/App.tsx index 54c8998..d271b7f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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"; diff --git a/src/assets/fonts/InstrumentSerif-Italic.woff2 b/src/assets/fonts/InstrumentSerif-Italic.woff2 new file mode 100644 index 0000000..444b688 Binary files /dev/null and b/src/assets/fonts/InstrumentSerif-Italic.woff2 differ diff --git a/src/assets/fonts/InstrumentSerif-Regular.woff2 b/src/assets/fonts/InstrumentSerif-Regular.woff2 new file mode 100644 index 0000000..6580bf1 Binary files /dev/null and b/src/assets/fonts/InstrumentSerif-Regular.woff2 differ diff --git a/src/assets/fonts/NotoSans-Medium.woff2 b/src/assets/fonts/NotoSans-Medium.woff2 new file mode 100644 index 0000000..c66dc4a Binary files /dev/null and b/src/assets/fonts/NotoSans-Medium.woff2 differ diff --git a/src/assets/fonts/NotoSans-Regular.woff2 b/src/assets/fonts/NotoSans-Regular.woff2 new file mode 100644 index 0000000..9465704 Binary files /dev/null and b/src/assets/fonts/NotoSans-Regular.woff2 differ diff --git a/src/assets/fonts/NotoSans-SemiBold.woff2 b/src/assets/fonts/NotoSans-SemiBold.woff2 new file mode 100644 index 0000000..6a9e444 Binary files /dev/null and b/src/assets/fonts/NotoSans-SemiBold.woff2 differ diff --git a/src/assets/fonts/README.md b/src/assets/fonts/README.md index b1890d9..d9012fe 100644 --- a/src/assets/fonts/README.md +++ b/src/assets/fonts/README.md @@ -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 +``` diff --git a/src/browser-fonts.test.ts b/src/browser-fonts.test.ts new file mode 100644 index 0000000..e90bed8 --- /dev/null +++ b/src/browser-fonts.test.ts @@ -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(""); +}); diff --git a/src/browser-fonts.ts b/src/browser-fonts.ts new file mode 100644 index 0000000..b720907 --- /dev/null +++ b/src/browser-fonts.ts @@ -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; 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 }); +} diff --git a/src/components/ArchivedHabits.tsx b/src/components/ArchivedHabits.tsx index 0952382..4cd0db1 100644 --- a/src/components/ArchivedHabits.tsx +++ b/src/components/ArchivedHabits.tsx @@ -71,7 +71,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose } - {selected === habit.habitId &&
setAttempt(value => value + 1)} />
} + {selected === habit.habitId &&
setAttempt(value => value + 1)} />
} )} {deleting && archiveFocus.current} onClose={() => setDeleting(null)}> diff --git a/src/components/CalendarCache.test.ts b/src/components/CalendarCache.test.ts new file mode 100644 index 0000000..1065574 --- /dev/null +++ b/src/components/CalendarCache.test.ts @@ -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); +}); diff --git a/src/components/CalendarCache.tsx b/src/components/CalendarCache.tsx new file mode 100644 index 0000000..2b38572 --- /dev/null +++ b/src/components/CalendarCache.tsx @@ -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 }; +/** Account-scoped, bounded cache; never survives signing out or changing accounts. */ +export class CalendarCache { + private entries = new Map(); + 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(`/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(null); +export function CalendarCacheProvider({ children }: { children: ReactNode }) { + const cache = useRef(null); + cache.current ??= new CalendarCache(); + return {children}; +} +export function useCalendarCache() { + const shared = useContext(Context); + const local = useRef(null); + local.current ??= new CalendarCache(); + return shared ?? local.current; +} diff --git a/src/components/HabitHistory.tsx b/src/components/HabitHistory.tsx index 93bd5f2..d3b0138 100644 --- a/src/components/HabitHistory.tsx +++ b/src/components/HabitHistory.tsx @@ -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(); + 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(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( - `/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 ? (

{error}

@@ -119,17 +137,20 @@ export function HabitHistory({ ) : ( <> } + selectedDayAction={selected => } legend={ {editingDay && setEditingDay(false)} busy={savingDay}>
-
{id => { if (event.target.value) setSelectedDate(event.target.value); }} />}
+
{id => { if (event.target.value) selectDate(event.target.value); }} />}
{ setAttempt(value => value + 1); onHistorySaved?.(); }} />
} diff --git a/src/components/design-system/CalendarHeatmap.tsx b/src/components/design-system/CalendarHeatmap.tsx index 400fd4c..998897e 100644 --- a/src/components/design-system/CalendarHeatmap.tsx +++ b/src/components/design-system/CalendarHeatmap.tsx @@ -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({
@@ -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 @@ -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 && } )} + {loading &&

Loading history…

}
>(load: () => Promise) { + const Surface = lazy(async () => ({ default: await load() })) as ComponentType>; + return function Deferred(props: ComponentProps) { + return Loading…

}>
; + }; +} diff --git a/src/index.ts b/src/index.ts index 1eca478..1950e14 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/lib/dashboard.ts b/src/lib/dashboard.ts index 40c8002..2090659 100644 --- a/src/lib/dashboard.ts +++ b/src/lib/dashboard.ts @@ -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 }; +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index d1e62e7..3c05aa9 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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() { ) : user ? ( - + ) : ( )} @@ -161,9 +163,10 @@ function Welcome({ onSignIn }: { onSignIn: () => void }) { function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => void }) { const [today, setToday] = useState(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>({}); const [pendingHabits, setPendingHabits] = useState>(new Set()); const progressSaves = useRef(new Set()); @@ -203,10 +206,10 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi habitRequest("/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("/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) => actions.current.update(...args), []); + const manageHabit = useCallback((...args: Parameters) => actions.current.manage(...args), []); + const refreshHistory = useCallback(() => setAttempt(value => value + 1), []); + const expire = useCallback(() => expired.current(), []); + return ( <> 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} /> ))}
@@ -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} ); -} +}); function SavedTask({ task, diff --git a/styles/design-system.css b/styles/design-system.css index c77d15b..ac3aed5 100644 --- a/styles/design-system.css +++ b/styles/design-system.css @@ -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;