Compare commits

...

29 Commits

Author SHA1 Message Date
syntaxbullet
7be401d105 Move app installation to a prominent header button 2026-10-25 14:43:55 +01:00
syntaxbullet
61001188b7 Simplify application copy and improve landing page spacing 2026-10-25 14:42:39 +01:00
syntaxbullet
36706c6734 Improve overview rendering with custom colors and graphical details 2026-10-25 14:30:17 +01:00
syntaxbullet
c9b1ba850f Add personal progress overviews with saved combined calendars 2026-10-24 20:34:37 +02:00
syntaxbullet
7a0cae8f08 fix(calendar): show unscheduled markers on future days off 2026-10-24 20:23:54 +02:00
syntaxbullet
ccd998ab04 Add service worker implementation and tests for PWA caching strategy
- Implemented a service worker (sw.js) that handles installation, activation, and fetch events.
- Added caching logic for public assets and a fallback for offline navigation.
- Created unit tests (sw.test.ts) to validate service worker behavior, including asset caching, navigation handling, and activation logic.
- Ensured that private API routes and third-party requests are excluded from caching.
2026-09-08 17:19:12 +02:00
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
95 changed files with 5044 additions and 342 deletions

View File

@@ -1,5 +1,9 @@
# Mina Habits
The production app is installable as a PWA, with an offline reconnect screen and
user-controlled updates. Habit data and changes require a connection. See
[PWA behavior and verification](docs/PWA.md).
Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The React Router
client includes a homepage for signed-out visitors and a personal habit dashboard.
@@ -81,7 +85,22 @@ have retry states. The dashboard refreshes on window focus and every minute whil
visible, using the server’s date and saved account timezone. Select a calendar date and choose **Edit selected check-in** to correct counts,
checkboxes, or task occurrences using that date’s saved requirements. A date picker
also reaches history outside the displayed year. Future dates and days off are read-only.
Combined-chart management is not exposed on this homepage.
Saved combined calendars appear alongside individual habits in the same list,
with a colored title and a **Combined chart** subtitle.
Choose **Create overview**, name the group, and select 1–100 habits, including
archived habits for historical review. Choose a palette or custom hex color for
the title and graph; colors adapt to light and dark themes. All saved overviews
are visible without a selector. The heading’s edit control changes the name,
color, or membership. Membership changes
recalculate the entire chart using each habit’s historical requirements.
The calendar starts with three months and supports longer and custom ranges.
**View day details** shows a completion ring, count and task progress bars,
manual check-in symbols, and task completion indicators alongside exact values.
Days off and future dates use neutral states. Each due habit opens the existing
check-in editor.
Corrections refresh the combined calendar and dashboard. Days off are excluded
from totals; partial progress does not count as a completed habit. **Delete
overview** requires confirmation and keeps all habits and check-ins.
### Daily check-in experience

View File

@@ -1,6 +1,7 @@
import tailwind from "bun-plugin-tailwind";
import { rm, mkdir, copyFile } from "node:fs/promises";
import path from "node:path";
import { buildPwa } from "./scripts/build-pwa";
const outdir = path.join(process.cwd(), "dist");
await rm(outdir, { recursive: true, force: true });
@@ -10,8 +11,9 @@ const result = await Bun.build({
outdir,
plugins: [tailwind],
minify: true,
splitting: true,
target: "bun",
external: ["@napi-rs/canvas"],
external: ["@napi-rs/canvas", "/fonts/browser/*"],
sourcemap: "linked",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
@@ -30,3 +32,10 @@ await mkdir(path.join(outdir, "fonts"), { recursive: true });
for (const file of ["InstrumentSerif-Regular.ttf", "NotoSans-Regular.ttf", "NotoSans-Medium.ttf", "NotoSans-SemiBold.ttf"]) {
await copyFile(`src/assets/fonts/${file}`, path.join(outdir, "fonts", file));
}
await mkdir(path.join(outdir, "fonts/browser"), { recursive: true });
for (const file of ["InstrumentSerif-Regular", "InstrumentSerif-Italic", "NotoSans-Regular", "NotoSans-Medium", "NotoSans-SemiBold"]) {
await copyFile(`src/assets/fonts/${file}.woff2`, path.join(outdir, `fonts/browser/${file}.woff2`));
}
await buildPwa(outdir, result.outputs);

4
bun-env.d.ts vendored
View File

@@ -9,6 +9,10 @@ declare module "*.svg" {
}
declare module "*.css" {}
declare module "*.webmanifest" {
const content: string;
export default content;
}
declare module "*.png" {
const path: string;
export default path;

View File

@@ -26,6 +26,7 @@
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"happy-dom": "^20.14.0",
"playwright": "^1.63.0",
"tailwindcss": "^4.1.11",
"typescript": "^7.0.2",
},
@@ -290,6 +291,10 @@
"lucide-react": ["lucide-react@1.40.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MaG+8WnOXkDWz9XeElj7TnQ890tTZUB0a36i03aRRCKGWE6e7jJpmdCvtxxuxcjjdqyN6m4sL6qlHVuSUDtYgg=="],
"playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],

View File

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

103
docs/PWA.md Normal file
View File

@@ -0,0 +1,103 @@
# Progressive web app
Mina Habits has an installable production build with a public offline app shell.
Its manifest supplies a stable app identity and root scope, standalone display,
192px and 512px icons, a separate maskable icon, app colors and descriptions.
The HTML includes an Apple touch icon, mobile metadata, theme color and safe-area
support. Browser chrome follows the selected light or dark theme.
Install from **Install app** in the footer. Supporting browsers show their native
install prompt. Other browsers receive instructions for Add to Home Screen or
Add to Dock. Installed windows hide the install action.
## Offline behavior
After one successful online visit and service-worker installation, the application
shell, fonts, icons and lazy browser chunks are available offline. Opening or
reloading the app offline shows a reconnect screen instead of the browser error
page. Nested routes also resolve correctly. Accounts retry on reconnection and
periodically while a failed connection is visible, including when the browser
incorrectly reports that it is online. An account request has a 10-second timeout;
page navigations fall back to the cached shell after four seconds.
**Account access, habit changes, sign-in, exports and Discord actions require a
connection.** Existing dashboard content can remain visible in an open window,
but no account responses are persisted for offline use and no mutations are
queued or replayed. Failed saves must be retried by the user. This is an offline
fallback, not offline habit editing or background synchronization.
All `/api` traffic, including OAuth callback navigations, bypasses the service
worker. API responses have `Cache-Control: no-store`. Only explicitly public
build assets enter Cache Storage; server JavaScript, source maps, account data,
exports and third-party responses are excluded. Clearing site data removes the
public offline files; it does not remove account records from the server.
## Build and updates
`bun run build` runs `scripts/build-pwa.ts` after Bun emits the browser and server
bundles. It derives a release identifier from the worker source and public asset
contents, copies a version-specific public HTML shell, and emits `dist/pwa/sw.js`
with an explicit precache list. A failed precache download prevents installation
of that release. The stable `/sw.js` route is served as JavaScript with
`Cache-Control: no-cache` and root service-worker scope.
Updates wait until existing windows close or the user chooses **Update app**,
then **Reload and update**. The confirmation tells the user to save unfinished
changes. Only the accepting window reloads; other windows retain their drafts
and receive their own update action. Old static caches remain available for their
lazy chunks. A subsequent activation with no open app windows removes obsolete
Mina Habits caches, leaving unrelated caches untouched. Checking for updates on
return to the app, reconnection and hourly supports long-lived installed windows.
The source manifest is `src/pwa/manifest.webmanifest`. Bun fingerprints its HTML
link; an explicit `/manifest.webmanifest` endpoint serves the same document. The
manifest's stable `id` keeps app identity independent of its asset filename.
Install icons reuse `docs/design/mina-habits-icon.png`; regenerate their exact
sizes with `bun scripts/pwa-icons.ts`. The maskable derivative adds opaque padding
to keep the monogram inside the central safe zone. Existing favicon and Apple
icons are preserved.
## Serving requirements
Use the normal production build/start or Docker workflow at the root of an HTTPS
origin. Local testing may use `http://localhost` or `http://127.0.0.1`. The current
production configuration already requires an HTTPS origin outside loopback and
uses Caddy for TLS. No new deployment environment variables are required.
Service-worker registration is deliberately disabled in development/HMR mode;
run the production smoke check to verify caching. Deploy the entire build
atomically, including `dist/pwa`, rather than uploading individual browser files.
Do not apply immutable HTTP caching to `/sw.js` or add a proxy cache for `/api`.
PWA installation support and its interface vary by browser. Push notifications,
background sync and app-store packaging are optional capabilities, not
installation requirements. This implementation follows the current
[MDN installability requirements](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable)
and the documented
[service-worker lifecycle](https://web.dev/articles/service-worker-lifecycle).
## Verification
```sh
bun run typecheck
bun test
bun x playwright install chromium
bun run test:pwa
bun run test:smoke
```
`test:pwa` copies the production build into a temporary directory, starts it with
a disposable SQLite database and no Discord credentials, then runs Chromium.
It verifies manifest parsing and Chrome installability errors, icon dimensions,
MIME/cache headers, first installation, offline startup and nested/lazy routes,
reconnection, responsive layouts, theme color, failed-update recovery, a two-window
update with an unsaved draft, API-cache exclusion, sign-out and mobile install
instructions. Screenshots are written to `.cache/pwa/`. Temporary data and the
server are removed when the check finishes. Worker unit tests cover routing,
installation failure and cache cleanup.
Before a public release, also exercise installation and standalone relaunch on a
physical iPhone/iPad and Android device over the production HTTPS origin. Desktop
Chromium checks and mobile emulation do not establish native OS installation or
Safari behavior. The implementation does not claim a universal PWA certification.

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,
"tag": "0012_discord_commands",
"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

@@ -14,6 +14,7 @@
"db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts",
"test:coverage": "bun test --coverage",
"test:smoke": "bun run build && bun scripts/api-smoke.ts",
"test:pwa": "bun run build && bun scripts/pwa-smoke.ts",
"db:backup": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/backup.ts",
"db:restore": "bun scripts/restore.ts",
"monitor": "bun --env-file=.env --env-file=.env.production scripts/monitor.ts"
@@ -40,6 +41,7 @@
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"happy-dom": "^20.14.0",
"playwright": "^1.63.0",
"tailwindcss": "^4.1.11",
"typescript": "^7.0.2"
}

View File

@@ -1,5 +1,7 @@
import { Database } from "bun:sqlite";
import { databasePath } from "../src/db/config";
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(); }

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;

27
scripts/build-pwa.ts Normal file
View File

@@ -0,0 +1,27 @@
import { createHash } from "node:crypto";
import { copyFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { manifest } from "../src/pwa/manifest";
export async function buildPwa(outdir: string, outputs: readonly { path: string }[]) {
const directory = path.join(outdir, "pwa");
await mkdir(directory, { recursive: true });
for (const icon of manifest.icons) await copyFile(`src/pwa/public/${path.basename(icon.src)}`, path.join(directory, path.basename(icon.src)));
await Bun.write(path.join(outdir, "manifest.webmanifest"), JSON.stringify(manifest, null, 2));
// Only browser assets emitted by Bun; index.js is the private server bundle.
const files = outputs.map(output => path.relative(outdir, output.path)).filter(file =>
file !== "index.js" && /\.(js|css|png|svg|woff2|webmanifest)$/.test(file));
files.push(...manifest.icons.map(icon => icon.src.slice(1)), "manifest.webmanifest");
for await (const file of new Bun.Glob("fonts/browser/*.woff2").scan(outdir)) files.push(file);
files.sort();
const template = await Bun.file("src/pwa/sw.js").text();
const html = await Bun.file(path.join(outdir, "index.html")).text();
const hash = createHash("sha256").update(template).update(html);
for (const file of files) hash.update(file).update(await Bun.file(path.join(outdir, file)).arrayBuffer().then(Buffer.from));
const version = hash.digest("hex").slice(0, 16);
const shell = `/pwa/shell-${version}.html`;
await Bun.write(path.join(outdir, shell.slice(1)), html);
const config = { version, shell, assets: [...files.map(file => `/${file}`), shell] };
await Bun.write(path.join(directory, "sw.js"), template.replace('"__PWA_CONFIG__"', JSON.stringify(JSON.stringify(config))));
console.log(` PWA ${version}: ${config.assets.length} public assets`);
}

View File

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

17
scripts/pwa-icons.ts Normal file
View File

@@ -0,0 +1,17 @@
import { createCanvas, loadImage } from "@napi-rs/canvas";
import { mkdir } from "node:fs/promises";
// Package the approved master at exact platform sizes; do not regenerate the artwork.
const master = await loadImage("docs/design/mina-habits-icon.png");
await mkdir("src/pwa/public", { recursive: true });
for (const [name, size, scale] of [
["icon-192", 192, 1], ["icon-512", 512, 1], ["icon-maskable-512", 512, 0.7],
] as const) {
const canvas = createCanvas(size, size);
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, size, size);
const inset = size * (1 - scale) / 2;
ctx.drawImage(master, inset, inset, size * scale, size * scale);
await Bun.write(`src/pwa/public/${name}.png`, canvas.toBuffer("image/png"));
}

194
scripts/pwa-smoke.ts Normal file
View File

@@ -0,0 +1,194 @@
import assert from "node:assert/strict";
import { cp, mkdtemp, mkdir, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { chromium } from "playwright";
import { Database } from "bun:sqlite";
import { hashToken } from "../src/auth";
// Exercise a copied production bundle and disposable account. No live data or Discord calls.
const directory = await mkdtemp(path.join(tmpdir(), "minabot-pwa-"));
const project = process.cwd();
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
const port = probe.port!;
probe.stop(true);
const origin = `http://127.0.0.1:${port}`;
const databasePath = path.join(directory, "test.sqlite");
const env = { ...process.env, NODE_ENV: "production", HOST: "127.0.0.1", PORT: String(port), APP_ORIGIN: origin,
DATABASE_PATH: databasePath, BACKUP_ENABLED: "false", DISCORD_CLIENT_ID: "", DISCORD_CLIENT_SECRET: "", DISCORD_PUBLIC_KEY: "",
DISCORD_BOT_TOKEN: "", DISCORD_SHARING_CHANNEL_ID: "", AUTH_COOKIE_SECRET: "pwa-test-only-secret-at-least-32-characters" };
let server: ReturnType<typeof Bun.spawn> | undefined;
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
const screenshots = path.resolve(".cache/pwa");
try {
await cp("dist", path.join(directory, "dist"), { recursive: true });
await symlink(path.join(project, "node_modules"), path.join(directory, "node_modules"), "dir");
const migration = Bun.spawn([process.execPath, "scripts/migrate.ts"], { env, stdout: "pipe", stderr: "pipe" });
assert.equal(await migration.exited, 0, "Production migrations failed");
const sqlite = new Database(databasePath);
const now = Date.now();
const token = "p".repeat(43);
sqlite.query("INSERT INTO users (id, discord_id, username, timezone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run("pwa", "pwa", "PWA Test", "UTC", now, now);
sqlite.query("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(hashToken(token), "pwa", now, now + 3600000);
sqlite.close();
server = Bun.spawn([process.execPath, "index.js"], { cwd: path.join(directory, "dist"), env, stdout: "pipe", stderr: "pipe" });
let healthy = false;
for (let attempt = 0; attempt < 100; attempt++) {
try { if ((await fetch(`${origin}/api/health`)).ok) { healthy = true; break; } } catch { /* Starting. */ }
if (server.exitCode !== null) throw new Error(await new Response(server.stderr as ReadableStream).text());
await Bun.sleep(50);
}
assert(healthy, "Production server failed to start");
const swResponse = await fetch(`${origin}/sw.js`);
assert.equal(swResponse.headers.get("service-worker-allowed"), "/");
assert.equal(swResponse.headers.get("cache-control"), "no-cache");
assert.match(swResponse.headers.get("content-type")!, /javascript/);
assert.equal((await fetch(`${origin}/pwa/index.js`)).status, 404);
assert.equal((await fetch(`${origin}/pwa/missing.png`)).status, 404);
assert.equal((await fetch(`${origin}/pwa/icon-192.png`, { method: "POST" })).status, 405);
assert.equal((await fetch(`${origin}/api/me`)).headers.get("cache-control"), "no-store");
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const page = await context.newPage();
const pageErrors: string[] = [];
page.on("pageerror", error => pageErrors.push(error.message));
await page.goto(origin);
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
await page.waitForFunction(() => Boolean(navigator.serviceWorker.controller));
assert.equal(await page.getByRole("button", { name: "Update app", exact: true }).count(), 0, "First install must not announce an update");
const cdp = await context.newCDPSession(page);
const manifestResult = await cdp.send("Page.getAppManifest");
assert.equal(manifestResult.errors.length, 0, JSON.stringify(manifestResult.errors));
const manifest = JSON.parse(manifestResult.data!);
assert.equal(manifest.id, "/");
assert.equal(manifest.display, "standalone");
assert.equal(manifest.scope, "/");
for (const icon of manifest.icons) {
const response = await fetch(new URL(icon.src, origin));
assert.match(response.headers.get("content-type")!, /image\/png/);
const png = Buffer.from(await response.arrayBuffer());
assert.equal(`${png.readUInt32BE(16)}x${png.readUInt32BE(20)}`, icon.sizes);
}
const installability = await cdp.send("Page.getInstallabilityErrors");
assert.deepEqual(installability.installabilityErrors, []);
console.log("PASS Chromium installability, manifest, icon sizes, MIME types and HTTP cache policy");
await mkdir(screenshots, { recursive: true });
await page.screenshot({ path: path.join(screenshots, "desktop.png"), fullPage: true });
await page.getByRole("radio", { name: "Dark", exact: true }).check();
assert.equal(await page.locator('meta[name="theme-color"]').getAttribute("content"), "#191e1b");
await page.getByRole("radio", { name: "Light", exact: true }).check();
await context.setOffline(true);
await page.goto(`${origin}/a/deep/link`);
await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
assert(await page.getByText("We can’t connect to Mina Habits.", { exact: false }).isVisible());
await page.setViewportSize({ width: 375, height: 812 });
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Offline mobile layout overflows");
await page.screenshot({ path: path.join(screenshots, "offline-mobile.png"), fullPage: true });
await page.goto(`${origin}/design-system`);
await page.getByRole("heading", { name: /design system/i }).first().waitFor();
await context.setOffline(false);
await page.goto(origin);
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
await context.setOffline(true);
await page.reload();
await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
await context.setOffline(false);
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Online mobile layout overflows");
await page.screenshot({ path: path.join(screenshots, "mobile.png"), fullPage: true });
console.log("PASS offline cold start, nested routes, lazy routes, mobile layouts, theme and automatic reconnection");
// Keep an unsaved draft in another open window while applying an update here.
const other = await context.newPage();
await other.goto(origin);
const draft = other.getByRole("textbox", { name: "Total glasses of water", exact: true });
await draft.fill("6");
const workerPath = path.join(directory, "dist/pwa/sw.js");
const original = await Bun.file(workerPath).text();
const configMatch = original.match(/const CONFIG = JSON.parse\((.+)\);/)!;
const config = JSON.parse(JSON.parse(configMatch[1]!));
// A broken release must leave the active worker and existing offline assets usable.
const broken = { ...config, version: `${config.version}-broken`, assets: [...config.assets, "/pwa/missing.png"] };
await Bun.write(workerPath, original.replace(configMatch[0], `const CONFIG = JSON.parse(${JSON.stringify(JSON.stringify(broken))});`));
await page.evaluate(async () => {
const registration = (await navigator.serviceWorker.getRegistration())!;
await new Promise<void>((resolve, reject) => {
registration.addEventListener("updatefound", () => {
const worker = registration.installing!;
worker.addEventListener("statechange", () => { if (worker.state === "redundant") resolve(); });
}, { once: true });
void registration.update().catch(reject);
});
});
assert.equal(await page.getByRole("button", { name: "Update app", exact: true }).count(), 0);
await context.setOffline(true);
await page.reload();
await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
await context.setOffline(false);
await page.getByRole("button", { name: "Try again", exact: true }).click();
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
config.version += "-qa";
await Bun.write(workerPath, original.replace(configMatch[0], `const CONFIG = JSON.parse(${JSON.stringify(JSON.stringify(config))});`));
await page.evaluate(async () => { await (await navigator.serviceWorker.getRegistration())!.update(); });
await page.getByRole("button", { name: "Update app", exact: true }).waitFor();
await page.getByRole("button", { name: "Update app", exact: true }).click();
await page.getByRole("button", { name: "Later", exact: true }).click();
assert.equal(await draft.inputValue(), "6");
await page.getByRole("button", { name: "Update app", exact: true }).click();
await Promise.all([page.waitForEvent("load"), page.getByRole("button", { name: "Reload and update", exact: true }).click()]);
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
assert.equal(await draft.inputValue(), "6", "Updating another window discarded its draft");
await other.getByRole("button", { name: "Update app", exact: true }).waitFor();
const keys = await page.evaluate(() => caches.keys());
assert(keys.some(key => key.endsWith("-qa")) && keys.length >= 2, "Old window assets were not retained");
console.log("PASS failed-update recovery, update waiting, deferred reload and preservation of another window’s draft");
await context.addCookies([{ name: "minabot_session", value: token, url: origin }]);
await page.reload();
await page.getByRole("button", { name: "Sign out", exact: true }).waitFor();
const privateResponse = await page.evaluate(async () => ({ status: (await fetch("/api/me")).status }));
assert.equal(privateResponse.status, 200);
await context.setOffline(true);
assert.equal(await page.evaluate(async () => { try { await fetch("/api/me"); return true; } catch { return false; } }), false, "Account response was cached");
await context.setOffline(false);
await page.getByRole("button", { name: "Sign out", exact: true }).click();
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
const cacheUrls = await page.evaluate(async () => {
const urls: string[] = [];
for (const key of await caches.keys()) for (const request of await (await caches.open(key)).keys()) urls.push(new URL(request.url).pathname);
return urls;
});
assert(!cacheUrls.some(url => url.startsWith("/api") || url.endsWith(".map") || url === "/index.js"), "Private files entered Cache Storage");
assert.deepEqual(pageErrors, []);
console.log("PASS authenticated API bypass, sign-out privacy and no uncaught browser errors");
const manualContext = await browser.newContext({ viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true });
await manualContext.addInitScript(() => {
window.addEventListener("beforeinstallprompt", event => { event.preventDefault(); event.stopImmediatePropagation(); });
});
const manual = await manualContext.newPage();
await manual.goto(origin);
await manual.getByRole("button", { name: "Install app", exact: true }).click();
await manual.getByRole("dialog", { name: "Keep Mina Habits close." }).waitFor();
assert(await manual.getByText("iPhone or iPad:", { exact: false }).isVisible());
assert(await manual.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Install dialog overflows");
await manual.screenshot({ path: path.join(screenshots, "install-mobile.png"), fullPage: true });
await manual.getByRole("button", { name: "Got it", exact: true }).click();
await manual.evaluate(() => window.dispatchEvent(new Event("appinstalled")));
assert.equal(await manual.getByRole("button", { name: "Install app", exact: true }).count(), 0);
await manualContext.close();
console.log("PASS mobile installation guidance and installed-state controls");
console.log(`Screenshots: ${screenshots}`);
} catch (error) {
const page = browser?.contexts()[0]?.pages()[0];
if (page) {
console.error("Browser at failure:", await page.locator("body").innerText());
await page.screenshot({ path: path.join(screenshots, "failure.png"), fullPage: true });
}
throw error;
} finally {
await browser?.close();
if (server) { server.kill(); await server.exited; }
await rm(directory, { recursive: true, force: true });
}

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") {
await act(async () =>
root.render(
@@ -100,6 +106,7 @@ async function render(path = "/design-system") {
</MemoryRouter>,
),
);
await settleDeferred();
}
function connectAccount() {
@@ -131,6 +138,7 @@ describe("homepage", () => {
const opener = buttonNamed("Settings");
opener.focus();
await act(async () => opener.click());
await settleDeferred();
const dialog = container.querySelector<HTMLDialogElement>("dialog#account-settings")!;
expect(dialog.open).toBe(true);
expect(dialog.contains(document.activeElement)).toBe(true);
@@ -158,6 +166,7 @@ describe("homepage", () => {
expect(inner.open).toBe(true);
expect(inner.querySelector<HTMLButtonElement>('[aria-label="Close dialog"]')?.disabled).toBe(true);
await act(async () => buttonNamed("Finish save").click());
await settleDeferred();
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
expect(container.querySelector("#inner")).toBeNull();
expect(container.querySelector<HTMLDialogElement>("#outer")?.open).toBe(true);
@@ -175,10 +184,13 @@ describe("homepage", () => {
await f.request(`/habits/${h.id}`, "DELETE");
await render("/");
await act(async () => buttonNamed("Archived habits").click());
await settleDeferred();
expect(buttonNamed("Restore Reading")).toBeDefined();
await act(async () => buttonNamed("View history").click());
await settleDeferred();
expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
await act(async () => buttonNamed("Restore Reading").click());
await settleDeferred();
expect(container.textContent).toContain("No archived habits.");
expect((await f.json("/today")).habits[0].name).toBe("Reading");
expect((await f.json(`/habits/${h.id}/days/2026-09-03`)).complete).toBe(true);
@@ -190,12 +202,16 @@ describe("homepage", () => {
await f.request(`/habits/${habit.id}`, "DELETE");
await render("/");
await act(async () => buttonNamed("Archived habits").click());
await settleDeferred();
await act(async () => buttonNamed("Delete permanently Discard me").click());
await settleDeferred();
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("This cannot be undone.");
expect(document.activeElement).toBe(buttonNamed("Keep habit"));
await act(async () => buttonNamed("Keep habit").click());
await settleDeferred();
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
await act(async () => buttonNamed("Delete permanently Discard me").click());
await settleDeferred();
const confirm = () => container.querySelector<HTMLButtonElement>("#delete-archived-habit .ds-action-row button:last-child")!;
fetchMock.mockResolvedValueOnce(Response.json({ error: "Temporary failure" }, { status: 500 }));
await act(async () => confirm().click());
@@ -210,7 +226,7 @@ describe("homepage", () => {
test("historical check-ins use the selected date's tracking method and preserve today", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
f.setTime("2026-01-03T12:00:00Z");
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
f.setTime("2026-09-04T12:00:00Z");
await f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" });
@@ -220,19 +236,29 @@ describe("homepage", () => {
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label^="2026-09-03:"]')!.click());
expect(buttonNamed("Edit selected check-in").closest(".ds-date-inspector")?.textContent).toContain("September 3, 2026");
await act(async () => buttonNamed("Edit selected check-in").click());
await settleDeferred();
const past = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!;
expect(past).toBeDefined();
await act(async () => past.click());
expect((await f.json(`/habits/${habit.id}/days/2026-09-03`)).complete).toBe(true);
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).value).toBe(0);
expect(container.textContent).toContain("Saved check-in for 2026-09-03");
fetchMock.mockClear();
await act(async () => {
const input = container.querySelector<HTMLInputElement>('input[type="date"]')!;
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "2026-01-03");
input.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event);
});
expect(container.querySelector('[aria-label^="2026-01-03:"]')).not.toBeNull();
expect(fetchMock.mock.calls.some(([url]) => String(url).includes("from=2025-11-01") && String(url).includes("compact=true"))).toBe(true);
});
test("signed-out visitors can try progress without calling private habit APIs", async () => {
await render("/");
expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm.");
expect(container.querySelector("h1")?.textContent).toBe("Track your habits");
expect(fetchMock.mock.calls.map(call => call[0])).toEqual(["/api/me"]);
await act(async () => buttonNamed("Increase glasses of water").click());
await settleDeferred();
expect(container.querySelector<HTMLInputElement>('[aria-label="Total glasses of water"]')?.value).toBe("4");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
@@ -252,7 +278,7 @@ describe("homepage", () => {
let resolve!: (response: Response) => void;
fetchMock.mockImplementation(Object.assign(() => new Promise<Response>(done => { resolve = done; }), { preconnect: fetch.preconnect }));
await render("/");
expect(container.textContent).toContain("Getting things ready");
expect(container.textContent).toContain("Loading account");
expect(container.querySelector("#welcome-title")).toBeNull();
await act(async () => resolve(new Response(null, { status: 401 })));
expect(container.querySelector("#welcome-title")).not.toBeNull();
@@ -264,6 +290,7 @@ describe("homepage", () => {
expect(container.textContent).toContain("Account unavailable");
expect(container.querySelector("#welcome-title")).toBeNull();
await act(async () => buttonNamed("Try again").click());
await settleDeferred();
expect(container.querySelector("#welcome-title")).not.toBeNull();
});
@@ -271,8 +298,9 @@ describe("homepage", () => {
const f = connectAccount();
await render("/");
expect(container.textContent).toContain("Welcome back, alice.");
expect(container.textContent).toContain("Start with one habit.");
expect(container.textContent).toContain("No habits yet.");
await act(async () => buttonNamed("Add a habit +").click());
await settleDeferred();
const input = container.querySelector<HTMLInputElement>("form input")!;
expect(document.activeElement).toBe(input);
await act(async () => {
@@ -285,6 +313,7 @@ describe("homepage", () => {
expect(container.textContent).not.toContain("Read a little created.");
expect(container.querySelector(".ds-habit-chart")).not.toBeNull();
await act(async () => buttonNamed("Sign out").click());
await settleDeferred();
expect(container.querySelector("#welcome-title")).not.toBeNull();
expect((await f.request("/me")).status).toBe(401);
});
@@ -310,12 +339,14 @@ describe("homepage", () => {
expect(container.querySelectorAll('.ds-goal--tasks .ds-routine-items > li')).toHaveLength(1);
await act(async () => buttonNamed("Increase Water").click());
await settleDeferred();
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3");
expect(container.querySelectorAll(".ds-goal-graph .ds-calendar")).toHaveLength(3);
expect(countGoal.querySelector(".ds-goal-status")?.textContent).toBe("Complete");
expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)?.textContent).toContain("8 of 8 glasses");
expect(container.textContent).not.toContain("Sunday walk");
await act(async () => buttonNamed("All habits · 4").click());
await settleDeferred();
const checkbox = (name: string) => [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => (input.getAttribute("aria-label") || input.closest("label")?.textContent) === name)!;
expect(checkbox("Sunday walk done").disabled).toBe(true);
await act(async () => checkbox("Reading done").click());
@@ -323,9 +354,81 @@ describe("homepage", () => {
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("3 / 3");
expect((await f.json("/today")).completed).toBe(3);
await act(async () => buttonNamed("Decrease Water").click());
await settleDeferred();
expect((await f.json("/today")).completed).toBe(2);
});
test("focus retains unchanged calendars, detects remote historical edits and loads expanded history on demand", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
const reading = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await f.json("/habits", "POST", { name: "Walking", method: "manual" }, 201);
f.setTime("2026-09-04T12:00:00Z");
await render("/");
const calendarRequests = () => fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?"));
expect(calendarRequests()).toHaveLength(2);
expect(calendarRequests()[0]).toContain("from=2026-07-01");
expect(calendarRequests()[0]).toContain("compact=true");
const parse = spyOn(Date, "parse");
await act(async () => buttonNamed("All habits · 2").click());
await settleDeferred();
expect(parse).not.toHaveBeenCalled();
parse.mockRestore();
fetchMock.mockClear();
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
expect(calendarRequests()).toHaveLength(0);
await f.json(`/habits/${reading.id}/days/2026-09-03/progress`, "PUT", { done: true });
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
expect(calendarRequests()).toHaveLength(1);
expect(calendarRequests()[0]).toContain(reading.id);
const history = container.querySelector(`#history-${reading.id}`)!;
fetchMock.mockClear();
await act(async () => history.querySelector<HTMLButtonElement>('[aria-label="12 months"]')!.click());
expect(calendarRequests()).toHaveLength(1);
expect(calendarRequests()[0]).toContain("from=2025-10-01");
expect(history.querySelectorAll(".ds-day")).toHaveLength(365);
fetchMock.mockClear();
await act(async () => history.querySelector<HTMLButtonElement>('[aria-label="3 months"]')!.click());
expect(calendarRequests()).toHaveLength(0);
expect(history.querySelectorAll(".ds-day")).toHaveLength(92);
expect(history.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
fetchMock.mockClear();
f.setTime("2026-09-05T12:00:00Z");
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
expect(calendarRequests()).toHaveLength(2);
});
test("month rollover preserves a twelve-month preset and an explicit custom window", async () => {
const f = connectAccount();
f.setTime("2026-09-30T12:00:00Z");
const annual = await f.json("/habits", "POST", { name: "Annual", method: "manual" }, 201);
const custom = await f.json("/habits", "POST", { name: "Custom", method: "manual" }, 201);
await render("/");
const annualHistory = container.querySelector(`#history-${annual.id}`)!;
const customHistory = container.querySelector(`#history-${custom.id}`)!;
await act(async () => annualHistory.querySelector<HTMLButtonElement>('[aria-label="12 months"]')!.click());
await act(async () => [...customHistory.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Custom")!.click());
await act(async () => {
for (const [name, value] of [["from", "2026-08-05"], ["to", "2026-09-10"]]) {
const input = customHistory.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, value);
input.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event);
}
customHistory.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event);
});
fetchMock.mockClear();
f.setTime("2026-10-01T12:00:00Z");
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
const requests = fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?"));
expect(requests).toHaveLength(2);
expect(requests.find(url => url.includes(annual.id))).toContain("from=2025-11-01&to=2026-10-31");
expect(requests.find(url => url.includes(custom.id))).toContain("from=2026-08-05&to=2026-09-10");
expect(annualHistory.querySelector(".ds-calendar")?.getAttribute("data-months")).toBe("12");
expect(annualHistory.querySelectorAll(".ds-day")).toHaveLength(365);
expect(customHistory.querySelector(".ds-calendar")?.getAttribute("data-months")).toBe("custom");
expect(customHistory.querySelectorAll(".ds-day")).toHaveLength(37);
});
test("a pending counter save leaves other habits usable and refreshes only its history", async () => {
const f = connectAccount();
const water = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201);
@@ -341,6 +444,7 @@ describe("homepage", () => {
}, { preconnect: fetch.preconnect }));
fetchMock.mockClear();
await act(async () => buttonNamed("Increase Water").click());
await settleDeferred();
expect(buttonNamed("Increase Water").disabled).toBe(true);
const check = container.querySelector<HTMLInputElement>('[aria-label="Reading done"]')!;
expect(check.disabled).toBe(false);
@@ -367,6 +471,7 @@ describe("homepage", () => {
f.setTime("2026-09-04T12:00:00Z");
await render("/");
await act(async () => buttonNamed("Edit habit Water").click());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit habit Water"]')!;
expect(document.activeElement).toBe(form.querySelector("input"));
await act(async () => {
@@ -384,11 +489,15 @@ describe("homepage", () => {
expect(container.querySelector("form")).toBeNull();
expect(container.querySelector("h3")?.textContent).toContain("Daily water");
await act(async () => buttonNamed("Archive habit Daily water").click());
await settleDeferred();
expect((await f.json("/habits")).habits).toHaveLength(1);
await act(async () => buttonNamed("Cancel").click());
await settleDeferred();
expect((await f.json("/habits")).habits).toHaveLength(1);
await act(async () => buttonNamed("Archive habit Daily water").click());
await settleDeferred();
await act(async () => buttonNamed("Archive habit").click());
await settleDeferred();
expect((await f.json("/habits")).habits).toHaveLength(0);
expect(container.querySelector(".ds-habit-chart")).toBeNull();
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
@@ -407,6 +516,7 @@ describe("homepage", () => {
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${task.id}`, "PUT", { done: true });
await render("/");
await act(async () => buttonNamed("Edit task Stretch").click());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Stretch"]')!;
await act(async () => {
const input = form.querySelector("input")!;
@@ -419,7 +529,9 @@ describe("homepage", () => {
expect(saved.schedule).toEqual({ type: "weekdays", days: [4, 5] });
expect((await f.json("/today")).habits[0].value).toBe(1);
await act(async () => buttonNamed("Delete task Stretch gently").click());
await settleDeferred();
await act(async () => buttonNamed("Delete task").click());
await settleDeferred();
const today = (await f.json("/today")).habits[0];
expect(today.target).toBe(1);
expect(today.value).toBe(0);
@@ -435,9 +547,11 @@ describe("homepage", () => {
await render("/");
expect(container.querySelector(".ds-habit-chart")).toBeNull();
await act(async () => buttonNamed("All habits · 1").click());
await settleDeferred();
expect(container.textContent).toContain("Not scheduled today");
expect(container.querySelector<HTMLInputElement>('.ds-editable-task input[type="checkbox"]')?.disabled).toBe(true);
await act(async () => buttonNamed("Edit task Walk").click());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
await act(async () => {
const input = form.querySelector("input")!;
@@ -477,6 +591,7 @@ describe("homepage", () => {
{ type: "daily" },
] as const) {
await act(async () => buttonNamed("Edit task Walk").click());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
await selectValue(form.querySelector("select")!, schedule.type);
if (schedule.type === "interval" || schedule.type === "weekly") {
@@ -489,6 +604,7 @@ describe("homepage", () => {
if (schedule.type === "interval") expect(buttonNamed("All habits · 1").getAttribute("aria-pressed")).toBe("true");
expect(container.querySelector("form")).toBeNull();
await act(async () => buttonNamed("Edit task Walk").click());
await settleDeferred();
const reopened = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
expect(reopened.querySelector("select")?.value).toBe(schedule.type);
if (schedule.type === "interval" || schedule.type === "weekly") {
@@ -496,6 +612,7 @@ describe("homepage", () => {
expect(reopened.querySelector<HTMLInputElement>('input[type="date"]')?.value).toBe(schedule.anchor);
}
await act(async () => buttonNamed("Cancel").click());
await settleDeferred();
}
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
expect(previous.tasks[0].done).toBe(true);
@@ -515,17 +632,20 @@ describe("homepage", () => {
};
const submit = async () => act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
await act(async () => buttonNamed("Edit habit Reading").click());
await settleDeferred();
await selectMethod("count");
await act(async () => container.querySelector<HTMLInputElement>('form input[type="checkbox"]')!.click());
await submit();
expect((await f.json(`/habits/${habit.id}`)).method).toBe("count");
expect((await f.json(`/habits/${habit.id}`)).carryPartialProgress).toBe(true);
await act(async () => buttonNamed("Edit habit Reading").click());
await settleDeferred();
expect(container.querySelector<HTMLInputElement>('form input[type="checkbox"]')?.checked).toBe(true);
await selectMethod("tasks");
await submit();
expect((await f.json(`/habits/${habit.id}`)).method).toBe("tasks");
await act(async () => buttonNamed("Add task").click());
await settleDeferred();
const form = container.querySelector<HTMLFormElement>('form[aria-label="Add task"]')!;
await act(async () => {
const input = form.querySelector("input")!;
@@ -545,16 +665,19 @@ describe("homepage", () => {
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await render("/");
await act(async () => buttonNamed("Archive habit Reading").click());
await settleDeferred();
fetchMock.mockImplementationOnce((async (input, init) => {
return f.request(String(input).replace("/api", ""), init?.method);
}) as typeof fetch);
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }));
await act(async () => buttonNamed("Archive habit").click());
await settleDeferred();
expect(container.querySelector("form")).toBeNull();
expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh");
expect(buttonNamed("Archive habit Reading").disabled).toBe(true);
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
await act(async () => buttonNamed("Try again").click());
await settleDeferred();
expect(container.querySelector(".ds-habit-chart")).toBeNull();
expect(fetchMock.mock.calls.filter(call => call[1]?.method === "DELETE")).toHaveLength(1);
});
@@ -565,10 +688,12 @@ describe("homepage", () => {
await render("/");
fetchMock.mockResolvedValueOnce(Response.json({ error: "Save unavailable" }, { status: 500 }));
await act(async () => buttonNamed("Increase Water").click());
await settleDeferred();
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save unavailable");
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("0");
expect(buttonNamed("Increase Water").disabled).toBe(false);
await act(async () => buttonNamed("Increase Water").click());
await settleDeferred();
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("1");
expect((await f.json("/today")).habits[0].value).toBe(1);
});

View File

@@ -1,5 +1,6 @@
import { Navigate, Route, Routes } from "react-router";
import { DesignSystem } from "./pages/DesignSystem";
import { deferred } from "./components/design-system/deferred";
const DesignSystem = deferred(() => import("./pages/DesignSystem").then(module => module.DesignSystem));
import { Home } from "./pages/Home";
import { AuthProvider } from "./components/AuthProvider";

View File

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

View File

@@ -18,6 +18,8 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
app.use("*", async (c, next) => {
const requestId = crypto.randomUUID();
c.set("requestId", requestId); c.header("X-Request-ID", requestId);
// Accounts, calendars, exports and OAuth responses must never enter HTTP caches.
c.header("Cache-Control", "no-store");
await next();
});
const auth = createAuth(db, config, request, now);

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
`Mina Sans`; no system sans-serif font is selected for card text. The native font
loader fails startup if a required font cannot be registered.
Browser WOFF2 copies are lossless conversions of these exact TTF files using
FontTools with Brotli; no subsetting is applied. All glyphs and Unicode mappings
are retained (3,748 glyphs for each Noto Sans weight, 377 for each Instrument
Serif style). Native canvas keeps using the original TTFs. CSS uses separately
cacheable `/fonts/browser/*.woff2` URLs so font bytes do not block CSS parsing.
Regenerate with `uv run --with fonttools --with brotli python`:
```python
from pathlib import Path
from fontTools.ttLib import TTFont
for path in Path("src/assets/fonts").glob("*.ttf"):
font = TTFont(path)
cmap, glyphs = font.getBestCmap(), font.getGlyphOrder()
font.flavor = "woff2"
font.save(path.with_suffix(".woff2"))
restored = TTFont(path.with_suffix(".woff2"))
assert restored.getBestCmap() == cmap
assert restored.getGlyphOrder() == glyphs
```

14
src/browser-fonts.test.ts Normal file
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

@@ -60,7 +60,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
<div ref={archiveFocus} tabIndex={-1} aria-label="Archive status"><FormMessage>{notice}</FormMessage></div>
<FormMessage error>{error}</FormMessage>
{error && <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry archive</Button>}
{loading ? <p className="ds-supporting-copy" role="status">Loading archive…</p> : !habits.length && <div className="ds-empty-panel"><Archive size={24} aria-hidden="true" /><div><p className="type-ui-heading">No archived habits.</p><p className="ds-supporting-copy">When you put a habit aside, its history will be waiting here.</p></div></div>}
{loading ? <p className="ds-supporting-copy" role="status">Loading archive…</p> : !habits.length && <div className="ds-empty-panel"><Archive size={24} aria-hidden="true" /><div><p className="type-ui-heading">No archived habits.</p></div></div>}
<div className="ds-record-list">
{habits.map(habit => <article key={habit.habitId} className="ds-record" aria-labelledby={`archive-title-${habit.habitId}`}>
<header className="ds-record-heading">
@@ -71,7 +71,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
<Button variant="text" disabled={busy || loading} aria-label={`Delete permanently ${habit.name}`} aria-haspopup="dialog" onClick={() => { setDeleteError(""); setDeleting(habit); }}><Trash2 size={16} aria-hidden="true" />Delete permanently</Button>
</div>
</header>
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={habit.calendarRevision ?? revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
</article>)}
</div>
{deleting && <Modal id="delete-archived-habit" size="compact" eyebrow="PERMANENT DELETION" title="Delete this habit?" description={`“${deleting.name ?? "Habit"}” and all its check-ins, tasks, and progress history will be permanently deleted. It will be removed from combined charts; charts with no remaining habits will also be deleted. This cannot be undone.`} busy={busy} initialFocus="[data-cancel-delete]" returnFocus={() => archiveFocus.current} onClose={() => setDeleting(null)}>

View File

@@ -11,6 +11,7 @@ const authErrors: Record<string, string> = {
};
function signIn() {
if (navigator.onLine === false) return;
let timezone = "UTC";
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
@@ -21,6 +22,7 @@ type AuthState = {
loading: boolean;
busy: boolean;
accountError: string;
connectionError: boolean;
error: string;
signIn: () => void;
signOut: () => Promise<void>;
@@ -34,6 +36,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [accountError, setAccountError] = useState("");
const [connectionError, setConnectionError] = useState(false);
const [actionError, setActionError] = useState("");
const [attempt, setAttempt] = useState(0);
const { search } = useLocation();
@@ -42,15 +45,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
let reachedServer = false;
try {
const response = await fetch("/api/me", { signal: controller.signal });
setAccountError("");
setConnectionError(false);
const response = await fetch("/api/me", { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]) });
reachedServer = true;
if (controller.signal.aborted) return;
if (response.status === 401) { setUser(null); return; }
if (!response.ok) throw new Error("Could not load your account. Please try again.");
const account: PublicUser = await response.json();
if (!controller.signal.aborted) setUser(account);
} catch (error) {
if (!controller.signal.aborted) setAccountError(error instanceof Error ? error.message : "Could not load your account.");
if (!controller.signal.aborted) {
setConnectionError(!reachedServer);
setAccountError(!reachedServer ? "Could not connect to Mina Habits. Check your connection and try again." : error instanceof Error ? error.message : "Could not load your account.");
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
@@ -59,6 +69,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => controller.abort();
}, [attempt]);
useEffect(() => {
const reconnect = () => { if (accountError) retry(); };
const check = () => { if (connectionError && navigator.onLine !== false && document.visibilityState === "visible") retry(); };
const timer = window.setInterval(check, 15_000);
window.addEventListener("online", reconnect);
window.addEventListener("focus", check);
return () => { window.clearInterval(timer); window.removeEventListener("online", reconnect); window.removeEventListener("focus", check); };
}, [accountError, connectionError]);
function retry() {
setAccountError("");
setLoading(true);
@@ -69,6 +88,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setBusy(true);
setActionError("");
try {
if (navigator.onLine === false) throw new Error("Reconnect to sign out.");
const response = await fetch("/api/auth/logout", { method: "POST" });
if (!response.ok) throw new Error("Could not sign out. Please try again.");
setUser(null);
@@ -78,7 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
return (
<AuthContext.Provider value={{ user, loading, busy, accountError, error: accountError || actionError || signInError || "", signIn, signOut, retry }}>
<AuthContext.Provider value={{ user, loading, busy, accountError, connectionError, error: accountError || actionError || signInError || "", signIn, signOut, retry }}>
{children}
</AuthContext.Provider>
);

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

@@ -40,7 +40,7 @@ export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired, onB
} finally { saving.current = false; setBusy(false); onBusyChange?.(false); }
}
return <div className="ds-form-stack ds-check-in-controls" aria-label={`Check-in for ${date}`}>
<header className="ds-check-in-heading"><p className="ds-eyebrow">EDITING CHECK-IN</p><p className="type-ui-heading">{formatTrackingDate(date)}</p></header>
<header className="ds-check-in-heading"><p className="type-ui-heading">{formatTrackingDate(date)}</p></header>
<FormMessage error>{error}</FormMessage>
{!day ? error ? <Button onClick={() => setAttempt(value => value + 1)}>Retry check-in</Button> : <p role="status">Loading check-in…</p>
: day.future ? <p>Future check-ins cannot be edited.</p>

View File

@@ -114,9 +114,6 @@ export function CreateHabit({
}}
>
<div className="home-create-heading">
<p className="ds-eyebrow">
{editing ? "SHAPE YOUR RHYTHM" : "A LITTLE ROOM FOR SOMETHING GOOD"}
</p>
<Button
variant="text"
aria-label={editing ? "Close habit editor" : "Close new habit"}
@@ -127,21 +124,9 @@ export function CreateHabit({
</Button>
</div>
<h2 id="create-title">
{editing ? (
<>
Edit your <em>habit.</em>
</>
) : (
<>
Make it <em>yours.</em>
</>
)}
{editing ? "Edit habit" : "Create habit"}
</h2>
<p className="ds-muted">
{editing
? "Changes start today. Earlier targets and schedules stay as they were."
: "Start small. You don’t need a perfect plan."}
</p>
{editing && <p className="type-small">Changes apply from today. Earlier history is kept.</p>}
<form onSubmit={submit}>
<fieldset disabled={busy} className="home-form-fields">
<div className="ds-field">

View File

@@ -47,7 +47,6 @@ export function DeleteHabit({
if (!saving.current) onClose();
}}
>
<p className="ds-eyebrow">MAKE A LITTLE ROOM</p>
<h2 id="delete-title">
Delete this <em>habit?</em>
</h2>

View File

@@ -1,15 +1,17 @@
import { useCalendarCache } from "./CalendarCache";
import { Modal } from "./design-system/Modal";
import { Pencil } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { CalendarResponse } from "../shared/calendar";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { toHeatmapDays, type CalendarResponse } from "../shared/calendar";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
import { monthWindow } from "./design-system/calendar-model";
import { monthWindow, type MonthView } from "./design-system/calendar-model";
import { Button } from "./design-system/primitives";
import { HabitChart } from "./design-system/HabitChart";
import { CalendarLegend } from "./design-system/CalendarLegend";
import { shade } from "../habits/calendar";
import { CheckInEditor } from "./CheckInEditor";
import { deferred } from "./design-system/deferred";
const CheckInEditor = deferred(() => import("./CheckInEditor").then(module => module.CheckInEditor));
import { Field } from "./design-system/Field";
import { ItemActions } from "./design-system/ItemActions";
@@ -40,7 +42,29 @@ export function HabitHistory({
onHistorySaved?: () => void;
calendarOnly?: boolean;
}) {
const cache = useCalendarCache();
const [rangeSelection, setRangeSelection] = useState(() => ({ date, months: 3 as MonthView | "custom", range: monthWindow(date, 3) }));
const range = useMemo(() => {
if (rangeSelection.date === date || rangeSelection.months === "custom") return rangeSelection.range;
// A preset follows the current month across midnight; an explicitly inspected
// older window and custom dates retain the user's chosen historical period.
const previousCurrent = monthWindow(rangeSelection.date, rangeSelection.months);
const anchor = rangeSelection.range.to < previousCurrent.to ? rangeSelection.range.to : date;
return monthWindow(anchor, rangeSelection.months);
}, [date, rangeSelection]);
function requestRange(next: { from: string; to: string }, months: MonthView | "custom" = rangeSelection.months) {
setRangeSelection({ date, months, range: next });
}
const [historyLoading, setHistoryLoading] = useState(false);
const [selectedDate, setSelectedDate] = useState<string>();
function selectDate(selected: string) {
setSelectedDate(selected);
const available = monthWindow(date, 12);
if (rangeSelection.months !== "custom" && selected >= available.from && selected <= available.to && (selected < range.from || selected > range.to)) {
const requested = monthWindow(selected, rangeSelection.months);
requestRange({ from: requested.from < available.from ? available.from : requested.from, to: requested.to });
}
}
const [editingDay, setEditingDay] = useState(false);
const [savingDay, setSavingDay] = useState(false);
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
@@ -49,45 +73,27 @@ export function HabitHistory({
const expired = useRef(onExpired);
expired.current = onExpired;
useEffect(() => {
const controller = new AbortController();
let active = true;
setError("");
const range = monthWindow(date, 12);
habitRequest<CalendarResponse>(
`/habits/${habit.habitId}/calendar?${new URLSearchParams(range)}`,
{ signal: controller.signal },
)
.then((result) => {
if (!controller.signal.aborted) setCalendar(result);
})
.catch((error) => {
if (controller.signal.aborted) return;
if (error.message.includes("session has expired") && expired.current)
expired.current();
setHistoryLoading(true);
cache.load(habit.habitId, `${date}:${revision}:${attempt}`, range)
.then(result => { if (active) setCalendar(result); })
.catch(error => {
if (!active) return;
if (error.message.includes("session has expired") && expired.current) expired.current();
else setError(error.message);
});
return () => controller.abort();
}, [habit.habitId, date, revision, attempt]);
}).finally(() => { if (active) setHistoryLoading(false); });
return () => { active = false; };
}, [cache, habit.habitId, date, revision, attempt, range.from, range.to]);
const days =
calendar?.days.map((day) => ({
date: day.date,
color: day.color,
unit: day.habits[0]?.unit,
value: day.habits[0]?.value ?? 0,
target: day.habits[0]?.target ?? 0,
state: day.future
? ("future" as const)
: day.due
? ("due" as const)
: ("not-due" as const),
})) ?? [];
const days = useMemo(() => calendar ? toHeatmapDays(calendar) : [], [calendar]);
const steps = Math.max(
1,
calendar?.days.find((day) => day.date === date)?.shadeCount ??
habit.target ??
1,
);
const shades = calendar
const shades = useMemo(() => calendar
? [
calendar.settings.emptyColor,
...Array.from(
@@ -101,7 +107,7 @@ export function HabitHistory({
).color,
),
]
: [];
: [], [calendar, steps]);
const chart = error ? (
<div className="ds-form-feedback">
<p role="alert">{error}</p>
@@ -119,17 +125,20 @@ export function HabitHistory({
) : (
<>
<CalendarHeatmap
onSelectDate={setSelectedDate}
onSelectDate={selectDate}
selectedDate={selectedDate}
compact
initialMonths={3}
availableRange={monthWindow(date, 12)}
onRangeChange={requestRange}
loading={historyLoading}
historyLabel="Your recorded progress"
label={`${habit.name} progress calendar`}
unit={habit.unit}
color={calendar.settings.mainColor}
emptyColor={calendar.settings.emptyColor}
days={days}
selectedDayAction={selected => <Button variant="secondary" disabled={disabled} aria-label="Edit selected check-in" aria-haspopup="dialog" onClick={() => { setSelectedDate(selected); setEditingDay(true); }}><Pencil size={16} aria-hidden="true" />Edit check-in</Button>}
selectedDayAction={selected => <Button variant="secondary" disabled={disabled} aria-label="Edit selected check-in" aria-haspopup="dialog" onClick={() => { selectDate(selected); setEditingDay(true); }}><Pencil size={16} aria-hidden="true" />Edit check-in</Button>}
legend={
<CalendarLegend
label={`${habit.name} progress calendar`}
@@ -141,7 +150,7 @@ export function HabitHistory({
/>
{editingDay && <Modal id={`check-in-${habit.habitId}`} title="Edit check-in" description={habit.name ?? "Habit"} closeLabel="Close check-in editor" onClose={() => setEditingDay(false)} busy={savingDay}>
<div className="ds-history-editor">
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" disabled={savingDay} max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) setSelectedDate(event.target.value); }} />}</Field></div>
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" disabled={savingDay} max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) selectDate(event.target.value); }} />}</Field></div>
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
</div></Modal>}

View File

@@ -0,0 +1,47 @@
import type { CSSProperties } from "react";
import { Check, Circle, Clock3, Minus } from "lucide-react";
import type { CalendarResponse } from "../shared/calendar";
import { habitProgressColor } from "../lib/habit-palette";
import { useTheme } from "./ThemeProvider";
import { Button } from "./design-system/primitives";
type Day = CalendarResponse["days"][number];
/** The ring counts completed habits; individual bars retain partial progress. */
export function OverviewDayGraphic({ day, color, busy, onEdit }: {
day: Day; color: string; busy: boolean; onEdit: (habitId: string) => void;
}) {
const { theme } = useTheme();
const neutral = !day.due || day.future;
const ratio = neutral ? 0 : day.completed / day.due;
const summary = !day.due ? "Nothing scheduled" : day.future ? "Upcoming" : `${day.completed} of ${day.due} due habits completed`;
return <div className="ds-overview-details" style={{ "--habit-color": habitProgressColor(color, 1, theme) } as CSSProperties}>
<div className="ds-overview-summary">
<div className="ds-overview-ring" aria-hidden="true">
<svg viewBox="0 0 120 120" focusable="false">
<circle className="ds-overview-ring-track" cx="60" cy="60" r="50" />
{!neutral && <circle className="ds-overview-ring-value" cx="60" cy="60" r="50" pathLength="100" strokeDasharray={`${ratio * 100} 100`} transform="rotate(-90 60 60)" />}
</svg>
<span className="type-title">{neutral ? "—" : `${Math.round(ratio * 100)}%`}</span>
</div>
<div><p className="type-ui-heading">{summary}</p><p className="ds-supporting-copy">{!day.due ? "Days off don’t count against your progress." : day.future ? "Check back on this date to record your progress." : "Each completed habit counts equally. Partial progress is shown below."}</p></div>
</div>
<div className="ds-overview-detail-grid">
{day.habits.map(habit => {
const inactive = !habit.due || habit.future;
const StatusIcon = !habit.due ? Minus : habit.future ? Clock3 : habit.complete ? Check : Circle;
const status = !habit.due ? "Nothing scheduled" : habit.future ? "Upcoming" : habit.complete ? "Complete" : habit.value > 0 ? "In progress" : "Not started";
return <article key={habit.habitId} className="ds-overview-habit" aria-label={`${habit.name ?? "Habit"} progress`}>
<header><h3 className="type-ui-heading">{habit.name ?? "Habit"}</h3><span className="ds-overview-status"><StatusIcon size={18} aria-hidden="true" />{status}</span></header>
{inactive ? <p className="ds-supporting-copy">{!habit.due ? "Excluded from this day’s total." : "Progress will be available on this date."}</p> : habit.method === "manual" ?
<div className={`ds-overview-check${habit.complete ? " is-complete" : ""}`}><StatusIcon size={28} aria-hidden="true" /><span>{habit.complete ? "Checked in" : "Not checked in"}</span></div> :
<div className="ds-overview-meter"><p><strong className="type-control">{habit.value}</strong><span> of {habit.target} {habit.unit}</span></p>
<progress aria-label={`${habit.name} progress`} aria-valuetext={`${habit.value} of ${habit.target} ${habit.unit}`} value={Math.min(habit.value, habit.target ?? 0)} max={Math.max(1, habit.target ?? 1)} />
</div>}
{!inactive && !!habit.tasks?.length && <ul className="ds-overview-tasks">{habit.tasks.map(task => <li key={task.taskId}><span className={task.done ? "is-complete" : ""}>{task.done ? <Check size={16} aria-hidden="true" /> : <Circle size={16} aria-hidden="true" />}</span><span>{task.name}<span className="ds-visually-hidden"> · {task.done ? "Complete" : "Incomplete"}</span></span></li>)}</ul>}
{!inactive && <Button variant="text" disabled={busy} aria-haspopup="dialog" aria-label={`Edit ${habit.name ?? "habit"} check-in`} onClick={() => onEdit(habit.habitId)}>Edit check-in</Button>}
</article>;
})}
</div>
</div>;
}

View File

@@ -0,0 +1,196 @@
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act, useState } from "react";
import type { Root } from "react-dom/client";
import { ProgressOverview } from "./ProgressOverview";
import { fixture } from "../habits/test-fixture";
const dom = new Window({ url: "http://localhost:3000/design-system" });
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
let createRoot: typeof import("react-dom/client").createRoot;
let root: Root;
let container: HTMLDivElement;
let f: ReturnType<typeof fixture>;
const originalFetch = globalThis.fetch;
beforeAll(async () => {
for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputElement", "Element", "Node", "Event", "MouseEvent", "IS_REACT_ACT_ENVIRONMENT"]) {
originalGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value: key === "window" ? dom : key === "IS_REACT_ACT_ENVIRONMENT" ? true : (dom as unknown as Record<string, unknown>)[key],
});
}
({ createRoot } = await import("react-dom/client"));
});
beforeEach(() => {
f = fixture();
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
return f.request(String(input).replace(/^\/api/, ""), init?.method, init?.body ? JSON.parse(String(init.body)) : undefined);
}) as typeof fetch;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
globalThis.fetch = originalFetch;
f.close();
});
afterAll(() => {
dom.happyDOM.abort();
for (const [key, descriptor] of originalGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
function button(text: string) {
return [...container.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent?.trim() === text || element.getAttribute("aria-label") === text || element.getAttribute("aria-label")?.startsWith(`${text} `))!;
}
function Dashboard() {
const [creating, setCreating] = useState(false);
return <><button id="create-overview" onClick={() => setCreating(true)}>Create overview</button><div className="ds-habit-list"><ProgressOverview creating={creating} onCloseCreate={() => setCreating(false)} date="2026-09-04" revision={0} onExpired={() => {}} onHistorySaved={() => {}} /></div></>;
}
async function render() {
await act(async () => root.render(<Dashboard />));
}
function checkbox(label: string) {
return [...container.querySelectorAll('label')].find(element => element.textContent === label)!.querySelector<HTMLInputElement>('input')!;
}
async function click(text: string) { await act(async () => button(text).click()); }
async function name(value: string) {
const input = container.querySelector<HTMLInputElement>('input[placeholder="e.g. Morning routine"]')!;
await act(async () => {
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
test("saved overview creation, membership editing with archived habits, and deletion preserve habits", async () => {
const water = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8 }, 201);
const reading = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201);
expect((await f.request(`/habits/${reading.id}`, 'DELETE')).status).toBe(204);
await render();
await click('Create overview');
expect(container.textContent).toContain('Reading · Archived');
await name('Essentials');
await act(async () => checkbox('Water').click());
await click('Save overview');
const saved = (await f.json('/charts')).charts[0];
expect(saved.name).toBe('Essentials');
expect(saved.habitIds).toEqual([water.id]);
expect(container.textContent).toContain('View day details');
await click('Edit overview');
await name('My progress');
await act(async () => checkbox('Reading · Archived').click());
expect(container.textContent).toContain('recalculates this overview’s entire history');
await click('Save overview');
expect((await f.json(`/charts/${saved.id}`)).habitIds).toEqual([water.id, reading.id]);
await click('Delete overview');
const dialog = container.querySelector('#delete-overview')!;
await act(async () => [...dialog.querySelectorAll<HTMLButtonElement>('button')].find(b => b.textContent === 'Delete overview')!.click());
expect((await f.json('/charts')).charts).toHaveLength(0);
expect((await f.json('/habits?archived=true')).habits).toHaveLength(2);
});
test("combined calendars show completed versus due and corrections refresh the aggregate", async () => {
const water = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8, unit: 'glasses' }, 201);
const read = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201);
await f.json(`/habits/${water.id}/days/2026-09-04/progress`, 'PUT', { count: 7 });
await f.json('/charts', 'POST', { name: 'Essentials', habitIds: [water.id, read.id] }, 201);
await render();
expect(container.querySelector('button[aria-label^="2026-09-04:"]')?.getAttribute('aria-label')).toContain('0 of 2 habits complete');
await click('View day details');
expect(container.textContent).toContain('7 of 8 glasses');
await click('Edit Reading check-in');
await act(async () => container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click());
expect(container.textContent).toContain('1 of 2 due habits completed');
expect(container.querySelector('button[aria-label^="2026-09-04:"]')?.getAttribute('aria-label')).toContain('1 of 2 habits complete');
});
test("failed lists retry and failed saves preserve the overview draft", async () => {
await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201);
const working = globalThis.fetch;
globalThis.fetch = (async () => Response.json({ error: 'Temporarily unavailable' }, { status: 503 })) as unknown as typeof fetch;
await render();
expect(container.textContent).toContain('Temporarily unavailable');
globalThis.fetch = working;
await click('Retry overviews');
await click('Create overview');
await name('Keep this draft');
await act(async () => container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click());
globalThis.fetch = (async () => Response.json({ error: 'Save failed' }, { status: 503 })) as unknown as typeof fetch;
await click('Save overview');
expect(container.textContent).toContain('Save failed');
expect(container.querySelector<HTMLInputElement>('input[placeholder="e.g. Morning routine"]')!.value).toBe('Keep this draft');
globalThis.fetch = working;
await click('Save overview');
expect((await f.json('/charts')).charts[0].name).toBe('Keep this draft');
});
test("range changes retain the selected window and future or unscheduled days have no edit controls", async () => {
const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual', schedule: { type: 'weekdays', days: [5] } }, 201);
await f.json('/charts', 'POST', { name: 'Fridays', habitIds: [habit.id] }, 201);
await render();
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="6 months"]')!.click());
const squares = () => container.querySelectorAll('button[aria-label^="2026-"]').length;
const count = squares();
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label^="2026-09-05:"]')!.click());
expect(squares()).toBe(count);
expect(container.querySelector('.ds-calendar')?.getAttribute('data-months')).toBe('6');
await click('View day details');
const dialog = container.querySelector('#overview-day')!;
expect(dialog.textContent).toContain('Nothing scheduled');
expect([...dialog.querySelectorAll('button')].some(b => b.textContent?.startsWith('Edit '))).toBe(false);
await act(async () => dialog.querySelector<HTMLButtonElement>('button[aria-label="Close dialog"]')!.click());
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label^="2026-09-11:"]')!.click());
await click('View day details');
expect(container.querySelector('#overview-day')!.textContent).toContain('Upcoming');
expect([...container.querySelectorAll('#overview-day button')].some(b => b.textContent?.startsWith('Edit '))).toBe(false);
});
test("all overviews render in the habit list and custom colors persist without resetting other settings", async () => {
const habit = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
const first = await f.json('/charts', 'POST', { name: 'Health', habitIds: [habit.id], settings: { mainColor: '#112233', shadeCount: 7 } }, 201);
await f.json('/charts', 'POST', { name: 'Learning', habitIds: [habit.id] }, 201);
await render();
expect(container.querySelectorAll('.ds-habit-list > .ds-overview')).toHaveLength(2);
expect(container.querySelector('select')).toBeNull();
expect([...container.querySelectorAll('.ds-goal-meta')].map(el => el.textContent)).toEqual(['Combined chart · 1 habit', 'Combined chart · 1 habit']);
await click('Edit overview Health');
await click('Custom');
const hex = container.querySelector<HTMLInputElement>('input[placeholder="#426582"]')!;
await act(async () => {
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, 'value')!.set!.call(hex, '#a35488');
hex.dispatchEvent(new Event('input', { bubbles: true }));
});
await click('Save overview');
expect((await f.json(`/charts/${first.id}`)).settings).toMatchObject({ mainColor: '#a35488', shadeCount: 7 });
expect(container.querySelectorAll('.ds-overview')).toHaveLength(2);
await click('Edit overview Health');
expect(container.querySelector<HTMLInputElement>('input[placeholder="#426582"]')!.value).toBe('#a35488');
});
test("day graphics show partial task and count progress without crediting incomplete habits", async () => {
const count = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8, unit: 'glasses' }, 201);
const routine = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: [{ name: 'Stretch' }, { name: 'Walk' }] }, 201);
const manual = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
await f.json(`/habits/${count.id}/days/2026-09-04/progress`, 'PUT', { count: 7 });
await f.json(`/habits/${manual.id}/days/2026-09-04/progress`, 'PUT', { done: true });
await f.json(`/habits/${routine.id}/days/2026-09-04/tasks/${routine.tasks[0].id}`, 'PUT', { done: true });
await f.json('/charts', 'POST', { name: 'Together', habitIds: [count.id, routine.id, manual.id] }, 201);
await render();
await click('View day details');
expect(container.querySelector('.ds-overview-ring')?.textContent).toBe('33%');
expect(container.querySelector('progress[aria-label="Water progress"]')?.getAttribute('value')).toBe('7');
expect(container.querySelector('progress[aria-label="Routine progress"]')?.getAttribute('value')).toBe('1');
expect(container.querySelectorAll('.ds-overview-tasks li')).toHaveLength(2);
expect(container.querySelector('.ds-overview-check')?.textContent).toBe('Checked in');
});

View File

@@ -0,0 +1,187 @@
import { useEffect, useRef, useState, type CSSProperties } from "react";
import type { HabitService } from "../habits/service";
import { habitRequest, type TodayHabit } from "../lib/dashboard";
import { toHeatmapDays, type CalendarResponse } from "../shared/calendar";
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
import { monthWindow, type MonthView } from "./design-system/calendar-model";
import { Button, Checkbox } from "./design-system/primitives";
import { HabitColorPicker, isHabitColor } from "./design-system/HabitColorPicker";
import { ItemActions } from "./design-system/ItemActions";
import { useTheme } from "./ThemeProvider";
import { habitProgressColor } from "../lib/habit-palette";
import { OverviewDayGraphic } from "./OverviewDayGraphic";
import { Field } from "./design-system/Field";
import { Modal } from "./design-system/Modal";
import { FormMessage } from "./design-system/WorkspacePanel";
import { deferred } from "./design-system/deferred";
const CheckInEditor = deferred(() => import("./CheckInEditor").then(module => module.CheckInEditor));
type Chart = ReturnType<HabitService["chart"]>;
type Day = CalendarResponse["days"][number];
const message = (error: unknown) => error instanceof Error ? error.message : "Could not load your overview. Try again.";
export function ProgressOverview({ date, revision, disabled, onExpired, onHistorySaved, creating = false, onCloseCreate = () => {} }: {
creating?: boolean; onCloseCreate?: () => void;
date: string; revision: number; disabled?: boolean; onExpired: () => void; onHistorySaved: () => void;
}) {
const [charts, setCharts] = useState<Chart[]>([]);
const { theme } = useTheme();
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0);
const [editing, setEditing] = useState<Chart | null>(null);
const [deleting, setDeleting] = useState<Chart | null>(null);
const [busy, setBusy] = useState(false);
const [deleteError, setDeleteError] = useState("");
const saving = useRef(false);
const expired = useRef(onExpired); expired.current = onExpired;
useEffect(() => {
const controller = new AbortController();
setLoading(true); setError("");
habitRequest<{ charts: Chart[] }>("/charts", { signal: controller.signal })
.then(data => { if (!controller.signal.aborted) {
setCharts(data.charts);
} })
.catch(error => { if (!controller.signal.aborted) { setError(message(error)); if (message(error).includes("session has expired")) expired.current(); } })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [revision, date, attempt]);
async function remove() {
if (!deleting || saving.current) return;
saving.current = true; setBusy(true); setDeleteError("");
try {
await habitRequest(`/charts/${deleting.id}`, { method: "DELETE" });
setCharts(current => current.filter(item => item.id !== deleting.id));
setDeleting(null); setAttempt(value => value + 1);
} catch (error) { setDeleteError(message(error)); if (message(error).includes("session has expired")) expired.current(); }
finally { saving.current = false; setBusy(false); }
}
return <>
{error && <div><FormMessage error>{error}</FormMessage><Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry overviews</Button></div>}
{loading && !charts.length && <p role="status">Loading overviews…</p>}
{charts.map(chart => <section key={chart.id} className="ds-habit-chart ds-goal ds-overview" aria-labelledby={`overview-${chart.id}`} style={{ "--habit-color": habitProgressColor(chart.settings.mainColor, 1, theme) } as CSSProperties}>
<header className="ds-goal-heading">
<div className="ds-goal-identity">
<h3 id={`overview-${chart.id}`} className="type-title ds-overview-title">{chart.name}</h3>
<p className="ds-goal-meta">Combined chart · {chart.habitIds.length} {chart.habitIds.length === 1 ? "habit" : "habits"}</p>
</div>
<ItemActions name={chart.name} kind="overview" disabled={disabled || loading || busy} onEdit={() => setEditing(chart)} onDelete={() => { setDeleteError(""); setDeleting(chart); }} />
</header>
<OverviewCalendar chart={chart} date={date} revision={`${revision}:${attempt}:${chart.updatedAt}`} disabled={disabled || loading || busy} onExpired={onExpired} onHistorySaved={onHistorySaved} />
</section>)}
{(creating || editing) && <OverviewForm chart={editing ?? undefined} date={date} onClose={() => { setEditing(null); onCloseCreate(); }} onExpired={onExpired} onSaved={saved => {
setCharts(current => current.some(item => item.id === saved.id) ? current.map(item => item.id === saved.id ? saved : item) : [...current, saved]); setEditing(null); onCloseCreate(); setAttempt(value => value + 1);
}} />}
{deleting && <Modal id="delete-overview" title="Delete overview?" size="compact" description={`Delete “${deleting.name}”? Your habits and check-ins will stay saved.`} busy={busy} initialFocus="[data-keep-overview]" returnFocus={() => document.getElementById("create-overview")} onClose={() => setDeleting(null)}>
<FormMessage error>{deleteError}</FormMessage>
<div className="ds-action-row"><Button data-keep-overview variant="secondary" disabled={busy} onClick={() => setDeleting(null)}>Keep overview</Button><Button disabled={busy} onClick={() => void remove()}>{busy ? "Deleting…" : "Delete overview"}</Button></div>
</Modal>}
</>;
}
function OverviewForm({ chart, date, onClose, onSaved, onExpired }: {
chart?: Chart; date: string; onClose: () => void; onSaved: (chart: Chart) => void; onExpired: () => void;
}) {
const [name, setName] = useState(chart?.name ?? "");
const [color, setColor] = useState(chart?.settings.mainColor ?? "#196127");
const [ids, setIds] = useState(chart?.habitIds ?? []);
const [habits, setHabits] = useState<TodayHabit[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [loadError, setLoadError] = useState("");
const [attempt, setAttempt] = useState(0);
const saving = useRef(false);
const expired = useRef(onExpired); expired.current = onExpired;
useEffect(() => {
const controller = new AbortController(); setLoading(true); setLoadError("");
habitRequest<{ habits: TodayHabit[] }>(`/days/${date}`, { signal: controller.signal })
.then(data => { if (!controller.signal.aborted) setHabits(data.habits); })
.catch(error => { if (!controller.signal.aborted) { setLoadError(message(error)); if (message(error).includes("session has expired")) expired.current(); } })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [date, attempt]);
async function save() {
if (saving.current || loading || loadError) return;
if (!name.trim() || !ids.length || ids.length > 100) { setError("Enter a name and choose between 1 and 100 habits."); return; }
if (!isHabitColor(color)) { setError("Choose a six-digit hex color."); return; }
saving.current = true; setBusy(true); setError("");
try {
const saved = await habitRequest<Chart>(chart ? `/charts/${chart.id}` : "/charts", {
method: chart ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name.trim(), habitIds: ids, settings: { ...chart?.settings, mainColor: color } }),
});
onSaved(saved);
} catch (error) { setError(message(error)); if (message(error).includes("session has expired")) expired.current(); }
finally { saving.current = false; setBusy(false); }
}
return <Modal id="overview-form" title={chart ? "Edit overview" : "Create overview"} onClose={onClose} busy={busy}>
<form className="ds-form-stack" onSubmit={event => { event.preventDefault(); void save(); }}>
<Field label="Overview name">{id => <input id={id} required maxLength={200} value={name} disabled={busy} placeholder="e.g. Morning routine" onChange={event => setName(event.target.value)} />}</Field>
<fieldset className="ds-task-editor-fieldset" disabled={busy}><HabitColorPicker label="Overview color" value={color} onChange={setColor} /></fieldset>
<fieldset className="ds-task-editor-fieldset ds-form-stack" disabled={busy || loading}><legend className="type-ui-heading">Habits</legend>
<p className="ds-supporting-copy">Choose 1–100 habits. Archived habits can be included to review earlier progress.</p>
{habits.map(habit => <Checkbox key={habit.habitId} label={`${habit.name ?? "Habit"}${habit.requirements?.archived ? " · Archived" : ""}`} checked={ids.includes(habit.habitId)} disabled={!ids.includes(habit.habitId) && ids.length >= 100} onChange={event => { const checked = event.target.checked; setIds(current => checked ? [...current, habit.habitId] : current.filter(id => id !== habit.habitId)); }} />)}
</fieldset>
{loading && <p role="status">Loading habits…</p>}
{loadError && <><FormMessage error>{loadError}</FormMessage><Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry habits</Button></>}
{!loading && !loadError && !habits.length && <p>Create a habit first, then add it to an overview.</p>}
{chart && <p className="ds-supporting-copy">Changing the selected habits recalculates this overview’s entire history. Each habit keeps its original targets and schedules for earlier dates.</p>}
<FormMessage error>{error}</FormMessage>
<div className="ds-action-row"><Button type="submit" disabled={busy || loading || !!loadError || !ids.length || !name.trim() || !isHabitColor(color)}>{busy ? "Saving…" : "Save overview"}</Button><Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button></div>
</form>
</Modal>;
}
function OverviewCalendar({ chart, date, revision, disabled, onExpired, onHistorySaved }: {
chart: Chart; date: string; revision: string; disabled?: boolean; onExpired: () => void; onHistorySaved: () => void;
}) {
const [selection, setSelection] = useState({ months: 3 as MonthView | "custom", range: monthWindow(date, 3) });
const range = selection.months === "custom" ? selection.range : monthWindow(date, selection.months);
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [attempt, setAttempt] = useState(0);
const [inspecting, setInspecting] = useState<string | null>(null);
const expired = useRef(onExpired); expired.current = onExpired;
useEffect(() => {
const controller = new AbortController(); setLoading(true); setError("");
habitRequest<CalendarResponse>(`/charts/${chart.id}/calendar?${new URLSearchParams({ ...range, compact: "true" })}`, { signal: controller.signal })
.then(data => { if (!controller.signal.aborted) setCalendar(data); })
.catch(error => { if (!controller.signal.aborted) { setError(message(error)); if (message(error).includes("session has expired")) expired.current(); } })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [chart.id, revision, date, range.from, range.to, attempt]);
return <>
{error && <><FormMessage error>{error}</FormMessage><Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry calendar</Button></>}
{!calendar ? !error && <p role="status">Loading combined calendar…</p> : <CalendarHeatmap days={toHeatmapDays(calendar)} unit="habits complete" label={`${chart.name} combined calendar`} color={calendar.settings.mainColor} initialMonths={3} availableRange={monthWindow(date, 12)} loading={loading} onRangeChange={(range, months) => setSelection({ range, months })} historyLabel="Completed habits out of those due" selectedDayAction={selected => <Button variant="secondary" disabled={loading || !!error || disabled} aria-haspopup="dialog" onClick={() => setInspecting(selected)}>View day details</Button>} />}
{inspecting && <OverviewDay color={chart.settings.mainColor} chartName={chart.name} chartId={chart.id} date={inspecting} revision={`${revision}:${attempt}`} onClose={() => setInspecting(null)} onExpired={onExpired} onSaved={() => { setAttempt(value => value + 1); onHistorySaved(); }} />}
</>;
}
function OverviewDay({ color, chartName, chartId, date, revision, onClose, onExpired, onSaved }: {
color: string; chartName: string; chartId: string; date: string; revision: string; onClose: () => void; onExpired: () => void; onSaved: () => void;
}) {
const [day, setDay] = useState<Day | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0);
const [editing, setEditing] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const expired = useRef(onExpired); expired.current = onExpired;
useEffect(() => {
const controller = new AbortController(); setError(""); setLoading(true);
habitRequest<Day>(`/charts/${chartId}/days/${date}`, { signal: controller.signal })
.then(data => { if (!controller.signal.aborted) setDay(data); })
.catch(error => { if (!controller.signal.aborted) { setError(message(error)); if (message(error).includes("session has expired")) expired.current(); } })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [chartId, date, revision, attempt]);
return <Modal id="overview-day" title={chartName} description={`Day details · ${date}`} busy={busy} onClose={onClose}>
{error && <><FormMessage error>{error}</FormMessage><Button onClick={() => setAttempt(value => value + 1)}>Retry day details</Button></>}
{!day ? !error && <p role="status">Loading day details…</p> : <>
<OverviewDayGraphic day={day} color={color} busy={busy || loading} onEdit={setEditing} />
</>}
{editing && <Modal id="overview-check-in" title="Edit check-in" description={day?.habits.find(habit => habit.habitId === editing)?.name ?? date} busy={busy} onClose={() => setEditing(null)}><CheckInEditor key={`${editing}:${date}`} habitId={editing} date={date} onExpired={onExpired} onBusyChange={setBusy} onSaved={onSaved} /></Modal>}
</Modal>;
}

View File

@@ -0,0 +1,43 @@
import { useState } from "react";
import { Download } from "lucide-react";
import { applyUpdate, installApp, useOnline, usePwa } from "../pwa/client";
import { Button } from "./design-system/primitives";
import { Modal } from "./design-system/Modal";
export function InstallApp() {
const { enabled, installed, installPrompt } = usePwa();
const [help, setHelp] = useState(false);
if (!enabled || installed) return null;
return <>
<Button variant="primary" onClick={() => { if (installPrompt) void installApp(); else setHelp(true); }}><Download size={16} aria-hidden="true" />Install app</Button>
{help && <Modal id="install-app" size="compact" title="Install app" onClose={() => setHelp(false)}>
<div className="ds-form-stack">
<p><strong>iPhone or iPad:</strong> open the Share menu, then choose Add to Home Screen.</p>
<p><strong>Mac Safari:</strong> choose File → Add to Dock.</p>
<p><strong>Chrome or Edge:</strong> use the install icon in the address bar, or Install app in the browser menu.</p>
<p className="ds-supporting-copy">An internet connection is required to load and save habits.</p>
<div className="ds-actions"><Button onClick={() => setHelp(false)}>Got it</Button></div>
</div>
</Modal>}
</>;
}
export function PwaStatus() {
const online = useOnline();
const { updateReady, error } = usePwa();
const [confirmUpdate, setConfirmUpdate] = useState(false);
return <>
{!online && <div className="ds-form-feedback" role="status">You’re offline. Reconnect to load or save your habits. Changes are not queued.</div>}
{error && <div className="ds-form-feedback" role="status">{error}</div>}
{updateReady && <div className="ds-action-row" role="status">
<span>A new version of Mina Habits is ready.</span>
<Button variant="secondary" onClick={() => setConfirmUpdate(true)}>Update app</Button>
</div>}
{confirmUpdate && <Modal id="update-app" size="compact" title="Ready to update?" onClose={() => setConfirmUpdate(false)}>
<div className="ds-form-stack">
<p>This window will reload. Save any unfinished changes first.</p>
<div className="ds-actions"><Button onClick={applyUpdate}>Reload and update</Button><Button variant="text" onClick={() => setConfirmUpdate(false)}>Later</Button></div>
</div>
</Modal>}
</>;
}

View File

@@ -91,7 +91,6 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
<div className={`ds-share-preview${thumbnail ? " ds-share-preview--thumbnail" : ""}`} aria-busy={!ready && !error}>
{error ? <div className="ds-share-placeholder" role="alert"><ImageIcon size={28} aria-hidden="true" /><p>{error}</p>{ids.length > 0 && <Button variant="secondary" onClick={() => setRenderAttempt(n => n + 1)}>Retry preview</Button>}</div> : ready ? <img src={ready.url} alt={ready.alt} /> : <div className="ds-share-placeholder" role="status"><ImageIcon size={28} aria-hidden="true" /><p>Creating your card…</p></div>}
</div>
<p className="ds-share-caption">Your preview matches the shared image.</p>
{ready?.avatarMissing && <p role="status" className="ds-share-caption">Your avatar couldn’t load. A neutral profile icon is used.</p>}
</section>
<div className="ds-share-controls">
@@ -112,13 +111,13 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
</fieldset>
</details>
<div className="ds-share-destination"><span className="ds-share-destination-icon"><Hash size={20} aria-hidden="true" /></span><div><span className="ds-share-hint">Discord destination</span>
{connectionError ? <div role="alert"><p>{connectionError}</p><Button variant="text" disabled={busy} onClick={() => setConnectionAttempt(n => n + 1)}>Retry connection</Button></div> : connection?.connected ? <><a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a><p className="ds-share-hint">Posted by the Mina Habits bot.</p></> : <p className="ds-share-hint" role="status">{connection?.message ?? "Loading channel…"}</p>}
{connectionError ? <div role="alert"><p>{connectionError}</p><Button variant="text" disabled={busy} onClick={() => setConnectionAttempt(n => n + 1)}>Retry connection</Button></div> : connection?.connected ? <><a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a></> : <p className="ds-share-hint" role="status">{connection?.message ?? "Loading channel…"}</p>}
</div></div>
</div>
</div>
<footer className="ds-share-footer">
<div className="ds-share-footer-copy" aria-live="polite">
{sendError ? <p role="alert" className="ds-share-error">{sendError}</p> : sent ? <p>{sent.status === "sent" ? <>Shared successfully. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord ↗</a>}</> : <>Delivery unconfirmed. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"}; this attempt won’t be resent.</>}</p> : <p>{sending ? "Sending your card to Discord…" : error ? "Check your card settings." : ready ? "Ready to share." : "Preparing preview…"}<span>{sending ? "This may take a moment." : "Only the details in your preview are shared."}</span></p>}
{sendError ? <p role="alert" className="ds-share-error">{sendError}</p> : sent ? <p>{sent.status === "sent" ? <>Shared successfully. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord ↗</a>}</> : <>Delivery unconfirmed. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"}; this attempt won’t be resent.</>}</p> : <p>{sending ? "Sending your card to Discord…" : error ? "Check your card settings." : ready ? "Ready to share." : "Preparing preview…"}</p>}
</div>
<div className="ds-share-actions">
{ready ? <a className="ds-button ds-button--secondary" href={ready.url} download={`mina-habits-progress-${from}-${to}.png`}><Download size={17} aria-hidden="true" />Download</a> : <Button variant="secondary" disabled><Download size={17} aria-hidden="true" />Download</Button>}

View File

@@ -13,6 +13,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
document.documentElement.dataset.theme = theme;
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", theme === "dark" ? "#191e1b" : "#ffffff");
}, [theme]);
useEffect(() => {
const media = window.matchMedia?.("(prefers-color-scheme: dark)");

View File

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

View File

@@ -8,33 +8,30 @@ export function ContainerShowcase() {
return (
<section className="ds-section ds-container-showcase" id="containers" aria-labelledby="containers-title">
<SectionHeading number="CONTAINERS & LAYOUT" id="containers-title" title={<>A little structure. <em>Room to breathe.</em></>}>
Group related content with a quiet surface. Keep open layouts for long lists and calendars.
</SectionHeading>
<SectionHeading id="containers-title" title="Containers & layout" />
<div className="ds-spec-heading ds-spec-heading--spaced">
<h3>Two quiet surfaces</h3>
<h3>Card surfaces</h3>
<span className="ds-code">Card · soft / outlined</span>
</div>
<CardGrid>
<Card headingLevel={4} eyebrow="SOFT SURFACE" heading="A gentle grouping.">
<Card headingLevel={4} heading="Soft surface">
<p>A neutral fill gathers related content without adding another divider. Use it for summaries, guidance, and a small set of actions.</p>
</Card>
<Card headingLevel={4} variant="outlined" eyebrow="OUTLINED SURFACE" heading="A clear boundary.">
<Card headingLevel={4} variant="outlined" heading="Outlined surface">
<p>A fine border defines a standalone group on white. Useful when a form or a focused task needs its own space.</p>
</Card>
</CardGrid>
<div className="ds-spec-heading ds-spec-heading--spaced">
<h3>A day, arranged simply</h3>
<h3>Bento layout</h3>
<span className="ds-code">CardGrid · bento</span>
</div>
<CardGrid layout="bento" aria-label="Interactive bento layout example">
<Card
headingLevel={4}
span="featured"
eyebrow="TODAY / YOUR OWN PACE"
heading={<>Small things, <em>adding up.</em></>}
heading="Today"
>
<div className="ds-card-summary" role="status">
<p className="type-display">{Number(water === 8) + Number(read)}<span className="type-title ds-muted"> / 2</span></p>
@@ -45,11 +42,11 @@ export function ContainerShowcase() {
<Counter label="bento glasses of water" value={water} target={8} onChange={setWater} />
<p className="type-small" role="status">{water === 8 ? "Complete for today" : `${8 - water} glasses to go`}</p>
</Card>
<Card headingLevel={4} eyebrow="A FEW PAGES" heading="Read a little" variant="outlined">
<Card headingLevel={4} heading="Read a little" variant="outlined">
<Checkbox label="Reading done" checked={read} onChange={(event) => setRead(event.target.checked)} />
</Card>
<Card headingLevel={4} span="wide" eyebrow="A GENTLE REMINDER" heading="There’s no catching up.">
<p>Come back to today. Your next small step is enough.</p>
<Card headingLevel={4} span="wide" heading="Demo">
<p>Changes to these examples are not saved.</p>
</Card>
</CardGrid>
<div className="ds-container-guidance">

View File

@@ -407,18 +407,7 @@ export function EditingWorkbench({
</div>
<div className="ds-edit-layout">
<aside className="ds-edit-context">
<span className="ds-eyebrow">
{tab === "Backfill progress"
? "A DATE, NOT A RESET"
: "MAKE IT YOURS"}
</span>
<h3>
{tab === "Habit settings"
? "Shape your rhythm."
: tab === "Tasks"
? "Small steps, clearly defined."
: "A missed log is not a missed day."}
</h3>
<h3>{tab}</h3>
<p>
{tab === "Habit settings"
? "Adjust a name, target, or schedule. Changes start today; your history stays as it was."

View File

@@ -38,7 +38,9 @@ export function isHabitColor(value: string) {
export function HabitColorPicker({
value,
onChange,
label = "Habit color",
}: {
label?: string;
value: string;
onChange: (color: string) => void;
mode?: "demo" | "create" | "edit";
@@ -50,11 +52,11 @@ export function HabitColorPicker({
const showCustom = expanded || !valid;
return (
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
<legend>Habit color</legend>
<legend>{label}</legend>
<p id={`${id}-hint`} className="ds-visually-hidden">
Your color adapts to each theme. Custom colors use six-digit hex values, such as #426582.
</p>
<div className="ds-color-swatches" role="group" aria-label="Habit colors">
<div className="ds-color-swatches" role="group" aria-label={`${label}s`}>
{HABIT_COLOR_CHOICES.map((color) => (
<button key={color} type="button" aria-label={`Color ${color}`} aria-pressed={value.toLowerCase() === color} title={color} onClick={() => onChange(color)}>
<span style={{ backgroundColor: habitProgressColor(color, 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>

View File

@@ -3,7 +3,7 @@ import { Button } from "./primitives";
export function ItemActions({ name, kind, disabled, onEdit, onDelete }: {
name: string;
kind: "habit" | "task";
kind: "habit" | "task" | "overview";
disabled?: boolean;
onEdit: () => void;
onDelete: () => void;

View File

@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { Link } from "react-router";
import { ThemeControl } from "../ThemeProvider";
import { Button } from "./primitives";
import { InstallApp, PwaStatus } from "../PwaControls";
/** The existing design-system page shell, shared without page-specific styling. */
export function PageLayout({
@@ -31,13 +32,13 @@ export function PageLayout({
<Link className="ds-wordmark" to={wordmarkTo}>
Mina Habits<span aria-hidden="true">.</span>
</Link>
<div className="ds-header-actions">{header}<ThemeControl /></div>
<div className="ds-header-actions">{header}<InstallApp /><ThemeControl /></div>
</header>
<main id={mainId} className="ds-main" tabIndex={-1}>
<PwaStatus />
{children}
<footer className="ds-footer">
<span className="ds-wordmark">Mina Habits.</span>
<span>A little, every day.</span>
<Button
variant="text"
onClick={() => window.scrollTo({ top: 0, behavior: "instant" })}

View File

@@ -68,7 +68,6 @@ export function WelcomePanel({
<h1 id="dashboard-title" className="type-display">
Welcome back, <em>{name}.</em>
</h1>
<p className="ds-welcome-subtitle">Make time for today.</p>
{children}
</div>
<div className="ds-welcome-calendar">
@@ -82,7 +81,7 @@ export function WelcomePanel({
<span className="ds-date-weekday">
{day.toLocaleDateString("en", { weekday: "long", timeZone: "UTC" })}
</span>
<span className="ds-date-caption ds-eyebrow">TODAY, AT YOUR OWN PACE</span>
<span className="ds-date-caption ds-eyebrow">TODAY</span>
</time>
) : (
<p className="ds-muted" role="status">Your day is loading…</p>

View File

@@ -262,3 +262,20 @@ test("custom timeframe rejects missing, reversed and unavailable dates without c
expect(description(container.querySelector("input")!)).toContain(error!);
}
});
test("upcoming days off render the not-scheduled marker without moving selection past today", async () => {
await act(async () => root.render(<CalendarHeatmap unit="completion" label="Weekly habit" days={[
{ date: "2026-09-04", value: 0, target: 1, state: "due", future: false },
{ date: "2026-09-05", value: 0, target: 1, state: "not-due", future: true },
{ date: "2026-09-06", value: 0, target: 1, state: "future", future: true },
{ date: "2026-09-07", value: 0, target: 1, state: "not-due", future: true },
]} />));
const days = [...container.querySelectorAll<HTMLButtonElement>(".ds-day")];
expect(days.map(day => day.className)).toEqual([
"ds-day ds-day--due", "ds-day ds-day--not-due", "ds-day ds-day--future", "ds-day ds-day--not-due",
]);
expect(days[0]!.getAttribute("aria-pressed")).toBe("true");
expect(days[1]!.getAttribute("aria-label")).toContain("Nothing scheduled");
expect(days[2]!.getAttribute("aria-label")).toContain("Upcoming");
expect(days[1]!.style.backgroundColor).toBe("transparent");
});

View File

@@ -17,6 +17,7 @@ export type CalendarDay = {
date: string;
value: number;
target: number;
future?: boolean;
state: "due" | "not-due" | "future";
};

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";
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.
export const habits = sqliteTable('habits', {
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(),
}, t => [index('habits_user_idx').on(t.userId)]);
export const habitRevisions = sqliteTable('habit_revisions', {
@@ -47,7 +49,7 @@ export const habitDays = sqliteTable('habit_days', {
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(),
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', {
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(),

View File

@@ -55,7 +55,7 @@ export function commandGraphicAction(c: Context, action: string, data: ControlDa
];
const reply = message(c, content, [row(...controls)], false);
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) } }; }
});
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 '../sharing/live-card-image';
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 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. */
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.');
const { width, height } = commandCardSize(data), canvas = createCanvas(width, height), ctx = canvas.getContext('2d');
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);
text(data.note, 64, height - 48, 21, 860, 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 the existing renderer's bundled font registration and data contract.
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 };
/** 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];
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);
@@ -62,5 +66,6 @@ export async function renderProgressImage(data: LiveImageData, theme: Theme = 'l
text(item.label, x + 36, 772, 21, 230, palette.muted);
});
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');
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 { renderProgressImage } from './progress-image';
import { ApiError } from '../habits/service';
import { ApiError, HabitService } from '../habits/service';
import { liveCardData } from '../sharing/live-card-image';
import type { createSharingRoutes } from '../sharing/routes';
import type { createLiveDiscord } from '../sharing/live-discord';
@@ -14,9 +14,11 @@ export type ProgressOperations = {
export type DeferredMessage = { message: Message; image?: Uint8Array; imageDescription?: string };
const fingerprint = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
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) {
const habits = c.service.list();
const habits = snapshot.list();
const index = habits.findIndex(h => h.id === data.habitId);
if (index < 0) throw new ApiError(409, 'This habit changed. Run /progress again.');
result.habits = [result.habits[index]!];
@@ -24,7 +26,7 @@ function imageData(c: Context, data: ControlData) {
return { ...result, avatarUrl: null };
}
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 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 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.');
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));
}
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.
if (i.type === 2 && i.data?.name === 'help') return c.json(help(!!i.guild_id, config.origin));
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 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();
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();
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 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 };
let result: Reply;
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();
return result;
});
}));
if (pending) {
const work = pending;
const job = async () => {

View File

@@ -3,6 +3,9 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { App } from "./App";
import "../styles/globals.css";
import { startPwa } from "./pwa/client";
if (process.env.NODE_ENV === "production") startPwa();
const container = document.getElementById("root")!;
const root = import.meta.hot

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;
}
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');
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; }
}
c.set('payload', payload);
const service = new HabitService(db, c.get('user'), now());
service.sync(); c.set('service', service); await next();
await HabitService.withPrepared(db, c.get('user'), now, async service => {
c.set('service', service); await next();
});
}); if (response) c.res = response; };
if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) {
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.put('/habits/:id/calendar-settings', c => c.json(service(c).saveSettings(id(c), body(c, calendarSettingsSchema))));
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 => {
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.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 => {
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 { PublicUser } from '../shared/user';
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 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 {
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;
private ownedCache = new Map<string, Habit>();
private revisionCache = new Map<string, Revision[]>();
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 occurrencesCache = new Map<string, Map<number, (typeof taskOccurrences.$inferSelect)[]>>();
private countCache = new Map<string, Map<string, { value: number; carriedFrom: string | null }>>();
constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) {
// 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 };
// 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;
@@ -35,10 +77,27 @@ export class HabitService {
if (!habit) throw missing();
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 {
let revisions = this.revisionCache.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); }
return revisions.find(r => r.effectiveDate <= date);
const range = this.revisionRanges.get(id);
if (!range || date < range.from || date > range.to) this.loadRevisions(id, date, 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) {
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.' }]
: 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.` }));
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) {
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>) {
const id = crypto.randomUUID();
@@ -114,7 +173,8 @@ export class HabitService {
this.owned(id);
this.db.transaction(() => {
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);
}
@@ -137,19 +197,25 @@ export class HabitService {
}
sync() {
this.db.transaction(() => {
for (const habit of this.db.select().from(habits).where(eq(habits.userId, this.user.id)).all()) this.materialize(habit);
// Record the original deadline even after downtime. Only active day snapshots expire.
for (const habit of this.pending()) this.materialize(habit);
// 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
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)
WHERE closed_at IS NULL AND day_id IN (
SELECT d.id FROM habit_days d JOIN habits h ON h.id = d.habit_id
WHERE h.user_id = ${this.user.id} AND d.ends_at <= ${this.now}
WHERE id IN (
SELECT o.id FROM task_occurrences o INDEXED BY task_occurrences_open_idx
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))`);
});
}
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) {
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) {
let value = this.deadlineCache.get(date);
@@ -160,7 +226,7 @@ export class HabitService {
let records = this.recordsCache.get(id);
if (!records) {
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);
}
return records;
@@ -169,15 +235,17 @@ export class HabitService {
let occurrences = this.occurrencesCache.get(id);
if (!occurrences) {
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); }
this.occurrencesCache.set(id, occurrences);
}
return occurrences;
}
private materialize(habit: Habit) {
let date = habit.materializedThrough ?? habit.createdDate;
while (date <= this.today) {
private materialize(habit: Habit, through = this.today, force = false) {
let date = force ? through : habit.materializedThrough ? addDays(habit.materializedThrough, 1) : habit.createdDate;
if (date > through) return;
this.loadRevisions(habit.id, date, through);
while (date <= through) {
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();
if (!existing || existing.revisionId !== revision.id) {
@@ -193,23 +261,43 @@ export class HabitService {
.innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id))
.where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date)))
.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);
this.db.insert(taskOccurrences).values({ 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();
}
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 };
});
if (occurrences.length) this.db.insert(taskOccurrences).values(occurrences).run();
}
}
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) {
const cached = this.countCache.get(id); if (cached) return cached;
const rows = this.db.select({ day: habitDays, config: habitRevisions.config }).from(habitDays)
.innerJoin(habitRevisions, eq(habitDays.revisionId, habitRevisions.id))
.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})`))
.orderBy(asc(habitDays.date)).all();
const range = this.ranges.get(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})`;
const select = () => this.db.select({ day: habitDays, config: habitRevisions.config }).from(habitDays).innerJoin(habitRevisions, eq(habitDays.revisionId, habitRevisions.id));
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 }>();
let carry = 0; let source: string | null = null;
for (const { day, config } of rows) {
@@ -223,6 +311,7 @@ export class HabitService {
}
day(id: string, date: string) {
this.owned(id);
this.range(id, date);
const revision = this.revision(id, date);
const record = this.records(id).get(date);
const config = revision?.config;
@@ -235,9 +324,9 @@ 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 target = !config ? null : config.method === 'count' ? config.target : config.method === 'manual' ? 1 : tasks.length;
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,
status: future ? 'future' : !due ? 'not_due' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty',
status: !due ? 'not_due' : future ? 'future' : 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',
ratio, complete: due && ratio === 1, tasks, requirements: config ?? null };
}
@@ -256,6 +345,7 @@ export class HabitService {
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.update(habitDays).set('count' in input ? { ...input, countSet: true } : input).where(eq(habitDays.id, record.id)).run();
this.changed(id);
});
this.invalidate(id);
return this.day(id, date);
@@ -267,6 +357,7 @@ export class HabitService {
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.update(taskOccurrences).set({ done, updatedAt: this.now }).where(eq(taskOccurrences.id, occurrence.id)).run();
this.changed(id);
});
this.invalidate(id);
return this.day(id, date);
@@ -290,6 +381,7 @@ export class HabitService {
saveSettings(id: string, settings: CalendarSettings) {
this.owned(id);
this.db.insert(habitCalendarSettings).values({ habitId: id, settings }).onConflictDoUpdate({ target: habitCalendarSettings.habitId, set: { settings } }).run();
this.changed(id);
return settings;
}
chart(id: string) {
@@ -306,8 +398,8 @@ export class HabitService {
return this.chart(id);
}
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) {
ids.forEach(id => this.owned(id));
calendar(ids: string[], from: string, to: string, settings: CalendarSettings, combined: boolean, compact = false) {
ids.forEach(id => { this.owned(id); this.range(id, from, to); });
const days = [];
for (let date = from; date <= to; date = addDays(date, 1)) {
const details = ids.map(id => this.day(id, date));
@@ -317,12 +409,15 @@ export class HabitService {
const steps = manual ? 1 : settings.shadeCount === 'auto' ? combined ? 4 : details[0]!.target ?? 1 : settings.shadeCount;
const progress = shade(ratio, steps, settings, !combined && (settings.shadeCount === 'auto' || manual));
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',
shadeCount: steps, ...progress, color: future ? settings.futureColor : progress.color, habits: details });
days.push({ date, due: due.length, completed, ratio, future, status: ratio === null ? 'not_due' : future ? 'future' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty',
shadeCount: steps, ...progress, color: future && ratio !== null ? 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,
// Explicit colors and details are supplied for the client's Nivo colorScale/tooltip adapters.
data: days.map(d => ({ day: d.date, value: d.future ? -2 : d.ratio === null ? -1 : d.level! })) };
data: days.map(d => ({ day: d.date, value: d.ratio === null ? -1 : d.future ? -2 : d.level! })) };
}
setTimezone(timezone: string) {
this.db.update(users).set({ timezone, updatedAt: sql`max(${users.updatedAt} + 1, ${this.now})` }).where(eq(users.id, this.user.id)).run();

View File

@@ -2,14 +2,22 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<base href="/" />
<link rel="manifest" href="./pwa/manifest.webmanifest" />
<meta name="theme-color" content="#ffffff" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Mina Habits" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="icon" type="image/png" sizes="32x32" href="./assets/favicon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="./assets/apple-touch-icon.png" />
<title>Mina Habits</title>
<meta name="description" content="A quiet place to track your habits and see your progress. Explore interactive habit calendars and connect with Discord with Mina Habits." />
<meta name="description" content="Track habits, check in, and view your progress." />
<script type="module" src="./frontend.tsx" async></script>
</head>
<body>
<div id="root"></div>
<noscript>Mina Habits needs JavaScript to display your habits. Enable JavaScript and reload this page.</noscript>
</body>
</html>

View File

@@ -1,3 +1,5 @@
import { browserFont } from "./browser-fonts";
import { pwaAsset } from "./pwa/assets";
import { startReminders } from "./reminders/worker";
import { backupConfig, startBackups } from "./ops/backups";
import { databasePath } from "./db/config";
@@ -15,6 +17,7 @@ if (process.env.NODE_ENV !== "production") {
}
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());
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 +31,10 @@ const server = Bun.serve({
hostname: process.env.HOST ?? "127.0.0.1",
port: Number(process.env.PORT ?? 3000),
routes: {
"/manifest.webmanifest": pwaAsset,
"/sw.js": pwaAsset,
"/pwa/*": pwaAsset,
"/fonts/browser/*": browserFont,
"/api": req => app.fetch(req),
"/api/*": req => app.fetch(req),
"/*": index,

View File

@@ -14,6 +14,8 @@ export async function habitRequest<T>(
path: string,
options?: RequestInit,
): Promise<T> {
if (typeof navigator !== "undefined" && navigator.onLine === false)
throw new Error("You’re offline. Reconnect and try again. Changes have not been saved or queued.");
const response = await fetch(`/api${path}`, options);
if (!response.ok) {
if (response.status === 401)
@@ -46,3 +48,15 @@ export function formatTrackingDate(date: string) {
timeZone: "UTC",
});
}
/** Preserve unchanged row references when a background refresh returns new JSON objects. */
export function reconcileToday(current: TodayResponse | null, next: TodayResponse): TodayResponse {
if (!current || current.date !== next.date || current.timezone !== next.timezone) return next;
const previous = new Map(current.habits.map(habit => [habit.habitId, habit]));
const habits = next.habits.map(habit => {
const old = previous.get(habit.habitId);
return old && JSON.stringify(old) === JSON.stringify(habit) ? old : habit;
});
if (current.due === next.due && current.completed === next.completed && habits.length === current.habits.length && habits.every((habit, index) => habit === current.habits[index])) return current;
return { ...next, habits };
}

View File

@@ -1,6 +1,6 @@
import { expect, test } from "bun:test";
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 { tmpdir } from "node:os";
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.directory)).toContain("keep.txt");
const runner = startBackups(f.sqlite, config);
await runner.ready;
expect(runner.state.failed).toBe(false); expect(runner.state.lastSuccess).toBeGreaterThan(0); runner.stop();
} 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 });
} 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 { chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
@@ -82,18 +83,40 @@ export function restoreBackup(source: string, destination: string) {
} finally { if (existsSync(temporary)) unlinkSync(temporary); }
return destination;
}
export function startBackups(database: Database, config: BackupConfig) {
const state = { enabled: config.enabled, lastSuccess: 0, failed: false };
if (!config.enabled) return { state, stop() {} };
const tick = () => {
try {
const latest = latestBackupTime(config);
if (latest && Date.now() - latest < config.intervalMs) { state.lastSuccess = latest; state.failed = false; return; }
createBackup(database, config); state.lastSuccess = Date.now(); state.failed = false;
console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date().toISOString() }));
} catch { state.failed = true; console.error(JSON.stringify({ event: "backup_failed", timestamp: new Date().toISOString() })); }
};
tick();
const timer = setInterval(tick, 60000); timer.unref();
return { state, stop: () => clearInterval(timer) };
/** Runs all filesystem and SQLite backup work in a separate Bun process. */
export async function runScheduledBackup(database: string, config: BackupConfig): Promise<number> {
const source = new URL("../../scripts/scheduled-backup.ts", import.meta.url);
const built = new URL("../scripts/scheduled-backup.ts", import.meta.url);
const script = fileURLToPath(existsSync(source) ? source : built);
const child = Bun.spawn([process.execPath, script, resolve(database), JSON.stringify(config)], {
stdout: "pipe", stderr: "ignore",
});
const [output, status] = await Promise.all([new Response(child.stdout).text(), child.exited]);
if (status !== 0) throw new Error("Scheduled backup process failed");
const { timestamp, created } = JSON.parse(output) as { timestamp: number; created: boolean };
if (!Number.isFinite(timestamp) || timestamp <= 0) throw new Error("Scheduled backup returned an invalid timestamp");
if (created) console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date(timestamp).toISOString() }));
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

@@ -36,7 +36,7 @@ export function DesignSystem() {
<DesignSystemTabs tabs={[
{ id: "foundations", label: "Foundations", content: (
<section className="ds-section ds-split-section" id="foundations">
<SectionHeading number="FOUNDATIONS" title="The essentials." />
<SectionHeading title="Foundations" />
<div className="ds-section-content">
<div className="ds-spec-heading">
<h3>Typography</h3>
@@ -44,14 +44,14 @@ export function DesignSystem() {
</div>
<div className="ds-type-specimen">
<span className="ds-type-display">
Small steps.
Regular type.
<br />
<em>Lasting rhythm.</em>
<em>Italic type.</em>
</span>
<p>Instrument Serif · Regular & italic · 72/81px display, 48/54px on small screens</p>
</div>
<div className="ds-body-specimen">
<span>Room to breathe. A clear next step.</span>
<span>Body text and controls.</span>
<p>System sans · 16/24px body & controls · 14/21px supporting text</p>
</div>
<ul className="ds-type-scale" aria-label="Shared typography scale">
@@ -131,7 +131,7 @@ export function DesignSystem() {
) },
{ id: "components", label: "Components", content: (
<section className="ds-section ds-split-section" id="components">
<SectionHeading number="COMPONENTS" title="Only what matters." />
<SectionHeading title="Components" />
<div className="ds-section-content">
<div className="ds-spec-heading">
<h3>Actions</h3>
@@ -180,7 +180,7 @@ export function DesignSystem() {
</div>
<div className="ds-component-row">
<div>
<h3>A little more</h3>
<h3>Counter</h3>
<span className="ds-code">Counter · editable total</span>
</div>
<Counter
@@ -245,7 +245,7 @@ export function DesignSystem() {
<section className="ds-section ds-split-section" id="calendar-states">
<SectionHeading
number="PROGRESS LANGUAGE"
title="Every shade counts."
title="Calendar states"
/>
<div className="ds-section-content">
<div className="ds-spec-heading">

View File

@@ -1,69 +1,73 @@
import { ProgressOverview } from "../components/ProgressOverview";
import { deferred } from "../components/design-system/deferred";
import { useOnline } from "../pwa/client";
import { CalendarCacheProvider } from "../components/CalendarCache";
import { Archive, Plus, Settings2 } from "lucide-react";
import { ArchivedHabits } from "../components/ArchivedHabits";
import { AccountSettings } from "../components/AccountSettings";
import { useCallback, useEffect, useRef, useState } from "react";
const ArchivedHabits = deferred(() => import("../components/ArchivedHabits").then(module => module.ArchivedHabits));
const AccountSettings = deferred(() => import("../components/AccountSettings").then(module => module.AccountSettings));
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "../components/AuthProvider";
import { DiscordSignInButton } from "../components/DiscordSignInButton";
import { PageLayout } from "../components/design-system/PageLayout";
import {
Button,
ButtonLink,
Checkbox,
Counter,
SectionHeading,
} from "../components/design-system/primitives";
import { Card, CardGrid } from "../components/design-system/Card";
import { HabitChart } from "../components/design-system/HabitChart";
import { HABIT_COLORS } from "../components/design-system/calendar-model";
import { HabitForm } from "../components/HabitForm";
import { habitRequest, scheduleLabel, type TodayHabit, type TodayResponse } from "../lib/dashboard";
const HabitForm = deferred(() => import("../components/HabitForm").then(module => module.HabitForm));
import { habitRequest, reconcileToday, scheduleLabel, type TodayHabit, type TodayResponse } from "../lib/dashboard";
import { HabitHistory } from "../components/HabitHistory";
import { WelcomePanel } from "../components/design-system/WelcomePanel";
import type { PublicUser } from "../shared/user";
import { ItemActions } from "../components/design-system/ItemActions";
import { type ItemMode } from "../components/InlineItemForm";
import { InlineHabitEditor } from "../components/InlineHabitEditor";
import { InlineTaskEditor } from "../components/InlineTaskEditor";
const InlineHabitEditor = deferred(() => import("../components/InlineHabitEditor").then(module => module.InlineHabitEditor));
const InlineTaskEditor = deferred(() => import("../components/InlineTaskEditor").then(module => module.InlineTaskEditor));
import type { Schedule } from "../habits/contracts";
import { ShareProgress } from "../components/ShareProgress";
const ShareProgress = deferred(() => import("../components/ShareProgress").then(module => module.ShareProgress));
export function Home() {
const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();
const online = useOnline();
const { user, loading, busy, error, accountError, connectionError, signIn, signOut, retry } = useAuth();
const disconnected = !online || connectionError;
return (
<PageLayout
header={
loading ? (
<span className="ds-library-label">Loading account…</span>
) : user ? (
<Button variant="text" disabled={busy} onClick={() => void signOut()}>
<Button variant="text" disabled={busy || !online} onClick={() => void signOut()}>
{busy ? "Signing out…" : "Sign out"}
</Button>
) : (
!accountError && <DiscordSignInButton variant="secondary" onClick={signIn} />
!accountError && <DiscordSignInButton disabled={!online} variant="secondary" onClick={signIn} />
)
}
>
{error && (
{error && !disconnected && (
<div className="ds-form-feedback" role="alert">
{error}
</div>
)}
{loading ? (
<section className="ds-section" aria-busy="true">
<p role="status">Getting things ready…</p>
<p role="status">Loading account…</p>
</section>
) : accountError ? (
<section className="ds-section">
<h1 className="type-section">Let’s try that again.</h1>
<Card headingLevel={2} heading="Account unavailable">
<p>Your account couldn’t be loaded.</p>
<h1 className="type-section">{disconnected ? "Connection unavailable" : "Account unavailable"}</h1>
<div className="ds-form-stack">
<p>{disconnected ? "Check your connection. We’ll retry automatically." : "Your account couldn’t be loaded."}</p>
<div className="ds-actions">
<Button onClick={retry}>Try again</Button>
</div>
</Card>
</div>
</section>
) : user ? (
<Dashboard key={user.id} user={user} onExpired={retry} />
<CalendarCacheProvider key={`${user.id}:${user.timezone}`}><Dashboard user={user} onExpired={retry} /></CalendarCacheProvider>
) : (
<Welcome onSignIn={signIn} />
)}
@@ -72,43 +76,22 @@ export function Home() {
}
function Welcome({ onSignIn }: { onSignIn: () => void }) {
const online = useOnline();
const [water, setWater] = useState(3);
const [read, setRead] = useState(false);
return (
<>
<section className="ds-section ds-split-section" aria-labelledby="welcome-title">
<div className="ds-section-heading">
<p className="ds-eyebrow">A LITTLE, EVERY DAY</p>
<h1 id="welcome-title" className="type-section">
Small steps.
<br />
<em>Lasting rhythm.</em>
</h1>
</div>
<Card headingLevel={2} heading="Make room for what matters.">
<p>
Check in, count a little more, or work through a few tasks. See your progress grow, one
day at a time.
</p>
<section className="ds-section ds-landing-intro" aria-labelledby="welcome-title">
<h1 id="welcome-title" className="type-section">Track your habits</h1>
<p className="type-small">Sign in to save your habits and progress.</p>
<div className="ds-actions">
<DiscordSignInButton onClick={onSignIn} />
<ButtonLink href="#try-it">Try it below ↓</ButtonLink>
<DiscordSignInButton disabled={!online} onClick={onSignIn} />
</div>
<p className="type-small">Sign in with your Discord account to save your habits.</p>
</Card>
</section>
<section className="ds-section" id="try-it" aria-labelledby="try-it-title">
<div className="ds-section-top">
<SectionHeading
number="TRY A CHECK-IN"
id="try-it-title"
title={
<>
A little progress. <em>Made visible.</em>
</>
}
/>
<span className="ds-demo-note">Example · September 4, 2026 · not saved</span>
<SectionHeading id="try-it-title" title="Try a check-in" />
<span className="ds-demo-note">Demo · changes are not saved</span>
</div>
<div className="ds-habit-chart-grid">
<HabitChart
@@ -139,31 +122,16 @@ function Welcome({ onSignIn }: { onSignIn: () => void }) {
</HabitChart>
</div>
</section>
<section className="ds-section" aria-label="Your own rhythm">
<CardGrid>
<Card heading="Your habits. Your pace." eyebrow="MAKE IT FIT">
<p>
Pick a check-in, a count target, or a task list. Repeat daily, on selected weekdays,
or at your own interval.
</p>
</Card>
<Card heading="See the days add up." eyebrow="KEEP PERSPECTIVE" variant="outlined">
<p>
Explore your calendar to see the progress behind each square. Days off stay distinct
from missed days.
</p>
</Card>
</CardGrid>
</section>
</>
);
}
function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => void }) {
const [today, setToday] = useState<TodayResponse | null>(null);
const todaySnapshot = useRef(today);
todaySnapshot.current = today;
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
const [calendarRevision, setCalendarRevision] = useState(0);
const [habitRevisions, setHabitRevisions] = useState<Record<string, number>>({});
const [pendingHabits, setPendingHabits] = useState<Set<string>>(new Set());
const progressSaves = useRef(new Set<string>());
@@ -171,6 +139,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
const [loading, setLoading] = useState(true);
const [habitView, setHabitView] = useState<"today" | "all">("today");
const [adding, setAdding] = useState(false);
const [addingOverview, setAddingOverview] = useState(false);
const [sharing, setSharing] = useState(false);
const [settings, setSettings] = useState(false);
const [archive, setArchive] = useState(false);
@@ -203,10 +172,10 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
habitRequest<TodayResponse>("/today", { signal: controller.signal })
.then((data) => {
if (!controller.signal.aborted) {
setToday(data);
const next = reconcileToday(todaySnapshot.current, data);
setToday(next);
setNeedsRefresh(false);
setRevision((value) => value + 1);
setCalendarRevision((value) => value + 1);
if (next !== todaySnapshot.current) setRevision((value) => value + 1);
}
})
.catch((error) => {
@@ -227,14 +196,16 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
// Refresh after returning to the page and across the account's local midnight.
useEffect(() => {
const refresh = () => {
if (!saving.current && progressSaves.current.size === 0 && document.visibilityState === "visible")
if (navigator.onLine !== false && !saving.current && progressSaves.current.size === 0 && document.visibilityState === "visible")
setAttempt((value) => value + 1);
};
const timer = window.setInterval(refresh, 60_000);
window.addEventListener("focus", refresh);
window.addEventListener("online", refresh);
return () => {
window.clearInterval(timer);
window.removeEventListener("focus", refresh);
window.removeEventListener("online", refresh);
};
}, []);
@@ -302,7 +273,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
try {
const refreshed = await habitRequest<TodayResponse>("/today");
if (mounted.current) {
setToday(refreshed);
setToday(current => reconcileToday(current, refreshed));
// Keep a habit reachable when an edit makes it unscheduled (including an empty routine).
if (refreshed.habits.some(item => item.habitId === habit.habitId && !item.due)) setHabitView("all");
setNeedsRefresh(false);
@@ -337,6 +308,13 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
}
}
const actions = useRef({ update, manage });
actions.current = { update, manage };
const updateHabit = useCallback((...args: Parameters<typeof update>) => actions.current.update(...args), []);
const manageHabit = useCallback((...args: Parameters<typeof manage>) => actions.current.manage(...args), []);
const refreshHistory = useCallback(() => setAttempt(value => value + 1), []);
const expire = useCallback(() => expired.current(), []);
return (
<>
<WelcomePanel
@@ -356,16 +334,13 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
</span>
<span>
habits complete today
{today.completed === today.due && (
<span className="ds-welcome-complete">A little, all done.</span>
)}
</span>
</>
) : (
<p>
{today.habits.length
? "Nothing scheduled today. Enjoy a little breathing room."
: "Start with one habit. Your first small step starts here."}
? "Nothing scheduled today."
: "No habits yet."}
</p>
)}
</div>
@@ -381,6 +356,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
>
Add a habit +
</Button>
<Button id="create-overview" variant="secondary" disabled={busy || loading || needsRefresh} aria-haspopup="dialog" onClick={() => setAddingOverview(true)}>Create overview</Button>
{today.habits.length > 0 && (
<>
<Button id="open-sharing" aria-haspopup="dialog" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button>
@@ -430,7 +406,6 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
}}
/>
)}
{today.habits.length > 0 && (
<section
className="ds-section ds-habits-section"
aria-labelledby="habits-title"
@@ -439,42 +414,38 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
<div className="ds-section-top">
<SectionHeading
id="habits-title"
title={
<>
Today, <em>at your pace.</em>
</>
}
title="Habits"
/>
<div className="ds-action-row ds-habit-filters" role="group" aria-label="Show habits">
<Button variant={habitView === "today" ? "primary" : "text"} aria-pressed={habitView === "today"} onClick={() => setHabitView("today")}>Today · {today.due}</Button>
<Button variant={habitView === "all" ? "primary" : "text"} aria-pressed={habitView === "all"} onClick={() => setHabitView("all")}>All habits · {today.habits.length}</Button>
</div>
</div>
{habitView === "today" && today.due === 0 && <p className="ds-supporting-copy">You’re all set for today. Choose All habits to view or edit your routines.</p>}
{habitView === "today" && today.habits.length > 0 && today.due === 0 && <p className="ds-supporting-copy">Choose All habits to view or edit your routines.</p>}
<div className="ds-habit-chart-grid ds-habit-list">
{today.habits.filter(habit => habitView === "all" || habit.due).map((habit) => (
<SavedHabit
key={habit.habitId}
habit={habit}
date={today.date}
revision={calendarRevision + (habitRevisions[habit.habitId] ?? 0)}
revision={habit.calendarRevision ?? habitRevisions[habit.habitId] ?? 0}
disabled={busy || pendingHabits.has(habit.habitId) || loading || needsRefresh}
onHistorySaved={() => setAttempt(value => value + 1)}
onUpdate={update}
onManage={manage}
onExpired={() => expired.current()}
onHistorySaved={refreshHistory}
onUpdate={updateHabit}
onManage={manageHabit}
onExpired={expire}
/>
))}
<ProgressOverview creating={addingOverview} onCloseCreate={() => setAddingOverview(false)} date={today.date} revision={revision + attempt} disabled={busy || loading || needsRefresh || pendingHabits.size > 0} onExpired={expire} onHistorySaved={refreshHistory} />
</div>
</section>
)}
</>
)}
</>
);
}
function SavedHabit({
const SavedHabit = memo(function SavedHabit({
habit,
date,
revision,
@@ -604,7 +575,7 @@ function SavedHabit({
) : undefined}
</HabitHistory>
);
}
});
function SavedTask({
task,

26
src/pwa/assets.ts Normal file
View File

@@ -0,0 +1,26 @@
import path from "node:path";
import { manifest } from "./manifest";
/** Serve only public PWA files. Never expose the server bundle or source maps. */
export function pwaAsset(request: Request): Response {
const pathname = new URL(request.url).pathname;
const headers = { "Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff" };
if (!["GET", "HEAD"].includes(request.method)) return new Response(null, { status: 405, headers: { ...headers, Allow: "GET, HEAD" } });
if (pathname === "/manifest.webmanifest") {
return new Response(request.method === "HEAD" ? null : JSON.stringify(manifest), { headers: { ...headers, "Content-Type": "application/manifest+json" } });
}
const production = process.env.NODE_ENV === "production";
const filename = pathname.slice("/pwa/".length);
const icon = /^icon-(192|512|maskable-512)\.png$/.test(filename);
const shell = production && /^shell-[a-f0-9]{16}\.html$/.test(filename);
if (pathname !== "/sw.js" && !icon && !shell) return new Response(null, { status: 404, headers });
if (pathname === "/sw.js" && !production) return new Response(null, { status: 404, headers });
const file = Bun.file(path.resolve(production ? "pwa" : "src/pwa/public", pathname === "/sw.js" ? "sw.js" : filename));
if (!file.size) return new Response(null, { status: 404, headers });
return new Response(request.method === "HEAD" ? null : file, { headers: {
...headers,
"Content-Type": icon ? "image/png" : shell ? "text/html; charset=utf-8" : "text/javascript; charset=utf-8",
...(pathname === "/sw.js" ? { "Service-Worker-Allowed": "/" } : {}),
...(shell ? { "Cache-Control": "public, max-age=31536000, immutable" } : {}),
} });
}

90
src/pwa/client.ts Normal file
View File

@@ -0,0 +1,90 @@
import { useSyncExternalStore } from "react";
type InstallPrompt = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
};
type PwaState = { enabled: boolean; installed: boolean; installPrompt: InstallPrompt | null; updateReady: boolean; error: string };
let state: PwaState = { enabled: false, installed: false, installPrompt: null, updateReady: false, error: "" };
const listeners = new Set<() => void>();
let registration: ServiceWorkerRegistration | undefined;
let reloading = false;
let started = false;
const publish = (change: Partial<PwaState>) => {
state = { ...state, ...change };
listeners.forEach(listener => listener());
};
const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; };
export const usePwa = () => useSyncExternalStore(subscribe, () => state, () => state);
const subscribeOnline = (listener: () => void) => {
window.addEventListener("online", listener);
window.addEventListener("offline", listener);
return () => { window.removeEventListener("online", listener); window.removeEventListener("offline", listener); };
};
export const useOnline = () => useSyncExternalStore(subscribeOnline, () => navigator.onLine !== false, () => true);
/** Called once before React renders so early install events cannot be missed. */
export function startPwa() {
if (started || !window.isSecureContext || !("serviceWorker" in navigator)) return;
started = true;
const standalone = window.matchMedia("(display-mode: standalone)");
const installed = () => standalone.matches || (navigator as Navigator & { standalone?: boolean }).standalone === true;
publish({ enabled: true, installed: installed() });
standalone.addEventListener("change", () => publish({ installed: installed() }));
window.addEventListener("beforeinstallprompt", event => {
event.preventDefault();
publish({ installPrompt: event as InstallPrompt });
});
window.addEventListener("appinstalled", () => publish({ installed: true, installPrompt: null }));
let controller = navigator.serviceWorker.controller;
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (reloading) window.location.reload();
else if (controller) publish({ updateReady: true });
controller = navigator.serviceWorker.controller;
});
const register = async () => {
try {
registration = await navigator.serviceWorker.register("/sw.js", { scope: "/", updateViaCache: "none" });
publish({ updateReady: Boolean(registration.waiting) });
const observeInstall = () => {
const worker = registration?.installing;
worker?.addEventListener("statechange", () => {
if (worker.state === "installed") publish({ updateReady: Boolean(navigator.serviceWorker.controller), error: "" });
if (worker.state === "redundant" && !navigator.serviceWorker.controller)
publish({ error: "Offline setup didn’t finish. Reconnect and reload to try again." });
});
};
registration.addEventListener("updatefound", observeInstall);
observeInstall();
const check = () => {
if (navigator.onLine && document.visibilityState === "visible") void registration?.update().catch(() => {});
};
document.addEventListener("visibilitychange", check);
window.addEventListener("online", check);
window.setInterval(check, 60 * 60 * 1000);
} catch {
publish({ error: "Offline setup is unavailable. Reconnect and reload to try again." });
}
};
if (document.readyState === "complete") void register();
else window.addEventListener("load", () => { void register(); }, { once: true });
}
export async function installApp(): Promise<boolean> {
const prompt = state.installPrompt;
if (!prompt) return false;
publish({ installPrompt: null });
try {
await prompt.prompt();
await prompt.userChoice;
} catch { publish({ error: "Could not open installation. Try your browser’s install menu." }); }
return true;
}
export function applyUpdate() {
if (!state.updateReady) return;
if (registration?.waiting) {
reloading = true;
registration.waiting.postMessage({ type: "ACTIVATE_UPDATE" });
} else window.location.reload();
}

9
src/pwa/manifest.ts Normal file
View File

@@ -0,0 +1,9 @@
import source from "./manifest.webmanifest" with { type: "text" };
export const manifest = JSON.parse(source) as {
id: string; name: string; short_name: string; description: string;
lang: string; dir: string; start_url: string; scope: string; display: string;
background_color: string; theme_color: string; categories: string[];
prefer_related_applications: boolean;
icons: { src: string; sizes: string; type: string; purpose: string }[];
};

View File

@@ -0,0 +1,38 @@
{
"id": "/",
"name": "Mina Habits",
"short_name": "Mina Habits",
"description": "A quiet place to track your habits and see your progress.",
"lang": "en",
"dir": "ltr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"categories": [
"productivity",
"lifestyle"
],
"prefer_related_applications": false,
"icons": [
{
"src": "/pwa/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

BIN
src/pwa/public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
src/pwa/public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

66
src/pwa/sw.js Normal file
View File

@@ -0,0 +1,66 @@
/* Build injects a content-derived version and an explicit public-asset allowlist. */
const CONFIG = JSON.parse("__PWA_CONFIG__");
const PREFIX = "mina-habits-static-";
const CACHE = PREFIX + CONFIG.version;
const ASSETS = new Set(CONFIG.assets);
self.addEventListener("install", event => {
event.waitUntil((async () => {
const cache = await caches.open(CACHE);
// addAll is atomic: a failed download cannot activate a partial release.
await cache.addAll(CONFIG.assets.map(url => new Request(url, { cache: "reload", credentials: "omit" })));
})());
});
self.addEventListener("activate", event => {
event.waitUntil((async () => {
// A user can accept an update while other windows still hold older code.
// Preserve their lazy chunks until a later activation with no open windows.
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
if (windows.length === 0) {
await Promise.all((await caches.keys()).filter(key => key.startsWith(PREFIX) && key !== CACHE).map(key => caches.delete(key)));
}
await self.clients.claim();
})());
});
self.addEventListener("message", event => {
if (event.data?.type === "ACTIVATE_UPDATE") event.waitUntil(self.skipWaiting());
});
async function cachedAsset(request) {
const current = await caches.open(CACHE);
const cached = await current.match(request);
if (cached) return cached;
// Old windows can still request fingerprinted chunks after an accepted update.
for (const key of await caches.keys()) {
if (key.startsWith(PREFIX) && key !== CACHE) {
const response = await (await caches.open(key)).match(request);
if (response) return response;
}
}
return fetch(request);
}
async function navigate(request) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4000);
try {
const response = await fetch(request, { signal: controller.signal });
if (response.ok) return response;
} catch { /* Unreachable server or offline: use the public shell. */ }
finally { clearTimeout(timeout); }
return (await (await caches.open(CACHE)).match(CONFIG.shell)) || Response.error();
}
self.addEventListener("fetch", event => {
const request = event.request;
const url = new URL(request.url);
// In particular: no account data, OAuth callbacks, exports, writes or third-party responses.
if (request.method !== "GET" || url.origin !== self.location.origin || /^\/api(?:\/|$)/.test(url.pathname)) return;
if (request.mode === "navigate") {
event.respondWith(navigate(request));
} else if (ASSETS.has(url.pathname) || /^\/chunk-[\w-]+\.js$/.test(url.pathname)) {
event.respondWith(cachedAsset(request));
}
});

99
src/pwa/sw.test.ts Normal file
View File

@@ -0,0 +1,99 @@
import { expect, test } from "bun:test";
import { runInNewContext } from "node:vm";
const template = await Bun.file(new URL("./sw.js", import.meta.url)).text();
const origin = "https://mina.example";
const cacheName = "mina-habits-static-test";
function worker({ offline = false, windows = 0, failInstall = false } = {}) {
const events = new Map<string, (event: any) => void>();
const stores = new Map<string, Map<string, Response>>([
[cacheName, new Map([[`${origin}/pwa/shell-test.html`, new Response("offline shell")]])],
["mina-habits-static-old", new Map([[`${origin}/chunk-old.js`, new Response("old chunk")]])],
["unrelated-cache", new Map()],
]);
const deleted: string[] = [];
const fetched: string[] = [];
let skipped = false;
let claimed = false;
let installed: Request[] = [];
const requestOptions: RequestInit[] = [];
const url = (input: Request | string) => new URL(typeof input === "string" ? input : input.url, origin).href;
const config = { version: "test", shell: "/pwa/shell-test.html", assets: ["/chunk-new.js", "/pwa/shell-test.html"] };
runInNewContext(template.replace('"__PWA_CONFIG__"', JSON.stringify(JSON.stringify(config))), {
self: {
location: { origin }, addEventListener: (type: string, handler: (event: any) => void) => events.set(type, handler),
skipWaiting: async () => { skipped = true; },
clients: { matchAll: async () => Array.from({ length: windows }, () => ({})), claim: async () => { claimed = true; } },
},
caches: {
open: async (name: string) => ({
match: async (request: Request | string) => stores.get(name)?.get(url(request))?.clone(),
addAll: async (requests: Request[]) => { if (failInstall) throw new Error("download failed"); installed = requests; },
}),
keys: async () => [...stores.keys()],
delete: async (name: string) => { deleted.push(name); return stores.delete(name); },
},
fetch: async (request: Request) => { fetched.push(request.url); if (offline) throw new TypeError("offline"); return new Response("network"); },
Request: class extends Request { constructor(input: string, init?: RequestInit) { super(url(input), init); requestOptions.push(init ?? {}); } },
Response, Set, URL, AbortController, setTimeout, clearTimeout,
});
async function lifecycle(type: string, data?: unknown) {
let promise: Promise<unknown> | undefined;
events.get(type)!({ data, waitUntil: (value: Promise<unknown>) => { promise = value; } });
await promise;
}
function request(pathname: string, method = "GET", mode = "cors") {
let response: Promise<Response> | undefined;
events.get("fetch")!({ request: { url: new URL(pathname, origin).href, method, mode }, respondWith: (value: Promise<Response>) => { response = value; } });
return response;
}
return { request, lifecycle, deleted, fetched, requestOptions, get skipped() { return skipped; }, get claimed() { return claimed; }, get installed() { return installed; } };
}
test("service worker excludes private API and OAuth navigations, writes and other origins", () => {
const sw = worker();
for (const route of ["/api", "/api/me", "/api/account/export", "/api/auth/discord/callback?code=private"]) {
expect(sw.request(route, "GET", "navigate")).toBeUndefined();
}
expect(sw.request("/chunk-new.js", "POST")).toBeUndefined();
expect(sw.request("https://discord.com/api/me")).toBeUndefined();
expect(sw.request("/private.json")).toBeUndefined();
expect(sw.request("/index.js")).toBeUndefined();
expect(sw.request("/chunk-new.js.map")).toBeUndefined();
});
test("network navigation is fresh while offline navigation uses only the public shell", async () => {
expect(await (await worker().request("/", "GET", "navigate"))!.text()).toBe("network");
const sw = worker({ offline: true });
expect(await (await sw.request("/a/deep/link", "GET", "navigate"))!.text()).toBe("offline shell");
});
test("older windows can load their cached lazy chunks after an update", async () => {
const sw = worker({ offline: true });
expect(await (await sw.request("/chunk-old.js"))!.text()).toBe("old chunk");
expect(sw.fetched).toEqual([]);
});
test("install excludes credentials and waits for every public asset without forcing activation", async () => {
const sw = worker();
await sw.lifecycle("install");
expect(sw.installed).toHaveLength(2);
expect(sw.requestOptions.every(options => options.credentials === "omit" && options.cache === "reload")).toBe(true);
expect(sw.skipped).toBe(false);
await expect(worker({ failInstall: true }).lifecycle("install")).rejects.toThrow("download failed");
});
test("activation preserves old windows and cleans only its own obsolete caches when windows close", async () => {
const occupied = worker({ windows: 2 });
await occupied.lifecycle("activate");
expect(occupied.deleted).toEqual([]);
expect(occupied.claimed).toBe(true);
const empty = worker();
await empty.lifecycle("activate");
expect(empty.deleted).toEqual(["mina-habits-static-old"]);
await empty.lifecycle("message", { type: "unrelated" });
expect(empty.skipped).toBe(false);
await empty.lifecycle("message", { type: "ACTIVATE_UPDATE" });
expect(empty.skipped).toBe(true);
});

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 { fixture } from "../habits/test-fixture";
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([]);
} 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.
db.delete(reminderDeliveries).where(lt(reminderDeliveries.updatedAt, now() - 90 * 86400000)).run();
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 prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get();
if (!currentUser || !prefs?.enabled) return null;
const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone);
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;
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);
service.sync();
// Avoid sending an artificial "today" after a backwards timezone change.
if (service.today !== localDate(timestamp, currentUser.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: currentUser.timezone, times, entries: entriesForDate(prefs, service.today) } : null;
if (checkDeliveries) {
const previous = db.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, date))).all();
// This is only a cheap eligibility hint. The transaction below still owns claims.
// Let stale pending records reach its recovery step before skipping the user.
const stalePending = previous.some(row => row.status === 'pending' && row.updatedAt < timestamp - 60000);
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;
}
const initial = due(); if (!initial) continue;
}
return { currentUser, prefs, date, times };
}
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 => {
// 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();
@@ -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;
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.
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; }
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.

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

@@ -1,6 +1,6 @@
import { expect, test } from 'bun:test';
import { fixture } from '../habits/test-fixture';
import { toResponsiveCalendarProps } from './calendar';
import { toHeatmapDays, toResponsiveCalendarProps } from './calendar';
test('Nivo adapter preserves per-date colors, neutral/future states and readable exact counts', async () => {
const f = fixture();
try {
@@ -19,5 +19,41 @@ test('Nivo adapter preserves per-date colors, neutral/future states and readable
const c = await f.json('/charts', 'POST', { name: 'One', habitIds: [h.id] }, 201);
const combined = await f.json(`/charts/${c.id}/calendar?from=2026-09-04&to=2026-09-04`);
expect(toResponsiveCalendarProps(combined).valueFormat(0)).toBe('0 of 1 habits complete');
expect(toHeatmapDays(combined)[0]).toMatchObject({ value: 0, target: 1, unit: 'habits complete' });
} finally { f.close(); }
});
test('future calendars preserve scheduled days and days off for individual and combined charts', async () => {
const f = fixture();
try {
for (const method of ['manual', 'count', 'tasks']) {
const schedule = { type: 'weekdays', days: [1, 3, 5] };
const h = await f.json('/habits', 'POST', {
name: method, method, ...(method === 'count' ? { target: 8 } : {}),
...(method === 'tasks' ? { tasks: [{ name: 'Exercise', schedule }] } : { schedule }),
}, 201);
const c = await f.json('/charts', 'POST', { name: method, habitIds: [h.id] }, 201);
for (const path of [`/habits/${h.id}`, `/charts/${c.id}`]) {
const payload = await f.json(`${path}/calendar?from=2026-09-04&to=2026-09-11`);
const props = toResponsiveCalendarProps(payload);
const heatmap = toHeatmapDays(payload);
expect(heatmap.map(day => day.state)).toEqual([
'due', 'not-due', 'not-due', 'future', 'not-due', 'future', 'not-due', 'future',
]);
for (let index = 1; index < payload.days.length; index++) {
const day = payload.days[index];
expect(day.future).toBe(true);
expect(heatmap[index]!.future).toBe(true);
expect(day.status).toBe(day.due ? 'future' : 'not_due');
expect(props.colorScale(index)).toBe(day.due ? payload.settings.futureColor : payload.settings.notDueColor);
expect(props.valueFormat(index)).toBe(day.due ? 'Upcoming' : 'Nothing scheduled');
expect(payload.data[index].value).toBe(day.due ? -2 : -1);
}
}
const off = await f.json(`/habits/${h.id}/days/2026-09-05`);
expect(off.future).toBe(true);
expect(off.status).toBe('not_due');
expect((await f.request(`/habits/${h.id}/days/2026-09-05/progress`, 'PUT', method === 'count' ? { count: 1 } : { done: true })).status).toBe(409);
}
} finally { f.close(); }
});

View File

@@ -1,10 +1,24 @@
import { ResponsiveCalendar } from '@nivo/calendar';
import type { ResponsiveCalendar } from '@nivo/calendar';
import type { ComponentProps } from 'react';
import type { CalendarDay } from '../components/design-system/calendar-model';
import type { HabitService } from '../habits/service';
export type CalendarResponse = ReturnType<HabitService['calendar']>;
export type ResponsiveCalendarProps = ComponentProps<typeof ResponsiveCalendar>;
/** Preserve timing independently so upcoming days off do not move the selected date. */
export function toHeatmapDays(calendar: CalendarResponse): CalendarDay[] {
return calendar.days.map(day => ({
date: day.date,
color: day.color,
unit: calendar.kind === 'combined' ? 'habits complete' : day.habits[0]?.unit,
value: calendar.kind === 'combined' ? day.completed : day.habits[0]?.value ?? 0,
target: calendar.kind === 'combined' ? day.due : day.habits[0]?.target ?? 0,
future: day.future,
state: !day.due ? 'not-due' : day.future ? 'future' : 'due',
}));
}
/** Data adapter only: no frontend component or page is rendered here. */
export function toResponsiveCalendarProps(calendar: CalendarResponse) {
// Nivo calls colorScale with a value, not a date. Give each date a stable
@@ -21,8 +35,8 @@ export function toResponsiveCalendarProps(calendar: CalendarResponse) {
valueFormat: (index: number) => {
const day = calendar.days[index];
if (!day) return '';
if (day.future) return 'Upcoming';
if (day.ratio === null) return 'Nothing scheduled';
if (day.future) return 'Upcoming';
// Counts for combined charts are always equal-weight completed habits.
if (calendar.kind === 'combined') return `${day.completed} of ${day.due} habits complete`;
const habit = day.habits[0]!;

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 { eq } from "drizzle-orm";
import type { AppDatabase } from "../auth";
import { publicUser, type AppDatabase } from "../auth";
import { users } from "../db/schema";
import { HabitService, ApiError } from "../habits/service";
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();
if (!user) throw new ApiError(404, "Account unavailable.");
const service = new HabitService(db, { ...user, displayName: user.globalName ?? user.username, avatarUrl: null }, now);
service.sync();
const service = prepared ?? new HabitService(db, { ...user, displayName: user.globalName ?? user.username, avatarUrl: null }, now);
if (!prepared) service.sync();
const from = addDays(service.today, -29);
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;
@@ -47,18 +54,15 @@ export function liveCardLayout(count: number) {
}
/** 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 canvas = createCanvas(layout.width, layout.height);
const ctx = canvas.getContext('2d');
const colors = themePalette[theme];
let avatar: Awaited<ReturnType<typeof loadImage>> | null = 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 avatar = data.avatarUrl ? await cachedAvatar(data.avatarUrl) : null;
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();
};
@@ -132,5 +136,6 @@ export async function renderLiveCard(data: LiveImageData, theme: Theme) {
rect(64, 1010, 1792, 1, colors.rule);
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);
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 { eq } from "drizzle-orm";
import { Hono } from "hono";
@@ -7,7 +8,7 @@ import type { AppDatabase, AuthEnv, createAuth } from "../auth";
import { liveDiscordCards, users } from "../db/schema";
import { ApiError } from "../habits/service";
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 { DiscordSharingConfig } from "./config";
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();
if (delay > 0) { schedule(id, delay); return; }
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.
const hash = createHash('sha256').update(JSON.stringify({ data, theme: current.theme, renderer: 3, content: liveMessageText })).digest('hex');
if (current.messageId && current.imageHash === hash) {
@@ -81,7 +82,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
guildId = channel.guild_id;
} 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; }
// A stop or account deletion during image generation cancels the outbound edit.
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);
const existing = running.get(id);
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.' }); }
}).finally(() => running.delete(id));
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('/preview', async c => {
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"');
return c.body(new Uint8Array(image));
});

View File

@@ -78,7 +78,7 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
app.post("/preview", async c => {
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.");
const result: ShareData = { from: input.from, to: input.to, timezone: service.user.timezone, habits: input.habitIds.map(id => {
const habit = service.current(id);
@@ -89,6 +89,8 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
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 })) };
}) };
return result;
});
result.botAvatarUrl = await botAvatarUrl();
return c.json(result);
});

View File

@@ -1,34 +1,34 @@
@font-face {
font-family: "Mina Sans";
src: url("../src/assets/fonts/NotoSans-Regular.ttf") format("truetype");
src: url("/fonts/browser/NotoSans-Regular.woff2") format("woff2");
font-style: normal;
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "Mina Sans";
src: url("../src/assets/fonts/NotoSans-Medium.ttf") format("truetype");
src: url("/fonts/browser/NotoSans-Medium.woff2") format("woff2");
font-style: normal;
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: "Mina Sans";
src: url("../src/assets/fonts/NotoSans-SemiBold.ttf") format("truetype");
src: url("/fonts/browser/NotoSans-SemiBold.woff2") format("woff2");
font-style: normal;
font-weight: 600;
font-display: swap;
}
@font-face {
font-family: "Instrument Serif";
src: url("../src/assets/fonts/InstrumentSerif-Regular.ttf") format("truetype");
src: url("/fonts/browser/InstrumentSerif-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Instrument Serif";
src: url("../src/assets/fonts/InstrumentSerif-Italic.ttf") format("truetype");
src: url("/fonts/browser/InstrumentSerif-Italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
font-display: swap;
@@ -52,6 +52,8 @@
position: relative;
margin: -8px;
min-height: 100vh;
min-height: 100dvh;
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
color: var(--ds-ink);
background: var(--ds-paper);
@apply type-body;
@@ -302,6 +304,13 @@
padding: 42px 0 56px;
border-top: 1px solid var(--ds-rule);
}
.ds-landing-intro > p {
margin-top: 16px;
}
.ds-landing-intro > .ds-actions {
margin-top: 24px;
padding-bottom: 0;
}
.ds-section-top {
display: flex;
justify-content: space-between;
@@ -770,6 +779,7 @@
border-top: 1px solid var(--ds-rule);
padding: 28px 0 40px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 20px;
@@ -1758,3 +1768,43 @@
.ds-inline-item-fields:not(.ds-habit-form-grid) { display: grid; gap: 12px; }
.ds-inline-item-fields:not(.ds-habit-form-grid) .ds-field { margin: 0; }
.ds-inline-item-fields:not(.ds-habit-form-grid) .ds-schedule-editor { display: grid; gap: 12px; }
/* Combined calendars live alongside habits; their color identifies the group. */
.ds-overview .ds-overview-title { color: var(--habit-color); margin: 0 0 8px; }
.ds-overview > .ds-calendar { min-width: 0; }
.ds-overview-details { display: grid; gap: 24px; }
.ds-overview-summary { display: flex; align-items: center; gap: 24px; padding: 24px; background: var(--ds-surface); }
.ds-overview-summary .ds-supporting-copy { margin-top: 8px; }
.ds-overview-ring { position: relative; flex: 0 0 112px; width: 112px; height: 112px; display: grid; place-items: center; color: var(--habit-color); }
.ds-overview-ring svg { position: absolute; inset: 0; width: 100%; height: 100%; fill: none; stroke-width: 8; }
.ds-overview-ring-track { stroke: var(--ds-rule); }
.ds-overview-ring-value { stroke: currentColor; }
.ds-overview-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.ds-overview-habit { display: flex; flex-direction: column; gap: 16px; padding: 20px; border: 1px solid var(--ds-rule); min-width: 0; }
.ds-overview-habit header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; }
.ds-overview-habit h3 { overflow-wrap: anywhere; }
.ds-overview-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ds-secondary); @apply type-small; }
.ds-overview-status svg { flex-shrink: 0; }
.ds-overview-meter { display: grid; gap: 12px; }
.ds-overview-meter p { color: var(--ds-secondary); }
.ds-overview-meter strong { color: var(--habit-color); font-variant-numeric: tabular-nums; }
.ds-overview-meter progress { appearance: none; width: 100%; height: 8px; border: 0; background: var(--ds-empty); color: var(--habit-color); accent-color: var(--habit-color); }
.ds-overview-meter progress::-webkit-progress-bar { background: var(--ds-empty); }
.ds-overview-meter progress::-webkit-progress-value { background: var(--habit-color); }
.ds-overview-meter progress::-moz-progress-bar { background: var(--habit-color); }
.ds-overview-check { display: flex; align-items: center; gap: 12px; padding-block: 8px; color: var(--ds-secondary); }
.ds-overview-check.is-complete, .ds-overview-tasks .is-complete { color: var(--habit-color); }
.ds-overview-tasks { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
.ds-overview-tasks li { display: flex; align-items: baseline; gap: 10px; overflow-wrap: anywhere; }
.ds-overview-tasks li > span:first-child { flex-shrink: 0; align-self: flex-start; padding-top: 3px; }
.ds-overview-habit > .ds-button { margin-top: auto; align-self: flex-start; }
@media (max-width: 640px) {
.ds-overview-detail-grid { grid-template-columns: minmax(0, 1fr); }
.ds-overview-summary { padding: 16px; gap: 16px; flex-wrap: wrap; }
.ds-overview-ring { flex-basis: 88px; width: 88px; height: 88px; }
}
@media (forced-colors: active) {
.ds-overview-ring-track { stroke: GrayText; }
.ds-overview-ring-value { stroke: Highlight; }
.ds-overview-meter progress { appearance: auto; }
}