Compare commits

...

23 Commits

Author SHA1 Message Date
syntaxbullet
e081e9acbf Document performance changes and measured results 2026-09-08 16:35:45 +02:00
syntaxbullet
b8df2001ec Merge frontend performance improvements 2026-09-08 16:35:45 +02:00
syntaxbullet
95f3d9a2c7 Merge worker performance improvements 2026-09-08 16:35:45 +02:00
syntaxbullet
8f15419703 Merge backend performance improvements 2026-09-08 16:35:45 +02:00
syntaxbullet
7c7a048b32 Initialize cold WAL bookkeeping for subprocess snapshots 2026-09-08 16:35:37 +02:00
syntaxbullet
27beb01144 Revalidate reminders synchronously after readiness yields 2026-09-08 16:35:36 +02:00
syntaxbullet
149ec36c77 Prepare worker and Discord snapshots through bounded catch-up 2026-09-08 16:35:36 +02:00
syntaxbullet
c3b52e8fe1 Resolve backup subprocess paths with URL decoding 2026-09-08 16:35:36 +02:00
syntaxbullet
ba049efdc8 Bound and deduplicate server image rendering per account 2026-09-08 16:35:36 +02:00
syntaxbullet
e3e07404f8 Cache bounded Discord avatars and coalesce downloads 2026-09-08 16:35:36 +02:00
syntaxbullet
17190219fa Skip reminder habit evaluation for completed delivery slots 2026-09-08 16:35:36 +02:00
syntaxbullet
b0950cb4b4 Run scheduled backups in an isolated subprocess 2026-09-08 16:35:36 +02:00
syntaxbullet
82fe4a1db1 Preserve calendar presets and custom ranges across local midnight 2026-09-08 16:29:20 +02:00
syntaxbullet
c91202bbfc Keep auxiliary previews stable on unchanged background refreshes 2026-09-08 16:27:03 +02:00
syntaxbullet
03eb8d9e7a Serve lossless WOFF2 browser fonts outside blocking stylesheets 2026-09-08 16:25:38 +02:00
syntaxbullet
9f552bf608 Load earlier calendar windows when editing historical dates 2026-09-08 16:25:38 +02:00
syntaxbullet
b22c933fc4 Split optional routes and editing dialogs from initial browser code 2026-09-08 16:25:38 +02:00
syntaxbullet
9a0fc108a9 Preserve unchanged habit renders and memoize calendar geometry 2026-09-08 16:25:38 +02:00
syntaxbullet
b4619dec33 Cache versioned habit history and load visible calendar ranges 2026-09-08 16:25:37 +02:00
syntaxbullet
31e4f3b2ef Yield bounded catch-up batches behind per-user readiness queues 2026-09-08 16:18:09 +02:00
syntaxbullet
30dee55799 Skip unchanged materialization and expire only open occurrences 2026-09-08 16:16:04 +02:00
syntaxbullet
4b8c7dff05 Add compact calendar responses and persisted habit change versions 2026-09-08 16:14:58 +02:00
syntaxbullet
85376a54b3 Bound habit day and calendar reads to requested history 2026-09-08 16:13:48 +02:00
54 changed files with 3631 additions and 182 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_*"

66
docs/performance.md Normal file
View File

@@ -0,0 +1,66 @@
# Performance improvements
The September 2026 audit produced twelve changes, implemented in separate backend,
frontend, and worker worktrees and verified together before release.
## Changes
| Area | Improvement |
| --- | --- |
| Database reads | Fetch the latest day snapshots, tasks, and effective revisions for the requested dates. Carry-enabled counts look back to the necessary prior reset or log. |
| Synchronization | Skip already-materialized habits and unchanged marker writes; expire tasks using an index of open occurrences. |
| Catch-up | Materialize bounded batches with bulk task inserts, yield between batches, and serialize same-account operations until their data is ready. |
| Calendar payloads | `compact=true` omits task lists and repeated requirements. Dated editing endpoints retain full detail. |
| Calendar caching | Cache account-scoped ranges using persisted per-habit change versions; fetch visible months first and extend on demand. Historical edits and local midnight invalidate the appropriate data. |
| React rendering | Preserve unchanged habit objects and callbacks; memoize calendar data and geometry. |
| Initial JavaScript | Split the showcase and optional forms, editors, settings, and sharing interfaces into lazy-loaded modules. |
| Browser fonts | Serve complete WOFF2 fonts separately with conditional caching; retain the original TTF files for native image rendering. |
| Image rendering | Share a bounded queue across server renderers, deduplicate identical in-flight requests within their owner, and release completed canvas surfaces. |
| Backups | Run SQLite snapshots, verification, durability, and replication in a subprocess, preserving startup health and overlap prevention. |
| Reminders | Check delivery-slot eligibility before evaluating habits; revalidate preferences and progress immediately before sending. |
| Avatars | Bound cached images/downloads, reuse concurrent downloads, and briefly cache failures. |
## Local measurements
These are synthetic measurements on a development Mac, not production capacity
estimates. Absolute timings vary with hardware and load.
| Scenario | Before | After |
| --- | ---: | ---: |
| Dashboard service work, ten task habits with five years of history | 169.81 ms | 2.76 ms |
| Unchanged synchronization, ten habits | 34 queries / 10 row changes | 4 queries / 0 row changes |
| Combined year calendar, ten habits with ten tasks each | 10,693,864 JSON bytes | 1,267,884 compact JSON bytes |
| Catching up a year, ten habits with ten tasks each | ~1.17 s without yielding | ~1.11 s total; 112 timer ticks, largest gap 11.75 ms |
| Initial calendars, ten new manual habits | 1,947,301 JSON characters | 476,954 JSON characters |
| Unchanged focus refresh, same ten habits | 11 requests / 10 calendar requests | 1 request / 0 calendar requests |
| Switching identical habit filters | 920 `Date.parse` calls | 0 |
| Initial JavaScript, including static dependencies | 716,094 bytes | 286,641 bytes |
| Initial JavaScript, locally gzip-compressed | 199,622 bytes | 99,192 bytes |
| Main stylesheet | 273,268 bytes | 85,733 bytes |
| Main stylesheet, locally gzip-compressed | 106,410 bytes | 13,636 bytes |
| Fifty already-delivered reminder checks | 210 ms / 1,000 day evaluations | 14 ms / 0 day evaluations |
| Three renders with the same avatar URL | 3 downloads | 1 download |
| Sixteen identical concurrent image renders: timer delay | 68 ms | 1.1 ms |
| ~98 MiB replicated backup: main-thread timer delay | 421 ms | 1.9 ms |
Calendar byte/character counts describe serialized JSON, not compressed wire
traffic. JavaScript/CSS gzip sizes are local compression estimates. Image RSS
measurements concern transient native allocations, not memory leaks.
## Operation and verification
The application remains one Bun process with SQLite/WAL and one set of Discord
delivery workers. The backup subprocess owns a separate connection. Render
admission defaults to two active jobs, sixteen total pending jobs, and two jobs
per account; an overloaded renderer returns a retryable busy response.
Migrations add a persisted calendar revision and a partial index for open task
occurrences. Existing historical requirements, carryover rules, privacy boundaries,
rate-limit handling, and confirmed-deleted-message replacement remain covered by
regression tests.
Run `bun run typecheck`, `bun test`, and `bun run test:smoke`. The smoke test uses
a disposable production database. Browser verification should cover lazy route
and dialog loading, count/task saves, historical corrections, calendar ranges,
both themes, narrow layouts, and separately served fonts. Timing tests should
measure event-loop availability as well as total job duration.

View File

@@ -0,0 +1 @@
ALTER TABLE habits ADD COLUMN calendar_revision integer NOT NULL DEFAULT 0;

View File

@@ -0,0 +1 @@
CREATE INDEX task_occurrences_open_idx ON task_occurrences (day_id) WHERE closed_at IS NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -92,6 +92,20 @@
"when": 1788847978168, "when": 1788847978168,
"tag": "0012_discord_commands", "tag": "0012_discord_commands",
"breakpoints": true "breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1788860000000,
"tag": "0013_habit_calendar_revision",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1788860000001,
"tag": "0014_open_occurrences",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,5 +1,7 @@
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { databasePath } from "../src/db/config"; import { databasePath } from "../src/db/config";
import { backupConfig, createBackup } from "../src/ops/backups"; import { backupConfig, createBackup } from "../src/ops/backups";
const db = new Database(databasePath, { readonly: true, strict: true }); // SQLite needs writable WAL bookkeeping when the first snapshot opens a cold database.
// Only snapshot SQL runs here; never create a missing source file.
const db = new Database(databasePath, { readwrite: true, create: false, strict: true });
try { console.log(createBackup(db, backupConfig(databasePath))); } finally { db.close(); } try { console.log(createBackup(db, backupConfig(databasePath))); } finally { db.close(); }

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

@@ -0,0 +1,18 @@
import { Database } from 'bun:sqlite';
import { createBackup, latestBackupTime, type BackupConfig } from '../src/ops/backups';
const path = process.argv[2];
if (!path || !process.argv[3]) throw new Error('Expected database path and backup configuration');
const config = JSON.parse(process.argv[3]) as BackupConfig;
const latest = latestBackupTime(config);
if (latest && Date.now() - latest < config.intervalMs) console.log(JSON.stringify({ timestamp: latest, created: false }));
else {
// SQLite needs writable WAL bookkeeping when the first snapshot opens a cold database.
// Only snapshot SQL runs here; never create a missing source file.
const db = new Database(path, { readwrite: true, create: false, strict: true });
try {
db.exec('PRAGMA busy_timeout=5000');
createBackup(db, config);
console.log(JSON.stringify({ timestamp: latestBackupTime(config), created: true }));
} finally { db.close(); }
}

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

View File

@@ -8,9 +8,8 @@ import { HabitService } from "../habits/service";
export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, now: () => number) { export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, now: () => number) {
const app = new Hono<AuthEnv>(); const app = new Hono<AuthEnv>();
app.use("*", auth.requireAuth); app.use("*", auth.requireAuth);
app.get("/export", c => { app.get("/export", c => HabitService.withPrepared(db, c.get("user"), now, () => {
const user = c.get("user"); const user = c.get("user");
const service = new HabitService(db, user, now()); service.sync();
const data = db.transaction(tx => { const data = db.transaction(tx => {
const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, user.id)); const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, user.id));
const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned)); const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned));
@@ -31,11 +30,12 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
}); });
c.header("Content-Disposition", 'attachment; filename="mina-habits-export.json"'); c.header("Content-Disposition", 'attachment; filename="mina-habits-export.json"');
return c.json(data); return c.json(data);
}); }));
app.delete("/", auth.requireSameOrigin, bodyLimit({ maxSize: 1024 }), async c => { app.delete("/", auth.requireSameOrigin, bodyLimit({ maxSize: 1024 }), async c => {
const body = await c.req.json().catch(() => null); const body = await c.req.json().catch(() => null);
if (body?.confirmation !== "DELETE") return c.json({ error: "Type DELETE to confirm account deletion." }, 422); if (body?.confirmation !== "DELETE") return c.json({ error: "Type DELETE to confirm account deletion." }, 422);
const userId = c.get("user").id; const userId = c.get("user").id;
return HabitService.withPrepared(db, c.get("user"), now, () => {
db.transaction(tx => { db.transaction(tx => {
const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, userId)); const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, userId));
const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned)); const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned));
@@ -50,6 +50,7 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
}); });
auth.clearSession(c); auth.clearSession(c);
return c.body(null, 204); return c.body(null, 204);
});
}); });
return app; return app;
} }

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 { sql } from 'drizzle-orm';
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", { export const users = sqliteTable("users", {
@@ -33,6 +34,7 @@ export const discordDeliveries = sqliteTable("discord_deliveries", {
// Revisions are append-only, including multiple edits on the same local date. // Revisions are append-only, including multiple edits on the same local date.
export const habits = sqliteTable('habits', { export const habits = sqliteTable('habits', {
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id), id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id),
calendarRevision: integer('calendar_revision').notNull().default(0),
createdDate: text('created_date').notNull(), materializedThrough: text('materialized_through'), createdAt: integer('created_at').notNull(), createdDate: text('created_date').notNull(), materializedThrough: text('materialized_through'), createdAt: integer('created_at').notNull(),
}, t => [index('habits_user_idx').on(t.userId)]); }, t => [index('habits_user_idx').on(t.userId)]);
export const habitRevisions = sqliteTable('habit_revisions', { export const habitRevisions = sqliteTable('habit_revisions', {
@@ -47,7 +49,7 @@ export const habitDays = sqliteTable('habit_days', {
export const taskOccurrences = sqliteTable('task_occurrences', { export const taskOccurrences = sqliteTable('task_occurrences', {
id: text('id').primaryKey(), dayId: integer('day_id').notNull().references(() => habitDays.id), taskId: text('task_id').notNull(), name: text('name').notNull(), id: text('id').primaryKey(), dayId: integer('day_id').notNull().references(() => habitDays.id), taskId: text('task_id').notNull(), name: text('name').notNull(),
done: integer('done', { mode: 'boolean' }).notNull().default(false), expiredAt: integer('expired_at'), closedAt: integer('closed_at'), updatedAt: integer('updated_at').notNull(), done: integer('done', { mode: 'boolean' }).notNull().default(false), expiredAt: integer('expired_at'), closedAt: integer('closed_at'), updatedAt: integer('updated_at').notNull(),
}, t => [index('task_occurrences_day_idx').on(t.dayId)]); }, t => [index('task_occurrences_day_idx').on(t.dayId), index('task_occurrences_open_idx').on(t.dayId).where(sql`${t.closedAt} IS NULL`)]);
export const progressEvents = sqliteTable('progress_events', { export const progressEvents = sqliteTable('progress_events', {
id: integer('id').primaryKey({ autoIncrement: true }), dayId: integer('day_id').notNull().references(() => habitDays.id), occurrenceId: text('occurrence_id'), id: integer('id').primaryKey({ autoIncrement: true }), dayId: integer('day_id').notNull().references(() => habitDays.id), occurrenceId: text('occurrence_id'),
before: text('before', { mode: 'json' }).$type<unknown>().notNull(), after: text('after', { mode: 'json' }).$type<unknown>().notNull(), createdAt: integer('created_at').notNull(), before: text('before', { mode: 'json' }).$type<unknown>().notNull(), after: text('after', { mode: 'json' }).$type<unknown>().notNull(), createdAt: integer('created_at').notNull(),

View File

@@ -55,7 +55,7 @@ export function commandGraphicAction(c: Context, action: string, data: ControlDa
]; ];
const reply = message(c, content, [row(...controls)], false); const reply = message(c, content, [row(...controls)], false);
c.defer(async () => { c.defer(async () => {
try { return { message: reply.data as import('./types').Message, image: await renderCommandCard(model, theme), imageDescription: `${model.title}. ${model.summary}. ${model.subtitle}. Page ${model.page + 1} of ${model.pages}.` }; } try { return { message: reply.data as import('./types').Message, image: await renderCommandCard(model, theme, c.service.user.id), imageDescription: `${model.title}. ${model.summary}. ${model.subtitle}. Page ${model.page + 1} of ${model.pages}.` }; }
catch { return { message: { ...(reply.data as import('./types').Message), content: `The graphic could not be rendered.\n${content}`.slice(0, 2000) } }; } catch { return { message: { ...(reply.data as import('./types').Message), content: `The graphic could not be rendered.\n${content}`.slice(0, 2000) } }; }
}); });
return { type: 5, data: c.interaction.guild_id ? { flags: 64 } : {} }; return { type: 5, data: c.interaction.guild_id ? { flags: 64 } : {} };

View File

@@ -1,3 +1,4 @@
import { renderImage } from '../rendering/queue';
import { createCanvas } from '@napi-rs/canvas'; import { createCanvas } from '@napi-rs/canvas';
import '../sharing/live-card-image'; import '../sharing/live-card-image';
import { themePalette, type Theme } from '../lib/theme'; import { themePalette, type Theme } from '../lib/theme';
@@ -7,7 +8,10 @@ export type CardRow = { label: string; detail: string; value?: string; ratio?: n
export type CommandCard = { title: string; owner: string; subtitle: string; summary: string; note: string; rows: CardRow[]; page: number; pages: number; history?: { date: string; ratio: number | null; future: boolean; color: string }[] }; export type CommandCard = { title: string; owner: string; subtitle: string; summary: string; note: string; rows: CardRow[]; page: number; pages: number; history?: { date: string; ratio: number | null; future: boolean; color: string }[] };
export const commandCardSize = (data: CommandCard) => ({ width: 1200, height: 350 + Math.max(1, data.rows.length) * 116 + (data.history ? 164 : 0) + 100 }); export const commandCardSize = (data: CommandCard) => ({ width: 1200, height: 350 + Math.max(1, data.rows.length) * 116 + (data.history ? 164 : 0) + 100 });
/** Fixed-size rows and text keep every page readable, including very large accounts. */ /** Fixed-size rows and text keep every page readable, including very large accounts. */
export async function renderCommandCard(data: CommandCard, theme: Theme = 'light') { export function renderCommandCard(data: CommandCard, theme: Theme = 'light', userId = 'unscoped') {
return renderImage('command', userId, data, theme, () => draw(data, theme));
}
async function draw(data: CommandCard, theme: Theme) {
if (data.rows.length > 6 || (data.history && data.history.length > 14)) throw new Error('Paginate command cards before rendering.'); if (data.rows.length > 6 || (data.history && data.history.length > 14)) throw new Error('Paginate command cards before rendering.');
const { width, height } = commandCardSize(data), canvas = createCanvas(width, height), ctx = canvas.getContext('2d'); const { width, height } = commandCardSize(data), canvas = createCanvas(width, height), ctx = canvas.getContext('2d');
const p = themePalette[theme]; const p = themePalette[theme];
@@ -61,5 +65,6 @@ export async function renderCommandCard(data: CommandCard, theme: Theme = 'light
rect(64, height - 92, 1072, 1, p.rule); rect(64, height - 92, 1072, 1, p.rule);
text(data.note, 64, height - 48, 21, 860, p.muted); text(data.note, 64, height - 48, 21, 860, p.muted);
text(`${data.page + 1} / ${data.pages}`, 1035, height - 48, 21, 100, p.muted); text(`${data.page + 1} / ${data.pages}`, 1035, height - 48, 21, 100, p.muted);
return new Uint8Array(await canvas.encode('png')); try { return new Uint8Array(await canvas.encode('png')); }
finally { canvas.width = 1; canvas.height = 1; }
} }

View File

@@ -1,3 +1,4 @@
import { renderImage } from '../rendering/queue';
import { createCanvas } from '@napi-rs/canvas'; import { createCanvas } from '@napi-rs/canvas';
// Import the existing renderer's bundled font registration and data contract. // Import the existing renderer's bundled font registration and data contract.
import { type LiveImageData } from '../sharing/live-card-image'; import { type LiveImageData } from '../sharing/live-card-image';
@@ -8,7 +9,10 @@ import { cardDate } from '../lib/progress-card';
export const progressImageSize = { width: 1200, height: 860 }; export const progressImageSize = { width: 1200, height: 860 };
/** Focused, chronological 30-day view; constant font/cell sizes at any habit count. */ /** Focused, chronological 30-day view; constant font/cell sizes at any habit count. */
export async function renderProgressImage(data: LiveImageData, theme: Theme = 'light') { export function renderProgressImage(data: LiveImageData, theme: Theme = 'light', userId = 'unscoped') {
return renderImage('progress', userId, data, theme, () => draw(data, theme));
}
async function draw(data: LiveImageData, theme: Theme) {
const habit = data.habits[0]; const habit = data.habits[0];
if (!habit || data.habits.length !== 1 || habit.days.length !== 30) throw new Error('Choose one habit and a 30-day range.'); if (!habit || data.habits.length !== 1 || habit.days.length !== 30) throw new Error('Choose one habit and a 30-day range.');
const canvas = createCanvas(progressImageSize.width, progressImageSize.height); const canvas = createCanvas(progressImageSize.width, progressImageSize.height);
@@ -62,5 +66,6 @@ export async function renderProgressImage(data: LiveImageData, theme: Theme = 'l
text(item.label, x + 36, 772, 21, 230, palette.muted); text(item.label, x + 36, 772, 21, 230, palette.muted);
}); });
text('A little, every day.', 64, 822, 27, 1072, palette.muted, true); text('A little, every day.', 64, 822, 27, 1072, palette.muted, true);
return new Uint8Array(await canvas.encode('png')); try { return new Uint8Array(await canvas.encode('png')); }
finally { canvas.width = 1; canvas.height = 1; }
} }

View File

@@ -112,3 +112,17 @@ test('snapshot quotas are shared with website operations and new controls cannot
f.setDiscordTime('2026-09-04T12:16:00Z'); f.setDiscordTime('2026-09-04T12:16:00Z');
expect((await click(preview, 'Choose public destination')).data.content).toContain('expired'); expect((await click(preview, 'Choose public destination')).data.content).toContain('expired');
}); });
test('a website edit during a deferred public share invalidates the prepared snapshot', async () => {
const env = setup();
const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201);
const panel = await f.run({ data: { name: 'progress' } });
await click(panel, 'Preview a habit', false, [habit.id]); const preview = await env.drain();
await click(preview, 'Choose public destination'); const confirmation = await env.drain();
await click(confirmation, 'Share snapshot publicly');
const delivery = env.drain();
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
const result = await delivery;
expect(result.data.content).toContain('changed');
expect(env.sends).toHaveLength(0);
});

View File

@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { renderProgressImage } from './progress-image'; import { renderProgressImage } from './progress-image';
import { ApiError } from '../habits/service'; import { ApiError, HabitService } from '../habits/service';
import { liveCardData } from '../sharing/live-card-image'; import { liveCardData } from '../sharing/live-card-image';
import type { createSharingRoutes } from '../sharing/routes'; import type { createSharingRoutes } from '../sharing/routes';
import type { createLiveDiscord } from '../sharing/live-discord'; import type { createLiveDiscord } from '../sharing/live-discord';
@@ -14,9 +14,11 @@ export type ProgressOperations = {
export type DeferredMessage = { message: Message; image?: Uint8Array; imageDescription?: string }; export type DeferredMessage = { message: Message; image?: Uint8Array; imageDescription?: string };
const fingerprint = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex'); const fingerprint = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
function imageData(c: Context, data: ControlData) { function imageData(c: Context, data: ControlData) {
const result = liveCardData(c.db, c.service.user.id, c.now); // Preparation happened before dispatch. Fresh read caches detect edits across deferred awaits.
const snapshot = new HabitService(c.db, c.service.user, c.now);
const result = liveCardData(c.db, c.service.user.id, c.now, snapshot);
if (data.habitId) { if (data.habitId) {
const habits = c.service.list(); const habits = snapshot.list();
const index = habits.findIndex(h => h.id === data.habitId); const index = habits.findIndex(h => h.id === data.habitId);
if (index < 0) throw new ApiError(409, 'This habit changed. Run /progress again.'); if (index < 0) throw new ApiError(409, 'This habit changed. Run /progress again.');
result.habits = [result.habits[index]!]; result.habits = [result.habits[index]!];
@@ -24,7 +26,7 @@ function imageData(c: Context, data: ControlData) {
return { ...result, avatarUrl: null }; return { ...result, avatarUrl: null };
} }
async function render(c: Context, data: ControlData, source = imageData(c, data)) { async function render(c: Context, data: ControlData, source = imageData(c, data)) {
return renderProgressImage(source, data.theme ?? 'light'); return renderProgressImage(source, data.theme ?? 'light', c.service.user.id);
} }
function output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; } function output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; }
function defer(c: Context, job: () => Promise<DeferredMessage>): Reply { function defer(c: Context, job: () => Promise<DeferredMessage>): Reply {
@@ -87,7 +89,7 @@ export function progressAction(c: Context, action: string, data: ControlData): R
const source = imageData(c, data); const source = imageData(c, data);
const image = await render(c, data, source); const image = await render(c, data, source);
if (fingerprint(imageData(c, data)) !== data.expected) throw new ApiError(409, 'Progress changed during rendering. Open a fresh preview.'); if (fingerprint(imageData(c, data)) !== data.expected) throw new ApiError(409, 'Progress changed during rendering. Open a fresh preview.');
const delivery = await ops.sharing.send(c.service.user, data.deliveryId!, new File([image], 'mina-habits-progress.png', { type: 'image/png' })); const delivery = await ops.sharing.send(c.service.user, data.deliveryId!, new File([new Uint8Array(image)], 'mina-habits-progress.png', { type: 'image/png' }));
return output(message(c, delivery.status === 'sent' ? `Snapshot shared.${delivery.messageUrl ? ` [Open snapshot](${delivery.messageUrl})` : ''}` : 'Discord could not confirm delivery. Check the destination before creating another snapshot; this action will not be retried.', [], false)); return output(message(c, delivery.status === 'sent' ? `Snapshot shared.${delivery.messageUrl ? ` [Open snapshot](${delivery.messageUrl})` : ''}` : 'Discord could not confirm delivery. Check the destination before creating another snapshot; this action will not be retried.', [], false));
} }
const state = action === 'progress.pause' ? await ops.live.pause(c.service.user.id) : await ops.live.start(c.service.user, data.theme ?? 'light'); const state = action === 'progress.pause' ? await ops.live.pause(c.service.user.id) : await ops.live.start(c.service.user, data.theme ?? 'light');

View File

@@ -54,15 +54,17 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
// Help needs no account creation, habit synchronization, or saved UI state. // Help needs no account creation, habit synchronization, or saved UI state.
if (i.type === 2 && i.data?.name === 'help') return c.json(help(!!i.guild_id, config.origin)); if (i.type === 2 && i.data?.name === 'help') return c.json(help(!!i.guild_id, config.origin));
let pending: (() => Promise<DeferredMessage>) | undefined; let pending: (() => Promise<DeferredMessage>) | undefined;
const reply = db.transaction((): Reply => { // Resolve the account before queueing catch-up; no transaction is held across yields.
const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get(); const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get();
const userId = existing?.id ?? crypto.randomUUID(); const userId = existing?.id ?? crypto.randomUUID();
if (!existing) db.insert(users).values({ id: userId, discordId: profile.id, username: profile.username, globalName: profile.global_name, avatarHash: profile.avatar, createdAt: now(), updatedAt: now() }).run(); if (!existing) db.insert(users).values({ id: userId, discordId: profile.id, username: profile.username, globalName: profile.global_name, avatarHash: profile.avatar, createdAt: now(), updatedAt: now() }).run();
const receipt = db.select().from(discordReceipts).where(eq(discordReceipts.id, i.id)).get();
if (receipt && receipt.expiresAt > now()) return c.json(receipt.response);
const owner = publicUser(db.select().from(users).where(eq(users.id, userId)).get()!);
const reply = await HabitService.withPrepared(db, owner, now, service => db.transaction((): Reply => {
const receipt = db.select().from(discordReceipts).where(eq(discordReceipts.id, i.id)).get(); const receipt = db.select().from(discordReceipts).where(eq(discordReceipts.id, i.id)).get();
if (receipt && receipt.expiresAt > now()) return receipt.response; if (receipt && receipt.expiresAt > now()) return receipt.response;
const user = publicUser(db.select().from(users).where(eq(users.id, userId)).get()!);
const controls = new ControlStore(db, userId, now()); controls.prune(); const controls = new ControlStore(db, userId, now()); controls.prune();
const service = new HabitService(db, user, now()); service.sync();
const ctx: Context = { db, service, controls, interaction: i, now: now(), origin: config.origin, remindersAvailable: config.remindersAvailable ?? false, changed: () => changed(userId), progress: dispatch.progress, defer: i.token ? job => { pending = job; } : undefined }; const ctx: Context = { db, service, controls, interaction: i, now: now(), origin: config.origin, remindersAvailable: config.remindersAvailable ?? false, changed: () => changed(userId), progress: dispatch.progress, defer: i.token ? job => { pending = job; } : undefined };
let result: Reply; let result: Reply;
try { try {
@@ -83,7 +85,7 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
} }
db.insert(discordReceipts).values({ id: i.id, userId, response: result, expiresAt: now() + 86400000 }).run(); db.insert(discordReceipts).values({ id: i.id, userId, response: result, expiresAt: now() + 86400000 }).run();
return result; return result;
}); }));
if (pending) { if (pending) {
const work = pending; const work = pending;
const job = async () => { const job = async () => {

View File

@@ -0,0 +1,85 @@
import { expect, test } from 'bun:test';
import { fixture } from './test-fixture';
import { HabitService } from './service';
import { publicUser } from '../auth';
import { users } from '../db/schema';
import { eq } from 'drizzle-orm';
test('single-day reads load only the selected latest snapshot and its tasks', async () => {
const f = fixture();
try {
const habit = await f.json('/habits', 'POST', { name: 'Tasks', method: 'tasks', tasks: [{name:'Read'}] }, 201);
f.setTime('2027-09-04T12:00:00Z');
await f.json('/today');
const logs: string[] = [];
(f.db as any).session.logger = { logQuery(query: string) { logs.push(query); } };
const service = new HabitService(f.db, publicUser(f.db.select().from(users).where(eq(users.id,'alice')).get()!), Date.parse('2027-09-04T12:00:00Z'));
const day = service.day(habit.id, '2027-09-04');
expect(day.tasks).toHaveLength(1);
expect((service as any).recordsCache.get(habit.id).size).toBe(1);
expect((service as any).occurrencesCache.get(habit.id).size).toBe(1);
expect(logs.find(query => query.includes('task_occurrences'))).toContain('"habit_days"."date" >=');
} finally { f.close(); }
});
test('calendar summaries omit editor payloads and mutation versions cover historical changes', async () => {
const f = fixture();
try {
const habit = await f.json('/habits', 'POST', { name: 'Tasks', method: 'tasks', tasks: [{name:'Read'}] }, 201);
const first = (await f.json('/today')).habits[0];
expect((await f.json('/today')).habits[0].calendarRevision).toBe(first.calendarRevision);
f.setTime('2026-09-05T12:00:00Z');
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${first.tasks[0].taskId}`, 'PUT', {done:true});
const changed = (await f.json('/today')).habits[0].calendarRevision;
expect(changed).toBeGreaterThan(first.calendarRevision);
await f.json(`/habits/${habit.id}/calendar-settings`, 'PUT', {mainColor:'#123456'});
expect((await f.json('/today')).habits[0].calendarRevision).toBeGreaterThan(changed);
const url = `/habits/${habit.id}/calendar?from=2026-09-04&to=2026-09-05`;
const full = await f.json(url);
const compact = await f.json(url+'&compact=true');
const detail = full.days[0].habits[0];
const {tasks, requirements, ...summary} = detail;
expect(compact.days[0].habits[0]).toEqual(summary);
expect(compact.data).toEqual(full.data);
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).tasks[0].done).toBe(true);
} finally { f.close(); }
});
test('unchanged dashboard and calendar reads write no rows after synchronization', async () => {
const f = fixture();
try {
const habit = await f.json('/habits', 'POST', {name:'Tasks', method:'tasks', tasks:[{name:'Read'}]}, 201);
f.setTime('2027-09-04T12:00:00Z');
await f.json('/today');
const before = (f.sqlite.query('SELECT total_changes() AS n').get() as {n:number}).n;
await f.json('/today');
await f.json(`/habits/${habit.id}/calendar?from=2027-09-01&to=2027-09-04&compact=true`);
expect((f.sqlite.query('SELECT total_changes() AS n').get() as {n:number}).n).toBe(before);
} finally {f.close();}
});
test('catch-up yields to other users and serializes same-user edits until fully ready', async () => {
const f = fixture();
try {
const habit = await f.json('/habits', 'POST', {name:'Many tasks', method:'tasks', tasks:Array.from({length:50}, (_,i)=>({name:`Task ${i}`}))}, 201);
f.setTime('2027-09-04T12:00:00Z');
let complete = false;
const dashboard = f.json('/today').then(value => {complete=true; return value;});
await new Promise(resolve=>setTimeout(resolve,0));
expect(complete).toBe(false);
// Another user's request can complete while Alice is still catching up.
expect((await f.request('/today','GET',undefined,'b')).status).toBe(200);
expect(complete).toBe(false);
// The actual date can advance while catch-up yields; readiness revalidates it.
f.setTime('2027-09-05T12:00:00Z');
const edit = f.json(`/habits/${habit.id}`, 'PATCH', {name:'Renamed'});
const [today, changed] = await Promise.all([dashboard, edit]);
expect(today.date).toBe('2027-09-05');
expect(today.habits[0].tasks).toHaveLength(50);
expect(changed.name).toBe('Renamed');
const through = f.sqlite.query('SELECT materialized_through AS date FROM habits WHERE id=?').get(habit.id) as {date:string};
expect(through.date).toBe('2027-09-05');
const day = await f.json(`/habits/${habit.id}/days/2026-09-05`);
expect(day.tasks.every((task:any)=>task.expiredAt!==null && task.closedAt!==null)).toBe(true);
} finally {f.close();}
});

View File

@@ -20,7 +20,7 @@ function date(c: Context<Env>) {
return result.data; return result.data;
} }
function range(c: Context<Env>) { function range(c: Context<Env>) {
const result = z.object({ from: dateSchema, to: dateSchema }).strict().safeParse(c.req.query()); const result = z.object({ from: dateSchema, to: dateSchema, compact: z.enum(['true', 'false']).optional() }).strict().safeParse(c.req.query());
if (!result.success || result.data.from > result.data.to || dayNumber(result.data.to) - dayNumber(result.data.from) > 1829) throw new ApiError(422, 'Supply from and to dates in order, spanning at most 1830 days'); if (!result.success || result.data.from > result.data.to || dayNumber(result.data.to) - dayNumber(result.data.from) > 1829) throw new ApiError(422, 'Supply from and to dates in order, spanning at most 1830 days');
return result.data; return result.data;
} }
@@ -41,8 +41,9 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
try { payload.value = await c.req.json(); } catch { payload.malformed = true; } try { payload.value = await c.req.json(); } catch { payload.malformed = true; }
} }
c.set('payload', payload); c.set('payload', payload);
const service = new HabitService(db, c.get('user'), now()); await HabitService.withPrepared(db, c.get('user'), now, async service => {
service.sync(); c.set('service', service); await next(); c.set('service', service); await next();
});
}); if (response) c.res = response; }; }); if (response) c.res = response; };
if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) { if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) {
const response = await origin(c, proceed); if (response) c.res = response; const response = await origin(c, proceed); if (response) c.res = response;
@@ -80,7 +81,7 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
app.get('/habits/:id/calendar-settings', c => c.json(service(c).settings(id(c)))); app.get('/habits/:id/calendar-settings', c => c.json(service(c).settings(id(c))));
app.put('/habits/:id/calendar-settings', c => c.json(service(c).saveSettings(id(c), body(c, calendarSettingsSchema)))); app.put('/habits/:id/calendar-settings', c => c.json(service(c).saveSettings(id(c), body(c, calendarSettingsSchema))));
app.get('/habits/:id/calendar', c => { app.get('/habits/:id/calendar', c => {
const { from, to } = range(c); return c.json(service(c).calendar([id(c)], from, to, service(c).settings(id(c)), false)); const { from, to, compact } = range(c); return c.json(service(c).calendar([id(c)], from, to, service(c).settings(id(c)), false, compact === 'true'));
}); });
app.get('/today', c => { app.get('/today', c => {
const s = service(c); const habits = s.list().map(h => s.day(h.id, s.today)); const s = service(c); const habits = s.list().map(h => s.day(h.id, s.today));
@@ -100,7 +101,7 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
}); });
app.delete('/charts/:id', c => { service(c).deleteChart(id(c)); return c.body(null, 204); }); app.delete('/charts/:id', c => { service(c).deleteChart(id(c)); return c.body(null, 204); });
app.get('/charts/:id/calendar', c => { app.get('/charts/:id/calendar', c => {
const chart = service(c).chart(id(c)); const { from, to } = range(c); return c.json(service(c).calendar(chart.habitIds, from, to, chart.settings, true)); const chart = service(c).chart(id(c)); const { from, to, compact } = range(c); return c.json(service(c).calendar(chart.habitIds, from, to, chart.settings, true, compact === 'true'));
}); });
app.get('/charts/:id/days/:date', c => { app.get('/charts/:id/days/:date', c => {
const chart = service(c).chart(id(c)); const day = date(c); return c.json(service(c).calendar(chart.habitIds, day, day, chart.settings, true).days[0]); const chart = service(c).chart(id(c)); const day = date(c); return c.json(service(c).calendar(chart.habitIds, day, day, chart.settings, true).days[0]);

View File

@@ -1,4 +1,4 @@
import { and, asc, desc, eq, inArray, lte, sql } from 'drizzle-orm'; import { and, asc, desc, eq, inArray, lte, gte, gt, lt, or, isNull, sql } from 'drizzle-orm';
import type { AppDatabase } from '../auth'; import type { AppDatabase } from '../auth';
import type { PublicUser } from '../shared/user'; import type { PublicUser } from '../shared/user';
import { habits, habitRevisions, habitDays, taskOccurrences, progressEvents, habitCalendarSettings, combinedCharts, users } from '../db/schema'; import { habits, habitRevisions, habitDays, taskOccurrences, progressEvents, habitCalendarSettings, combinedCharts, users } from '../db/schema';
@@ -13,17 +13,59 @@ export type Habit = typeof habits.$inferSelect;
export type Revision = typeof habitRevisions.$inferSelect; export type Revision = typeof habitRevisions.$inferSelect;
export type HabitDay = typeof habitDays.$inferSelect; export type HabitDay = typeof habitDays.$inferSelect;
// Each database has its own per-user readiness queue. No transaction spans a yield.
const preparationQueues = new WeakMap<AppDatabase, Map<string, Promise<void>>>();
export class HabitService { export class HabitService {
static async withPrepared<T>(db: AppDatabase, user: PublicUser, now: () => number, run: (service: HabitService) => T | Promise<T>): Promise<T> {
let queues = preparationQueues.get(db);
if (!queues) { queues = new Map(); preparationQueues.set(db, queues); }
const previous = queues.get(user.id);
let release!: () => void;
const ticket = new Promise<void>(resolve => { release = resolve; });
queues.set(user.id, ticket);
if (previous) await previous;
try {
let sliceStarted = performance.now();
while (true) {
// Re-read timezone, date and owned records after every scheduling yield.
const service = new HabitService(db, user, now());
const habit = service.pending()[0];
if (!habit) {
service.sync();
return await run(service);
}
const start = habit.materializedThrough ? addDays(habit.materializedThrough, 1) : habit.createdDate;
const through = [addDays(start, 7), service.today].sort()[0]!;
db.transaction(() => service.materialize(habit, through));
if (performance.now() - sliceStarted >= 8) {
await new Promise<void>(resolve => setTimeout(resolve, 0));
sliceStarted = performance.now();
}
}
} finally {
release();
if (queues.get(user.id) === ticket) queues.delete(user.id);
}
}
/** Ready read snapshot. Use withPrepared for read-modify-write handlers. */
static prepare(db: AppDatabase, user: PublicUser, now: () => number) {
return HabitService.withPrepared(db, user, now, service => service);
}
readonly today: string; readonly today: string;
private ownedCache = new Map<string, Habit>(); private ownedCache = new Map<string, Habit>();
private revisionCache = new Map<string, Revision[]>(); private revisionCache = new Map<string, Revision[]>();
private deadlineCache = new Map<string, number>(); private deadlineCache = new Map<string, number>();
private ranges = new Map<string, { from: string; to: string }>();
private revisionRanges = new Map<string, { from: string; to: string }>();
private recordsCache = new Map<string, Map<string, HabitDay>>(); private recordsCache = new Map<string, Map<string, HabitDay>>();
private occurrencesCache = new Map<string, Map<number, (typeof taskOccurrences.$inferSelect)[]>>(); private occurrencesCache = new Map<string, Map<number, (typeof taskOccurrences.$inferSelect)[]>>();
private countCache = new Map<string, Map<string, { value: number; carriedFrom: string | null }>>(); private countCache = new Map<string, Map<string, { value: number; carriedFrom: string | null }>>();
constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) { constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) {
// A different request may update the timezone while this request's body streams in. // A different request may update the timezone while this request's body streams in.
const timezone = db.select({ timezone: users.timezone }).from(users).where(eq(users.id, user.id)).get()!.timezone; const currentUser = db.select({ timezone: users.timezone }).from(users).where(eq(users.id, user.id)).get();
if (!currentUser) throw missing();
const timezone = currentUser.timezone;
this.user = { ...user, timezone }; this.user = { ...user, timezone };
// Travel west must never reopen or rewrite earlier requirements. // Travel west must never reopen or rewrite earlier requirements.
const latest = db.select({ date: habits.materializedThrough }).from(habits).where(eq(habits.userId, user.id)).orderBy(desc(habits.materializedThrough)).get()?.date; const latest = db.select({ date: habits.materializedThrough }).from(habits).where(eq(habits.userId, user.id)).orderBy(desc(habits.materializedThrough)).get()?.date;
@@ -35,10 +77,27 @@ export class HabitService {
if (!habit) throw missing(); if (!habit) throw missing();
this.ownedCache.set(id, habit); return habit; this.ownedCache.set(id, habit); return habit;
} }
private loadRevisions(id: string, from: string, to: string) {
const first = this.db.select().from(habitRevisions).where(and(eq(habitRevisions.habitId, id), lte(habitRevisions.effectiveDate, from))).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).get();
const later = from === to ? [] : this.db.select().from(habitRevisions).where(and(eq(habitRevisions.habitId, id), gt(habitRevisions.effectiveDate, from), lte(habitRevisions.effectiveDate, to))).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).all();
this.revisionCache.set(id, first ? [...later, first] : later);
this.revisionRanges.set(id, { from, to });
}
revision(id: string, date = this.today): Revision | undefined { revision(id: string, date = this.today): Revision | undefined {
let revisions = this.revisionCache.get(id); const range = this.revisionRanges.get(id);
if (!revisions) { revisions = this.db.select().from(habitRevisions).where(eq(habitRevisions.habitId, id)).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).all(); this.revisionCache.set(id, revisions); } if (!range || date < range.from || date > range.to) this.loadRevisions(id, date, date);
return revisions.find(r => r.effectiveDate <= date); return this.revisionCache.get(id)!.find(r => r.effectiveDate <= date);
}
private range(id: string, from: string, to = from) {
const old = this.ranges.get(id);
if (old && from >= old.from && to <= old.to) return;
this.ranges.set(id, { from, to });
this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.countCache.delete(id);
this.loadRevisions(id, from, to);
}
private changed(id: string) {
this.db.update(habits).set({ calendarRevision: sql`${habits.calendarRevision} + 1` }).where(eq(habits.id, id)).run();
this.ownedCache.delete(id);
} }
current(id: string) { current(id: string) {
const habit = this.owned(id); const revision = this.revision(id)!; const habit = this.owned(id); const revision = this.revision(id)!;
@@ -47,11 +106,11 @@ export class HabitService {
? [{ code: 'no_tasks', taskId: null, message: 'Add a task to make this habit due.' }] ? [{ code: 'no_tasks', taskId: null, message: 'Add a task to make this habit due.' }]
: config.tasks.filter(task => !schedulesOverlap(config.schedule, task.schedule)) : config.tasks.filter(task => !schedulesOverlap(config.schedule, task.schedule))
.map(task => ({ code: 'task_never_due', taskId: task.id, message: `Task \"${task.name}\" never coincides with the habit schedule.` })); .map(task => ({ code: 'task_never_due', taskId: task.id, message: `Task \"${task.name}\" never coincides with the habit schedule.` }));
return { id: habit.id, ...config, warnings, createdDate: habit.createdDate, createdAt: habit.createdAt, revisionId: revision.id, effectiveDate: revision.effectiveDate }; return { id: habit.id, calendarRevision: habit.calendarRevision, ...config, warnings, createdDate: habit.createdDate, createdAt: habit.createdAt, revisionId: revision.id, effectiveDate: revision.effectiveDate };
} }
list(includeArchived = false) { list(includeArchived = false) {
return this.db.select().from(habits).where(eq(habits.userId, this.user.id)).orderBy(asc(habits.createdAt), asc(habits.id)).all() return this.db.select().from(habits).where(eq(habits.userId, this.user.id)).orderBy(asc(habits.createdAt), asc(habits.id)).all()
.map(h => this.current(h.id)).filter(h => includeArchived || !h.archived); .map(h => { this.ownedCache.set(h.id, h); return this.current(h.id); }).filter(h => includeArchived || !h.archived);
} }
create(input: ReturnType<typeof habitInput.parse>) { create(input: ReturnType<typeof habitInput.parse>) {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
@@ -114,7 +173,8 @@ export class HabitService {
this.owned(id); this.owned(id);
this.db.transaction(() => { this.db.transaction(() => {
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run(); this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
this.materialize(this.owned(id)); this.materialize(this.owned(id), this.today, true);
this.changed(id);
}); });
return this.current(id); return this.current(id);
} }
@@ -137,19 +197,25 @@ export class HabitService {
} }
sync() { sync() {
this.db.transaction(() => { this.db.transaction(() => {
for (const habit of this.db.select().from(habits).where(eq(habits.userId, this.user.id)).all()) this.materialize(habit); for (const habit of this.pending()) this.materialize(habit);
// Record the original deadline even after downtime. Only active day snapshots expire. // Start with the small partial index of open occurrences rather than
// revisiting every historical snapshot on every unchanged request.
this.db.run(sql`UPDATE task_occurrences SET this.db.run(sql`UPDATE task_occurrences SET
expired_at = CASE WHEN done = 0 THEN COALESCE(expired_at, (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)) ELSE expired_at END, expired_at = CASE WHEN done = 0 THEN COALESCE(expired_at, (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)) ELSE expired_at END,
closed_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id) closed_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)
WHERE closed_at IS NULL AND day_id IN ( WHERE id IN (
SELECT d.id FROM habit_days d JOIN habits h ON h.id = d.habit_id SELECT o.id FROM task_occurrences o INDEXED BY task_occurrences_open_idx
WHERE h.user_id = ${this.user.id} AND d.ends_at <= ${this.now} CROSS JOIN habit_days d ON d.id = o.day_id
CROSS JOIN habits h ON h.id = d.habit_id
WHERE o.closed_at IS NULL AND h.user_id = ${this.user.id} AND d.ends_at <= ${this.now}
AND d.id = (SELECT MAX(d2.id) FROM habit_days d2 WHERE d2.habit_id = d.habit_id AND d2.date = d.date))`); AND d.id = (SELECT MAX(d2.id) FROM habit_days d2 WHERE d2.habit_id = d.habit_id AND d2.date = d.date))`);
}); });
} }
private pending() {
return this.db.select().from(habits).where(and(eq(habits.userId, this.user.id), or(isNull(habits.materializedThrough), lt(habits.materializedThrough, this.today)))).all();
}
private invalidate(id: string) { private invalidate(id: string) {
this.countCache.delete(id); this.revisionCache.delete(id); this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.ownedCache.delete(id); this.ranges.delete(id); this.revisionRanges.delete(id); this.countCache.delete(id); this.revisionCache.delete(id); this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.ownedCache.delete(id);
} }
private deadline(date: string) { private deadline(date: string) {
let value = this.deadlineCache.get(date); let value = this.deadlineCache.get(date);
@@ -160,7 +226,7 @@ export class HabitService {
let records = this.recordsCache.get(id); let records = this.recordsCache.get(id);
if (!records) { if (!records) {
records = new Map(); records = new Map();
for (const day of this.db.select().from(habitDays).where(eq(habitDays.habitId, id)).orderBy(asc(habitDays.id)).all()) records.set(day.date, day); for (const day of this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), gte(habitDays.date, this.ranges.get(id)!.from), lte(habitDays.date, this.ranges.get(id)!.to), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`)).all()) records.set(day.date, day);
this.recordsCache.set(id, records); this.recordsCache.set(id, records);
} }
return records; return records;
@@ -169,15 +235,17 @@ export class HabitService {
let occurrences = this.occurrencesCache.get(id); let occurrences = this.occurrencesCache.get(id);
if (!occurrences) { if (!occurrences) {
occurrences = new Map(); occurrences = new Map();
const rows = this.db.select({ occurrence: taskOccurrences }).from(taskOccurrences).innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id)).where(eq(habitDays.habitId, id)).orderBy(asc(taskOccurrences.id)).all(); const rows = this.db.select({ occurrence: taskOccurrences }).from(taskOccurrences).innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id)).where(and(eq(habitDays.habitId, id), gte(habitDays.date, this.ranges.get(id)!.from), lte(habitDays.date, this.ranges.get(id)!.to), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`)).orderBy(asc(taskOccurrences.id)).all();
for (const { occurrence } of rows) { const entries = occurrences.get(occurrence.dayId) ?? []; entries.push(occurrence); occurrences.set(occurrence.dayId, entries); } for (const { occurrence } of rows) { const entries = occurrences.get(occurrence.dayId) ?? []; entries.push(occurrence); occurrences.set(occurrence.dayId, entries); }
this.occurrencesCache.set(id, occurrences); this.occurrencesCache.set(id, occurrences);
} }
return occurrences; return occurrences;
} }
private materialize(habit: Habit) { private materialize(habit: Habit, through = this.today, force = false) {
let date = habit.materializedThrough ?? habit.createdDate; let date = force ? through : habit.materializedThrough ? addDays(habit.materializedThrough, 1) : habit.createdDate;
while (date <= this.today) { if (date > through) return;
this.loadRevisions(habit.id, date, through);
while (date <= through) {
const revision = this.revision(habit.id, date)!; const revision = this.revision(habit.id, date)!;
const existing = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get(); const existing = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get();
if (!existing || existing.revisionId !== revision.id) { if (!existing || existing.revisionId !== revision.id) {
@@ -193,23 +261,43 @@ export class HabitService {
.innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id)) .innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id))
.where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date))) .where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date)))
.orderBy(desc(habitDays.id)).all().map(row => row.occurrence) : []; .orderBy(desc(habitDays.id)).all().map(row => row.occurrence) : [];
for (const task of revision.config.tasks.filter(t => scheduled(t.schedule, date))) { const occurrences = revision.config.tasks.filter(t => scheduled(t.schedule, date)).map(task => {
const old = previous.find(t => t.taskId === task.id); const old = previous.find(t => t.taskId === task.id);
this.db.insert(taskOccurrences).values({ id: crypto.randomUUID(), dayId: day.id, taskId: task.id, name: task.name, return { id: crypto.randomUUID(), dayId: day.id, taskId: task.id, name: task.name,
done: old?.done ?? false, expiredAt: old ? old.expiredAt : day.endsAt <= this.now ? day.endsAt : null, closedAt: old?.closedAt ?? (day.endsAt <= this.now ? day.endsAt : null), updatedAt: old?.updatedAt ?? this.now }).run(); done: old?.done ?? false, expiredAt: old ? old.expiredAt : day.endsAt <= this.now ? day.endsAt : null, closedAt: old?.closedAt ?? (day.endsAt <= this.now ? day.endsAt : null), updatedAt: old?.updatedAt ?? this.now };
} });
if (occurrences.length) this.db.insert(taskOccurrences).values(occurrences).run();
} }
} }
date = addDays(date, 1); date = addDays(date, 1);
} }
this.db.update(habits).set({ materializedThrough: this.today }).where(eq(habits.id, habit.id)).run(); this.db.update(habits).set({ materializedThrough: through }).where(eq(habits.id, habit.id)).run();
} }
private counts(id: string) { private counts(id: string) {
const cached = this.countCache.get(id); if (cached) return cached; const cached = this.countCache.get(id); if (cached) return cached;
const rows = this.db.select({ day: habitDays, config: habitRevisions.config }).from(habitDays) const range = this.ranges.get(id)!;
.innerJoin(habitRevisions, eq(habitDays.revisionId, habitRevisions.id)) const latest = sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`;
.where(and(eq(habitDays.habitId, id), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`)) const select = () => this.db.select({ day: habitDays, config: habitRevisions.config }).from(habitDays).innerJoin(habitRevisions, eq(habitDays.revisionId, habitRevisions.id));
.orderBy(asc(habitDays.date)).all(); const rows = select().where(and(eq(habitDays.habitId, id), gte(habitDays.date, range.from), lte(habitDays.date, range.to), latest)).orderBy(asc(habitDays.date)).all();
// Only carry-enabled ranges need a prefix. Walk backwards in bounded pages
// until a logged value or requirements reset establishes the starting state.
const first = rows.find(({day, config}) => config.method !== 'count' || config.archived || scheduled(config.schedule, day.date));
if (first?.config.method === 'count' && first.config.carryPartialProgress && !first.day.countSet) {
let before = range.from;
const prefix: typeof rows = [];
let found = false;
while (!found) {
const page = select().where(and(eq(habitDays.habitId, id), lt(habitDays.date, before), latest)).orderBy(desc(habitDays.date)).limit(64).all();
if (!page.length) break;
for (const entry of page) {
prefix.push(entry);
const { day, config } = entry;
if (config.method !== 'count' || config.archived || (scheduled(config.schedule, day.date) && (day.countSet || !config.carryPartialProgress))) { found = true; break; }
}
before = page.at(-1)!.day.date;
}
rows.unshift(...prefix.reverse());
}
const result = new Map<string, { value: number; carriedFrom: string | null }>(); const result = new Map<string, { value: number; carriedFrom: string | null }>();
let carry = 0; let source: string | null = null; let carry = 0; let source: string | null = null;
for (const { day, config } of rows) { for (const { day, config } of rows) {
@@ -223,6 +311,7 @@ export class HabitService {
} }
day(id: string, date: string) { day(id: string, date: string) {
this.owned(id); this.owned(id);
this.range(id, date);
const revision = this.revision(id, date); const revision = this.revision(id, date);
const record = this.records(id).get(date); const record = this.records(id).get(date);
const config = revision?.config; const config = revision?.config;
@@ -235,7 +324,7 @@ export class HabitService {
const value = !due ? 0 : config!.method === 'count' ? counted?.value ?? 0 : config!.method === 'manual' ? Number(record?.done ?? false) : tasks.filter(t => t.done).length; const value = !due ? 0 : config!.method === 'count' ? counted?.value ?? 0 : config!.method === 'manual' ? Number(record?.done ?? false) : tasks.filter(t => t.done).length;
const target = !config ? null : config.method === 'count' ? config.target : config.method === 'manual' ? 1 : tasks.length; const target = !config ? null : config.method === 'count' ? config.target : config.method === 'manual' ? 1 : tasks.length;
const ratio = due ? Math.min(1, value / target!) : null; const ratio = due ? Math.min(1, value / target!) : null;
return { date, habitId: id, name: config?.name ?? null, method: config?.method ?? null, revisionId: revision?.id ?? null, return { date, habitId: id, calendarRevision: this.owned(id).calendarRevision, name: config?.name ?? null, method: config?.method ?? null, revisionId: revision?.id ?? null,
timezone: record?.timezone ?? this.user.timezone, endsAt: record?.endsAt ?? null, due, future, timezone: record?.timezone ?? this.user.timezone, endsAt: record?.endsAt ?? null, due, future,
status: future ? 'future' : !due ? 'not_due' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty', status: future ? 'future' : !due ? 'not_due' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty',
value, target, carriedFrom: counted?.carriedFrom ?? null, loggedCount: record?.countSet ? record.count : null, unit: config?.method === 'count' ? config.unit : config?.method === 'tasks' ? 'tasks' : 'completion', value, target, carriedFrom: counted?.carriedFrom ?? null, loggedCount: record?.countSet ? record.count : null, unit: config?.method === 'count' ? config.unit : config?.method === 'tasks' ? 'tasks' : 'completion',
@@ -256,6 +345,7 @@ export class HabitService {
this.db.transaction(() => { this.db.transaction(() => {
this.db.insert(progressEvents).values({ dayId: record.id, before: { count: detail.value, done: record.done, loggedCount: record.countSet ? record.count : null, carriedFrom: detail.carriedFrom }, after: input, createdAt: this.now }).run(); this.db.insert(progressEvents).values({ dayId: record.id, before: { count: detail.value, done: record.done, loggedCount: record.countSet ? record.count : null, carriedFrom: detail.carriedFrom }, after: input, createdAt: this.now }).run();
this.db.update(habitDays).set('count' in input ? { ...input, countSet: true } : input).where(eq(habitDays.id, record.id)).run(); this.db.update(habitDays).set('count' in input ? { ...input, countSet: true } : input).where(eq(habitDays.id, record.id)).run();
this.changed(id);
}); });
this.invalidate(id); this.invalidate(id);
return this.day(id, date); return this.day(id, date);
@@ -267,6 +357,7 @@ export class HabitService {
this.db.transaction(() => { this.db.transaction(() => {
this.db.insert(progressEvents).values({ dayId: record.id, occurrenceId: occurrence.id, before: { done: occurrence.done }, after: { done }, createdAt: this.now }).run(); this.db.insert(progressEvents).values({ dayId: record.id, occurrenceId: occurrence.id, before: { done: occurrence.done }, after: { done }, createdAt: this.now }).run();
this.db.update(taskOccurrences).set({ done, updatedAt: this.now }).where(eq(taskOccurrences.id, occurrence.id)).run(); this.db.update(taskOccurrences).set({ done, updatedAt: this.now }).where(eq(taskOccurrences.id, occurrence.id)).run();
this.changed(id);
}); });
this.invalidate(id); this.invalidate(id);
return this.day(id, date); return this.day(id, date);
@@ -290,6 +381,7 @@ export class HabitService {
saveSettings(id: string, settings: CalendarSettings) { saveSettings(id: string, settings: CalendarSettings) {
this.owned(id); this.owned(id);
this.db.insert(habitCalendarSettings).values({ habitId: id, settings }).onConflictDoUpdate({ target: habitCalendarSettings.habitId, set: { settings } }).run(); this.db.insert(habitCalendarSettings).values({ habitId: id, settings }).onConflictDoUpdate({ target: habitCalendarSettings.habitId, set: { settings } }).run();
this.changed(id);
return settings; return settings;
} }
chart(id: string) { chart(id: string) {
@@ -306,8 +398,8 @@ export class HabitService {
return this.chart(id); return this.chart(id);
} }
deleteChart(id: string) { this.chart(id); this.db.delete(combinedCharts).where(eq(combinedCharts.id, id)).run(); } deleteChart(id: string) { this.chart(id); this.db.delete(combinedCharts).where(eq(combinedCharts.id, id)).run(); }
calendar(ids: string[], from: string, to: string, settings: CalendarSettings, combined: boolean) { calendar(ids: string[], from: string, to: string, settings: CalendarSettings, combined: boolean, compact = false) {
ids.forEach(id => this.owned(id)); ids.forEach(id => { this.owned(id); this.range(id, from, to); });
const days = []; const days = [];
for (let date = from; date <= to; date = addDays(date, 1)) { for (let date = from; date <= to; date = addDays(date, 1)) {
const details = ids.map(id => this.day(id, date)); const details = ids.map(id => this.day(id, date));
@@ -318,7 +410,10 @@ export class HabitService {
const progress = shade(ratio, steps, settings, !combined && (settings.shadeCount === 'auto' || manual)); const progress = shade(ratio, steps, settings, !combined && (settings.shadeCount === 'auto' || manual));
const future = date > this.today; const future = date > this.today;
days.push({ date, due: due.length, completed, ratio, future, status: future ? 'future' : ratio === null ? 'not_due' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty', days.push({ date, due: due.length, completed, ratio, future, status: future ? 'future' : ratio === null ? 'not_due' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty',
shadeCount: steps, ...progress, color: future ? settings.futureColor : progress.color, habits: details }); shadeCount: steps, ...progress, color: future ? settings.futureColor : progress.color, habits: details.map(detail => {
const { tasks, requirements, ...summary } = detail;
return { ...summary, tasks: compact ? undefined : tasks, requirements: compact ? undefined : requirements };
}) });
} }
return { kind: combined ? 'combined' as const : 'individual' as const, from, to, today: this.today, timezone: this.user.timezone, settings, days, return { kind: combined ? 'combined' as const : 'individual' as const, from, to, today: this.today, timezone: this.user.timezone, settings, days,
// Explicit colors and details are supplied for the client's Nivo colorScale/tooltip adapters. // Explicit colors and details are supplied for the client's Nivo colorScale/tooltip adapters.

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";
@@ -15,6 +16,7 @@ if (process.env.NODE_ENV !== "production") {
} }
const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined; const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined;
await backups?.ready;
if (import.meta.hot) import.meta.hot.dispose(() => backups?.stop()); if (import.meta.hot) import.meta.hot.dispose(() => backups?.stop());
const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin, commandsAvailable: /^[a-f\d]{64}$/i.test(process.env.DISCORD_PUBLIC_KEY ?? "") && Boolean(readAuthConfig().clientId) }) : undefined; const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin, commandsAvailable: /^[a-f\d]{64}$/i.test(process.env.DISCORD_PUBLIC_KEY ?? "") && Boolean(readAuthConfig().clientId) }) : undefined;
@@ -28,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,6 +1,6 @@
import { expect, test } from "bun:test"; import { expect, test } from "bun:test";
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { mkdtempSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { fixture } from "../habits/test-fixture"; import { fixture } from "../habits/test-fixture";
@@ -35,6 +35,7 @@ test("WAL snapshots restore committed progress, revoke sessions, replicate, and
expect(readdirSync(config.replica!)).toHaveLength(2); expect(readdirSync(config.replica!)).toHaveLength(2);
expect(readdirSync(config.directory)).toContain("keep.txt"); expect(readdirSync(config.directory)).toContain("keep.txt");
const runner = startBackups(f.sqlite, config); const runner = startBackups(f.sqlite, config);
await runner.ready;
expect(runner.state.failed).toBe(false); expect(runner.state.lastSuccess).toBeGreaterThan(0); runner.stop(); expect(runner.state.failed).toBe(false); expect(runner.state.lastSuccess).toBeGreaterThan(0); runner.stop();
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); } } finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
}); });
@@ -71,3 +72,78 @@ test("backup and restore CLIs work against a live database without overwriting i
expect(f.sqlite.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 }); expect(f.sqlite.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 });
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); } } finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
}); });
test('scheduled backups yield to the event loop, retain WAL data, and verify replicas', async () => {
const directory = mkdtempSync(join(tmpdir(), 'minabot-async-backup-'));
const path = join(directory, 'live.sqlite'), f = fixture(path);
let runner: ReturnType<typeof startBackups> | undefined;
try {
f.sqlite.exec('PRAGMA journal_mode=WAL');
await f.json('/habits', 'POST', { name: 'Async snapshot', method: 'manual' }, 201);
const config = backupConfig(path, { BACKUP_REPLICA_DIR: 'replica' });
mkdirSync(config.replica!);
let yielded = false;
const timer = new Promise<void>(resolve => setTimeout(() => { yielded = true; resolve(); }, 0));
runner = startBackups(path, config);
expect(runner.state.lastSuccess).toBe(0);
await runner.ready;
expect(yielded).toBe(true); await timer;
expect(runner.state.failed).toBe(false);
expect(runner.state.lastSuccess).toBeGreaterThan(0);
const snapshots = readdirSync(config.directory).filter(file => file.endsWith('.sqlite'));
expect(snapshots).toHaveLength(1);
const check = new Database(join(config.replica!, snapshots[0]!), { readonly: true });
try { expect(check.query('SELECT count(*) AS n FROM habits').get()).toEqual({ n: 1 }); } finally { check.close(); }
await runner.tick();
expect(readdirSync(config.directory).filter(file => file.endsWith('.sqlite'))).toHaveLength(1);
} finally { runner?.stop(); f.close(); rmSync(directory, { recursive: true, force: true }); }
});
test('scheduled backup ticks coalesce, expose failure and recovery, and stop admitting work', async () => {
const config = backupConfig('/tmp/test-only.sqlite', {});
let calls = 0, release!: (value: number) => void;
const run = async () => { calls++; return new Promise<number>(resolve => { release = resolve; }); };
const runner = startBackups('/tmp/test-only.sqlite', config, run);
expect(runner.tick()).toBe(runner.ready);
expect(calls).toBe(1);
release(123); await runner.ready;
expect(runner.state.lastSuccess).toBe(123);
const second = runner.tick(); expect(calls).toBe(2);
runner.stop(); release(456); await second;
await runner.tick(); expect(calls).toBe(2);
const failed = startBackups('/tmp/test-only.sqlite', config, async () => { throw new Error('test failure'); });
await failed.ready; expect(failed.state.failed).toBe(true); failed.stop();
const disabled = startBackups('/tmp/test-only.sqlite', { ...config, enabled: false }, run);
await disabled.ready; expect(calls).toBe(2); disabled.stop();
});
test('the first scheduled snapshot initializes cold WAL bookkeeping without changing source rows', async () => {
const directory = mkdtempSync(join(tmpdir(), 'minabot-cold-wal-'));
const path = join(directory, 'live.sqlite'), f = fixture(path);
f.sqlite.exec('PRAGMA journal_mode=DELETE'); f.close();
const db = new Database(path, { readwrite: true, create: false, strict: true });
let runner: ReturnType<typeof startBackups> | undefined;
try {
db.exec('PRAGMA journal_mode=WAL');
expect(existsSync(`${path}-shm`)).toBe(false);
runner = startBackups(db, backupConfig(path, {}));
await runner.ready;
expect(runner.state.failed).toBe(false);
expect(runner.state.lastSuccess).toBeGreaterThan(0);
expect(db.query('SELECT count(*) AS n FROM users').get()).toEqual({ n: 2 });
expect(db.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 });
expect(db.query('SELECT total_changes() AS n').get()).toEqual({ n: 0 });
} finally { runner?.stop(); db.close(); rmSync(directory, { recursive: true, force: true }); }
});
test('the scheduled snapshot never creates a missing source database', async () => {
const directory = mkdtempSync(join(tmpdir(), 'minabot-missing-source-'));
const path = join(directory, 'missing.sqlite');
const runner = startBackups(path, backupConfig(path, {}));
try {
await runner.ready;
expect(runner.state.failed).toBe(true);
expect(existsSync(path)).toBe(false);
} finally { runner.stop(); rmSync(directory, { recursive: true, force: true }); }
});

View File

@@ -1,3 +1,4 @@
import { fileURLToPath } from "node:url";
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from "node:fs"; import { chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from "node:fs";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
@@ -82,18 +83,40 @@ export function restoreBackup(source: string, destination: string) {
} finally { if (existsSync(temporary)) unlinkSync(temporary); } } finally { if (existsSync(temporary)) unlinkSync(temporary); }
return destination; return destination;
} }
export function startBackups(database: Database, config: BackupConfig) { /** Runs all filesystem and SQLite backup work in a separate Bun process. */
const state = { enabled: config.enabled, lastSuccess: 0, failed: false }; export async function runScheduledBackup(database: string, config: BackupConfig): Promise<number> {
if (!config.enabled) return { state, stop() {} }; const source = new URL("../../scripts/scheduled-backup.ts", import.meta.url);
const tick = () => { const built = new URL("../scripts/scheduled-backup.ts", import.meta.url);
try { const script = fileURLToPath(existsSync(source) ? source : built);
const latest = latestBackupTime(config); const child = Bun.spawn([process.execPath, script, resolve(database), JSON.stringify(config)], {
if (latest && Date.now() - latest < config.intervalMs) { state.lastSuccess = latest; state.failed = false; return; } stdout: "pipe", stderr: "ignore",
createBackup(database, config); state.lastSuccess = Date.now(); state.failed = false; });
console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date().toISOString() })); const [output, status] = await Promise.all([new Response(child.stdout).text(), child.exited]);
} catch { state.failed = true; console.error(JSON.stringify({ event: "backup_failed", timestamp: new Date().toISOString() })); } if (status !== 0) throw new Error("Scheduled backup process failed");
}; const { timestamp, created } = JSON.parse(output) as { timestamp: number; created: boolean };
tick(); if (!Number.isFinite(timestamp) || timestamp <= 0) throw new Error("Scheduled backup returned an invalid timestamp");
const timer = setInterval(tick, 60000); timer.unref(); if (created) console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date(timestamp).toISOString() }));
return { state, stop: () => clearInterval(timer) }; return timestamp;
}
export function startBackups(database: Database | string, config: BackupConfig, run = runScheduledBackup) {
const state = { enabled: config.enabled, lastSuccess: 0, failed: false };
let running: Promise<void> | undefined;
let stopped = false;
const tick = (): Promise<void> => {
if (stopped || !config.enabled) return Promise.resolve();
if (running) return running;
running = (async () => {
try {
const path = typeof database === 'string' ? database : database.filename;
if (!path || path === ':memory:') throw new Error('Scheduled backups require a file database');
state.lastSuccess = await run(path, config); state.failed = false;
} catch { state.failed = true; console.error(JSON.stringify({ event: "backup_failed", timestamp: new Date().toISOString() })); }
})().finally(() => { running = undefined; });
return running;
};
// Callers await readiness before accepting traffic, matching the original startup check.
const ready = tick();
const timer = config.enabled ? setInterval(() => { void tick(); }, 60000) : undefined;
timer?.unref();
return { state, ready, tick, stop: () => { stopped = true; clearInterval(timer); } };
} }

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,4 +1,4 @@
import { expect, test } from "bun:test"; import { expect, test, spyOn } from "bun:test";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { fixture } from "../habits/test-fixture"; import { fixture } from "../habits/test-fixture";
import { reminderDeliveries, reminderSettings } from "../db/schema"; import { reminderDeliveries, reminderSettings } from "../db/schema";
@@ -213,3 +213,55 @@ test("messages include names only for the applicable reminders in the delivery b
expect(JSON.parse(sent[1]!).allowed_mentions.parse).toEqual([]); expect(JSON.parse(sent[1]!).allowed_mentions.parse).toEqual([]);
} finally { f.close(); } } finally { f.close(); }
}); });
test('delivered slots skip habit evaluation while later slots and retries remain eligible', async () => {
const { f } = await setup(); const sent: string[] = [];
const { HabitService } = await import('../habits/service');
let sync: ReturnType<typeof spyOn> | undefined;
try {
f.db.update(reminderSettings).set({ quietStart: '00:00', quietEnd: '00:00', reminders: [
{ type: 'date', date: '2026-09-04', time: '10:00' }, { type: 'date', date: '2026-09-04', time: '12:00' },
] }).run();
const tick = (clock: string) => sendDueReminders(f.db, config, transport(sent), () => Date.parse(`2026-09-04T${clock}:00Z`));
await tick('08:00'); expect(sent).toHaveLength(1);
sync = spyOn(HabitService.prototype, 'sync');
for (let i = 0; i < 5; i++) await tick('08:30');
expect(sync).not.toHaveBeenCalled(); expect(sent).toHaveLength(1);
await tick('10:00'); expect(sync).toHaveBeenCalled(); expect(sent).toHaveLength(2);
} finally { sync?.mockRestore(); f.close(); }
});
test('reminder catch-up yields and rechecks opt-out before claiming or sending', async () => {
const f = fixture(); let requests = 0;
try {
await f.json('/habits', 'POST', { name: 'Backlog', method: 'tasks', tasks: Array.from({ length: 50 }, (_, i) => ({ name: `Task ${i}` })) }, 201);
f.db.insert(reminderSettings).values({ userId: 'alice', ...defaultReminder, enabled: true, updatedAt: 0 }).run();
const work = sendDueReminders(f.db, config, async () => { requests++; throw new Error('No sends expected'); }, () => Date.parse('2027-09-04T18:00:00Z'));
await new Promise(resolve => setTimeout(resolve, 0));
f.db.update(reminderSettings).set({ enabled: false }).run();
await work;
expect(requests).toBe(0);
expect(f.db.select().from(reminderDeliveries).all()).toHaveLength(0);
} finally { f.close(); }
});
test('an opt-out queued at readiness completion prevents the final reminder POST', async () => {
const { f } = await setup(); let posts = 0, evaluations = 0;
const { HabitService } = await import('../habits/service');
const original = HabitService.withPrepared;
const prepare = spyOn(HabitService, 'withPrepared').mockImplementation((db, user, now, run) => original(db, user, now, service => {
const result = run(service);
if (++evaluations === 2) queueMicrotask(() => f.db.update(reminderSettings).set({ enabled: false }).run());
return result;
}));
try {
await sendDueReminders(f.db, config, async url => {
if (!url.endsWith('/users/@me/channels')) posts++;
return Response.json({ id: '223456789012345678' });
}, () => Date.parse('2026-09-04T18:00:00Z'));
expect(evaluations).toBe(2);
expect(posts).toBe(0);
expect(f.db.select().from(reminderDeliveries).get()?.status).toBe('skipped');
} finally { prepare.mockRestore(); f.close(); }
});

View File

@@ -22,22 +22,42 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig,
// Retain recent delivery history and deduplication records without unbounded growth. // Retain recent delivery history and deduplication records without unbounded growth.
db.delete(reminderDeliveries).where(lt(reminderDeliveries.updatedAt, now() - 90 * 86400000)).run(); db.delete(reminderDeliveries).where(lt(reminderDeliveries.updatedAt, now() - 90 * 86400000)).run();
for (const { user } of rows) { for (const { user } of rows) {
function due() { function eligible(checkDeliveries = false) {
const currentUser = db.select().from(users).where(eq(users.id, user.id)).get(); const currentUser = db.select().from(users).where(eq(users.id, user.id)).get();
const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get(); const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get();
if (!currentUser || !prefs?.enabled) return null; if (!currentUser || !prefs?.enabled) return null;
const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone); const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone);
if (inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null; if (inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null;
const times = timesForDate(prefs, localDate(timestamp, currentUser.timezone)).filter(time => time <= clock); const date = localDate(timestamp, currentUser.timezone);
const times = timesForDate(prefs, date).filter(time => time <= clock);
if (!times.length) return null; if (!times.length) return null;
const service = new HabitService(db, { id: currentUser.id, discordId: currentUser.discordId, username: currentUser.username, displayName: currentUser.globalName ?? currentUser.username, avatarUrl: null, timezone: currentUser.timezone }, timestamp); if (checkDeliveries) {
service.sync(); const previous = db.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, date))).all();
// Avoid sending an artificial "today" after a backwards timezone change. // This is only a cheap eligibility hint. The transaction below still owns claims.
if (service.today !== localDate(timestamp, currentUser.timezone)) return null; // Let stale pending records reach its recovery step before skipping the user.
const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length; const stalePending = previous.some(row => row.status === 'pending' && row.updatedAt < timestamp - 60000);
return remaining ? { date: service.today, remaining, timezone: currentUser.timezone, times, entries: entriesForDate(prefs, service.today) } : null; if (!stalePending) {
const legacy = previous.find(row => row.slot === '*');
const claimable = (prior: typeof previous[number] | undefined) => !prior || (prior.status === 'deferred' && (prior.retryAt ?? 0) <= timestamp);
if (previous.some(row => row.status === 'pending') || (legacy ? !claimable(legacy) : !times.some(slot => claimable(previous.find(row => row.slot === slot))))) return null;
}
}
return { currentUser, prefs, date, times };
} }
const initial = due(); if (!initial) continue; function readDue(prepared?: HabitService, checkDeliveries = false) {
const latest = eligible(checkDeliveries); if (!latest) return null;
const owner = latest.currentUser;
const service = prepared ?? new HabitService(db, { id: owner.id, discordId: owner.discordId, username: owner.username, displayName: owner.globalName ?? owner.username, avatarUrl: null, timezone: owner.timezone }, now());
if (service.today !== latest.date || service.user.timezone !== owner.timezone) return null;
const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length;
return remaining ? { date: service.today, remaining, timezone: service.user.timezone, times: latest.times, entries: entriesForDate(latest.prefs, service.today) } : null;
}
async function due(checkDeliveries = false) {
const initial = eligible(checkDeliveries); if (!initial) return null;
const owner = initial.currentUser;
return HabitService.withPrepared(db, { id: owner.id, discordId: owner.discordId, username: owner.username, displayName: owner.globalName ?? owner.username, avatarUrl: null, timezone: owner.timezone }, now, service => readDue(service, checkDeliveries));
}
const initial = await due(true); if (!initial) continue;
const claims = db.transaction(tx => { const claims = db.transaction(tx => {
// A crashed/aborted send must not block later times indefinitely. // A crashed/aborted send must not block later times indefinitely.
tx.update(reminderDeliveries).set({ status: "uncertain" }).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.status, "pending"), lt(reminderDeliveries.updatedAt, now() - 60000))).run(); tx.update(reminderDeliveries).set({ status: "uncertain" }).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.status, "pending"), lt(reminderDeliveries.updatedAt, now() - 60000))).run();
@@ -75,7 +95,10 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig,
const destination = channel.ok ? await channel.json().catch(() => null) as { id?: string } | null : null; const destination = channel.ok ? await channel.json().catch(() => null) as { id?: string } | null : null;
if (!destination?.id || !/^\d{17,20}$/.test(destination.id)) { update("failed"); continue; } if (!destination?.id || !/^\d{17,20}$/.test(destination.id)) { update("failed"); continue; }
// A user may opt out or finish a habit while Discord opens the DM channel. // A user may opt out or finish a habit while Discord opens the DM channel.
const latest = due(); const prepared = await due();
// Releasing readiness can run a queued edit before this continuation. Re-read
// synchronously, with fresh caches, and invoke POST without another yield.
const latest = prepared ? readDue() : null;
if (!latest || latest.date !== initial.date || !claims.some(claim => claim.slot === "*" || latest.times.includes(claim.slot))) { update("skipped"); continue; } if (!latest || latest.date !== initial.date || !claims.some(claim => claim.slot === "*" || latest.times.includes(claim.slot))) { update("skipped"); continue; }
const names = [...new Set(latest.entries.filter(entry => claims.some(claim => claim.slot === "*" ? latest.times.includes(entry.time) : claim.slot === entry.time)).map(entry => entry.name?.trim()).filter((name): name is string => Boolean(name)))]; const names = [...new Set(latest.entries.filter(entry => claims.some(claim => claim.slot === "*" ? latest.times.includes(entry.time) : claim.slot === entry.time)).map(entry => entry.name?.trim()).filter((name): name is string => Boolean(name)))];
// Bound the label prefix so the count and link always fit Discord's message limit. // Bound the label prefix so the count and link always fit Discord's message limit.

View File

@@ -0,0 +1,27 @@
import { expect, test } from 'bun:test';
import { createRenderQueue, RenderBusyError } from './queue';
const result = new Uint8Array([1, 2, 3]);
test('render queue bounds global concurrency and shares only the same owner and content', async () => {
const queue = createRenderQueue({ concurrency: 2, maxPending: 4, maxPerUser: 2 });
let active = 0, peak = 0, calls = 0;
const run = async () => { calls++; active++; peak = Math.max(peak, active); await Bun.sleep(5); active--; return result; };
const first = queue('alice', 'one', run);
expect(queue('alice', 'one', run)).toBe(first);
const all = [first, queue('alice', 'two', run), queue('bob', 'one', run), queue('carol', 'one', run)];
await Promise.all(all); expect(calls).toBe(4); expect(peak).toBe(2);
expect(await queue('alice', 'one', run)).toBe(result); expect(calls).toBe(5);
});
test('render admission enforces per-user and global capacity and recovers after failures', async () => {
const queue = createRenderQueue({ concurrency: 1, maxPending: 2, maxPerUser: 1 });
let release!: () => void;
const first = queue('alice', 'a', async () => { await new Promise<void>(resolve => { release = resolve; }); throw new Error('render failed'); });
const failure = first.catch(error => error);
await expect(queue('alice', 'b', async () => result)).rejects.toBeInstanceOf(RenderBusyError);
const second = queue('bob', 'b', async () => result);
await expect(queue('carol', 'c', async () => result)).rejects.toBeInstanceOf(RenderBusyError);
while (!release) await Bun.sleep(1);
release(); expect((await failure).message).toBe('render failed'); await second;
expect(await queue('alice', 'a', async () => result)).toBe(result);
});

42
src/rendering/queue.ts Normal file
View File

@@ -0,0 +1,42 @@
import { createHash } from 'node:crypto';
import { ApiError } from '../habits/service';
export class RenderBusyError extends ApiError {
constructor() { super(429, 'Image rendering is busy. Try again shortly.'); }
}
type Job = { run: () => Promise<Uint8Array>; resolve: (image: Uint8Array) => void; reject: (reason: unknown) => void };
/** One process-wide admission boundary for every server canvas renderer. */
export function createRenderQueue({ concurrency = 2, maxPending = 16, maxPerUser = 2 } = {}) {
const pending = new Map<string, Promise<Uint8Array>>();
const owners = new Map<string, number>();
const waiting: Job[] = [];
let active = 0;
function pump() {
while (active < concurrency && waiting.length) {
const job = waiting.shift()!; active++;
// Yield before drawing so a burst of callers cannot monopolize this turn.
setTimeout(() => {
void Promise.resolve().then(job.run).then(job.resolve, job.reject).finally(() => { active--; pump(); });
}, 0);
}
}
return (userId: string, key: string, run: Job['run']): Promise<Uint8Array> => {
const scopedKey = JSON.stringify([userId, key]);
const existing = pending.get(scopedKey);
if (existing) return existing;
if (pending.size >= maxPending || (owners.get(userId) ?? 0) >= maxPerUser) return Promise.reject(new RenderBusyError());
owners.set(userId, (owners.get(userId) ?? 0) + 1);
const work = new Promise<Uint8Array>((resolve, reject) => { waiting.push({ run, resolve, reject }); }).finally(() => {
pending.delete(scopedKey);
const count = owners.get(userId)! - 1;
if (count) owners.set(userId, count); else owners.delete(userId);
});
pending.set(scopedKey, work); pump();
return work;
};
}
const schedule = createRenderQueue();
export function renderImage(kind: string, userId: string, data: unknown, theme: string, run: Job['run']) {
const hash = createHash('sha256').update(JSON.stringify([kind, data, theme])).digest('hex');
return schedule(userId, hash, run);
}

View File

@@ -0,0 +1,38 @@
import { expect, test } from 'bun:test';
import { createCanvas } from '@napi-rs/canvas';
import { createAvatarCache } from './avatar-cache';
test('avatars share in-flight downloads and invalidate by URL or expiry', async () => {
const png = await createCanvas(16, 16).encode('png');
let calls = 0, timestamp = 0;
const request = Object.assign(async () => { calls++; return new Response(new Uint8Array(png)); }, { preconnect: fetch.preconnect });
const avatar = createAvatarCache({ request, now: () => timestamp, successTtlMs: 100 });
const [one, two] = await Promise.all([avatar('avatar/hash1'), avatar('avatar/hash1')]);
expect(one).not.toBeNull(); expect(one).toBe(two); expect(calls).toBe(1);
expect(await avatar('avatar/hash1')).toBe(one); expect(calls).toBe(1);
await avatar('avatar/hash2'); expect(calls).toBe(2);
timestamp = 100; await avatar('avatar/hash1'); expect(calls).toBe(3);
});
test('avatar cache evicts least recently used images and retries negative entries after their TTL', async () => {
const png = await createCanvas(16, 16).encode('png');
let calls = 0, timestamp = 0, fail = false;
const request = Object.assign(async () => { calls++; return fail ? new Response(null, { status: 503 }) : new Response(new Uint8Array(png)); }, { preconnect: fetch.preconnect });
const avatar = createAvatarCache({ request, now: () => timestamp, maxEntries: 2, failureTtlMs: 10 });
await avatar('a'); await avatar('b'); await avatar('a'); await avatar('c');
expect(calls).toBe(3); await avatar('b'); expect(calls).toBe(4);
fail = true;
expect(await avatar('bad')).toBeNull(); expect(await avatar('bad')).toBeNull(); expect(calls).toBe(5);
timestamp = 10; fail = false;
expect(await avatar('bad')).not.toBeNull(); expect(calls).toBe(6);
});
test('avatar downloads have a concurrency bound and oversized decoded images use the fallback', async () => {
let release!: (response: Response) => void, calls = 0;
const request = Object.assign(async () => { calls++; return new Promise<Response>(resolve => { release = resolve; }); }, { preconnect: fetch.preconnect });
const avatar = createAvatarCache({ request, maxPending: 1 });
const first = avatar('one'), duplicate = avatar('one');
expect(await avatar('two')).toBeNull(); expect(calls).toBe(1);
release(new Response(new Uint8Array(await createCanvas(257, 1).encode('png'))));
expect(await first).toBeNull(); expect(await duplicate).toBeNull();
});

View File

@@ -0,0 +1,61 @@
import { loadImage } from '@napi-rs/canvas';
async function boundedBytes(response: Response) {
const reader = response.body?.getReader();
if (!reader) return null;
const chunks: Uint8Array[] = []; let size = 0;
try {
while (true) {
const { value, done } = await reader.read();
if (done) return Buffer.concat(chunks);
size += value.byteLength;
if (size > 1024 * 1024) { await reader.cancel(); return null; }
chunks.push(value);
}
} finally { reader.releaseLock(); }
}
type Avatar = Awaited<ReturnType<typeof loadImage>>;
type Options = {
request?: typeof fetch;
now?: () => number;
maxEntries?: number;
maxPending?: number;
successTtlMs?: number;
failureTtlMs?: number;
};
/** Hash-based Discord URLs invalidate naturally; bound both cached images and downloads. */
export function createAvatarCache({ request = ((url, init) => fetch(url, init)) as typeof fetch, now = Date.now, maxEntries = 128, maxPending = 4, successTtlMs = 3600000, failureTtlMs = 30000 }: Options = {}) {
const cache = new Map<string, { image: Avatar | null; expires: number }>();
const pending = new Map<string, Promise<Avatar | null>>();
return async (url: string): Promise<Avatar | null> => {
const cached = cache.get(url);
if (cached && cached.expires > now()) {
cache.delete(url); cache.set(url, cached);
return cached.image;
}
cache.delete(url);
const existing = pending.get(url);
if (existing) return existing;
if (pending.size >= maxPending) return null;
const job = (async () => {
let image: Avatar | null = null;
try {
const response = await request(url, { redirect: 'error', signal: AbortSignal.timeout(3000) });
if (response.ok) {
const bytes = await boundedBytes(response);
if (bytes) {
const decoded = await loadImage(Buffer.from(bytes));
if (decoded.width <= 256 && decoded.height <= 256) image = decoded;
}
}
} catch { /* A brief negative cache keeps an unavailable CDN out of the hot path. */ }
cache.set(url, { image, expires: now() + (image ? successTtlMs : failureTtlMs) });
while (cache.size > maxEntries) cache.delete(cache.keys().next().value!);
return image;
})().finally(() => { pending.delete(url); });
pending.set(url, job);
return job;
};
}
export const cachedAvatar = createAvatarCache();

View File

@@ -1,7 +1,9 @@
import { createCanvas, GlobalFonts, loadImage } from "@napi-rs/canvas"; import { renderImage } from '../rendering/queue';
import { cachedAvatar } from './avatar-cache';
import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { AppDatabase } from "../auth"; import { publicUser, type AppDatabase } from "../auth";
import { users } from "../db/schema"; import { users } from "../db/schema";
import { HabitService, ApiError } from "../habits/service"; import { HabitService, ApiError } from "../habits/service";
import { addDays } from "../habits/calendar"; import { addDays } from "../habits/calendar";
@@ -23,11 +25,16 @@ for (const [file, family] of [
} }
} }
export function liveCardData(db: AppDatabase, userId: string, now: number) { export async function loadLiveCardData(db: AppDatabase, userId: string, now: () => number) {
const user = db.select().from(users).where(eq(users.id, userId)).get();
if (!user) throw new ApiError(404, 'Account unavailable.');
return HabitService.withPrepared(db, publicUser(user), now, service => liveCardData(db, userId, service.now, service));
}
export function liveCardData(db: AppDatabase, userId: string, now: number, prepared?: HabitService) {
const user = db.select().from(users).where(eq(users.id, userId)).get(); const user = db.select().from(users).where(eq(users.id, userId)).get();
if (!user) throw new ApiError(404, "Account unavailable."); if (!user) throw new ApiError(404, "Account unavailable.");
const service = new HabitService(db, { ...user, displayName: user.globalName ?? user.username, avatarUrl: null }, now); const service = prepared ?? new HabitService(db, { ...user, displayName: user.globalName ?? user.username, avatarUrl: null }, now);
service.sync(); if (!prepared) service.sync();
const from = addDays(service.today, -29); const from = addDays(service.today, -29);
const avatarUrl = /^\d{17,20}$/.test(user.discordId) && /^(?:a_)?[a-f0-9]{32}$/i.test(user.avatarHash ?? '') const avatarUrl = /^\d{17,20}$/.test(user.discordId) && /^(?:a_)?[a-f0-9]{32}$/i.test(user.avatarHash ?? '')
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatarHash}.png?size=128` : null; ? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatarHash}.png?size=128` : null;
@@ -47,18 +54,15 @@ export function liveCardLayout(count: number) {
} }
/** A deterministic 16:9 PNG. Every active habit receives a panel; none are paginated away. */ /** A deterministic 16:9 PNG. Every active habit receives a panel; none are paginated away. */
export async function renderLiveCard(data: LiveImageData, theme: Theme) { export function renderLiveCard(data: LiveImageData, theme: Theme, userId = 'unscoped') {
return renderImage('live', userId, data, theme, () => draw(data, theme));
}
async function draw(data: LiveImageData, theme: Theme) {
const layout = liveCardLayout(data.habits.length); const layout = liveCardLayout(data.habits.length);
const canvas = createCanvas(layout.width, layout.height); const canvas = createCanvas(layout.width, layout.height);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const colors = themePalette[theme]; const colors = themePalette[theme];
let avatar: Awaited<ReturnType<typeof loadImage>> | null = null; const avatar = data.avatarUrl ? await cachedAvatar(data.avatarUrl) : null;
if (data.avatarUrl) {
try {
const response = await fetch(data.avatarUrl, { redirect: 'error', signal: AbortSignal.timeout(3000) });
if (response.ok) avatar = await loadImage(Buffer.from(await response.arrayBuffer()));
} catch { /* Match the snapshot card's neutral avatar fallback. */ }
}
const rect = (x: number, y: number, w: number, h: number, color: string, radius = 0) => { const rect = (x: number, y: number, w: number, h: number, color: string, radius = 0) => {
ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, Math.max(0, w), Math.max(0, h), radius); ctx.fill(); ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, Math.max(0, w), Math.max(0, h), radius); ctx.fill();
}; };
@@ -132,5 +136,6 @@ export async function renderLiveCard(data: LiveImageData, theme: Theme) {
rect(64, 1010, 1792, 1, colors.rule); rect(64, 1010, 1792, 1, colors.rule);
text('Totals cover this entire period', 64, 1050, 28, 1200, colors.muted); text('Totals cover this entire period', 64, 1050, 28, 1200, colors.muted);
ctx.textAlign = 'right'; text(`${data.habits.length} active habits`, 1856, 1050, 28, 536, colors.muted); ctx.textAlign = 'right'; text(`${data.habits.length} active habits`, 1856, 1050, 28, 536, colors.muted);
return canvas.encode('png'); try { return new Uint8Array(await canvas.encode('png')); }
finally { canvas.width = 1; canvas.height = 1; }
} }

View File

@@ -1,3 +1,4 @@
import { RenderBusyError } from '../rendering/queue';
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { Hono } from "hono"; import { Hono } from "hono";
@@ -7,7 +8,7 @@ import type { AppDatabase, AuthEnv, createAuth } from "../auth";
import { liveDiscordCards, users } from "../db/schema"; import { liveDiscordCards, users } from "../db/schema";
import { ApiError } from "../habits/service"; import { ApiError } from "../habits/service";
import { endOfDay, localDate } from "../habits/calendar"; import { endOfDay, localDate } from "../habits/calendar";
import { liveCardData, renderLiveCard } from "./live-card-image"; import { loadLiveCardData, renderLiveCard } from "./live-card-image";
import type { PublicUser } from "../shared/user"; import type { PublicUser } from "../shared/user";
import type { DiscordSharingConfig } from "./config"; import type { DiscordSharingConfig } from "./config";
import type { DiscordFetch } from "./routes"; import type { DiscordFetch } from "./routes";
@@ -64,7 +65,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
const delay = Math.max(current.retryAt ?? 0, channelRetryAt) - now(); const delay = Math.max(current.retryAt ?? 0, channelRetryAt) - now();
if (delay > 0) { schedule(id, delay); return; } if (delay > 0) { schedule(id, delay); return; }
update(id, { dirty: false, retryAt: null }); update(id, { dirty: false, retryAt: null });
const data = liveCardData(db, id, now()); const data = await loadLiveCardData(db, id, now);
// Hash the rendered content, excluding timestamps and private task details. // Hash the rendered content, excluding timestamps and private task details.
const hash = createHash('sha256').update(JSON.stringify({ data, theme: current.theme, renderer: 3, content: liveMessageText })).digest('hex'); const hash = createHash('sha256').update(JSON.stringify({ data, theme: current.theme, renderer: 3, content: liveMessageText })).digest('hex');
if (current.messageId && current.imageHash === hash) { if (current.messageId && current.imageHash === hash) {
@@ -81,7 +82,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
guildId = channel.guild_id; guildId = channel.guild_id;
} catch { update(id, { status: 'error', error: 'Could not reach the Discord channel. Try again.' }); return; } } catch { update(id, { status: 'error', error: 'Could not reach the Discord channel. Try again.' }); return; }
} }
const png = await renderLiveCard(data, current.theme); const png = await renderLiveCard(data, current.theme, id);
if (png.byteLength > 4 * 1024 * 1024) { update(id, { status: 'error', error: 'The rendered image exceeds the 4 MB sharing limit.' }); return; } if (png.byteLength > 4 * 1024 * 1024) { update(id, { status: 'error', error: 'The rendered image exceeds the 4 MB sharing limit.' }); return; }
// A stop or account deletion during image generation cancels the outbound edit. // A stop or account deletion during image generation cancels the outbound edit.
if (stopped || !['active', 'sending'].includes(row(id)?.status ?? '')) return; if (stopped || !['active', 'sending'].includes(row(id)?.status ?? '')) return;
@@ -139,7 +140,11 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
clearTimeout(timers.get(id)); timers.delete(id); clearTimeout(timers.get(id)); timers.delete(id);
const existing = running.get(id); const existing = running.get(id);
if (existing) return existing; if (existing) return existing;
const work = queue.then(() => deliver(id)).catch(() => { const work = queue.then(() => deliver(id)).catch(error => {
if (error instanceof RenderBusyError && !stopped && ['active', 'sending'].includes(row(id)?.status ?? '')) {
update(id, { dirty: true, retryAt: now() + 1000, error: 'Image rendering is busy; update queued.' });
schedule(id, 1000); return;
}
if (!stopped && row(id)) { update(id, { status: 'error', error: 'Could not render or update the card. Try resuming updates.' }); } if (!stopped && row(id)) { update(id, { status: 'error', error: 'Could not render or update the card. Try resuming updates.' }); }
}).finally(() => running.delete(id)); }).finally(() => running.delete(id));
queue = work; running.set(id, work); return work; queue = work; running.set(id, work); return work;
@@ -152,7 +157,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
app.get('/', c => c.json(state(c.get('user').id))); app.get('/', c => c.json(state(c.get('user').id)));
app.get('/preview', async c => { app.get('/preview', async c => {
const theme = c.req.query('theme') === 'dark' ? 'dark' : 'light'; const theme = c.req.query('theme') === 'dark' ? 'dark' : 'light';
const image = await renderLiveCard(liveCardData(db, c.get('user').id, now()), theme); const image = await renderLiveCard(await loadLiveCardData(db, c.get('user').id, now), theme, c.get('user').id);
c.header('Content-Type', 'image/png'); c.header('Content-Disposition', 'inline; filename="minabot-live-progress.png"'); c.header('Content-Type', 'image/png'); c.header('Content-Disposition', 'inline; filename="minabot-live-progress.png"');
return c.body(new Uint8Array(image)); return c.body(new Uint8Array(image));
}); });

View File

@@ -78,17 +78,19 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
app.post("/preview", async c => { app.post("/preview", async c => {
const input = await json(c.req.raw, shareInput); const input = await json(c.req.raw, shareInput);
const service = new HabitService(db, c.get("user"), now()); service.sync(); const result = await HabitService.withPrepared(db, c.get("user"), now, service => {
if (input.to > service.today) throw new ApiError(422, "The end date cannot be after today."); if (input.to > service.today) throw new ApiError(422, "The end date cannot be after today.");
const result: ShareData = { from: input.from, to: input.to, timezone: service.user.timezone, habits: input.habitIds.map(id => { const result: ShareData = { from: input.from, to: input.to, timezone: service.user.timezone, habits: input.habitIds.map(id => {
const habit = service.current(id); const habit = service.current(id);
const chart = service.calendar([id], input.from, input.to, service.settings(id), false); const chart = service.calendar([id], input.from, input.to, service.settings(id), false);
return { name: habit.name, color: chart.settings.mainColor, return { name: habit.name, color: chart.settings.mainColor,
legend: { empty: chart.settings.emptyColor, complete: chart.settings.mainColor, legend: { empty: chart.settings.emptyColor, complete: chart.settings.mainColor,
partial: chart.days.some(d => d.shadeCount > 1) ? [0.25, 0.5, 0.75].map(ratio => shade(ratio, 4, chart.settings, false).color) : [] }, partial: chart.days.some(d => d.shadeCount > 1) ? [0.25, 0.5, 0.75].map(ratio => shade(ratio, 4, chart.settings, false).color) : [] },
completed: chart.days.filter(d => d.completed > 0).length, scheduled: chart.days.filter(d => d.due > 0).length, completed: chart.days.filter(d => d.completed > 0).length, scheduled: chart.days.filter(d => d.due > 0).length,
days: chart.days.map(d => ({ date: d.date, color: d.color, due: d.due > 0, complete: d.completed > 0, ratio: d.ratio })) }; days: chart.days.map(d => ({ date: d.date, color: d.color, due: d.due > 0, complete: d.completed > 0, ratio: d.ratio })) };
}) }; }) };
return result;
});
result.botAvatarUrl = await botAvatarUrl(); result.botAvatarUrl = await botAvatarUrl();
return c.json(result); return c.json(result);
}); });

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;