Compare commits

...

9 Commits

Author SHA1 Message Date
syntaxbullet
5c7ac488b5 Extend native Discord command screens and same-message navigation 2026-10-26 20:16:52 +01:00
syntaxbullet
17b3714861 Fix Discord layout test assumption about habit ordering 2026-10-26 20:04:42 +01:00
syntaxbullet
1a97036112 Build interactive Discord today dashboard and check-ins 2026-10-26 19:56:12 +01:00
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
71 changed files with 2042 additions and 402 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 });
@@ -36,3 +37,5 @@ 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=="],

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.

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

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

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

@@ -255,7 +255,7 @@ describe("homepage", () => {
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();
@@ -278,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();
@@ -298,7 +298,7 @@ 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")!;

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

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

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

@@ -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

@@ -2,7 +2,7 @@ import { useCalendarCache } from "./CalendarCache";
import { Modal } from "./design-system/Modal";
import { Pencil } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type { CalendarResponse } from "../shared/calendar";
import { toHeatmapDays, type CalendarResponse } from "../shared/calendar";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
import { monthWindow, type MonthView } from "./design-system/calendar-model";
@@ -86,19 +86,7 @@ export function HabitHistory({
return () => { active = false; };
}, [cache, habit.habitId, date, revision, attempt, range.from, range.to]);
const days = useMemo(() =>
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),
})) ?? [], [calendar]);
const days = useMemo(() => calendar ? toHeatmapDays(calendar) : [], [calendar]);
const steps = Math.max(
1,
calendar?.days.find((day) => day.date === date)?.shadeCount ??

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

@@ -52,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.

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

@@ -36,8 +36,8 @@ no signed interaction endpoint configured. Global command registration and the
Discord interaction endpoint must be configured separately before production use.
## Preferences
`preferences.ts` implements global `/settings` and `/reminders`. Reminder menus
paginate ten named schedules at a time; entries with matching name and schedule
`preferences.ts` implements global `/settings` and `/reminders`. Reminder panels
paginate six named schedules at a time; entries with matching name and schedule
are edited together as multiple comma-separated 24-hour times. Forms accept
weekday names (or `every day`) or one ISO calendar date. Delivery is toggled for
all schedules. Removing the last schedule disables delivery and stores an empty
@@ -60,14 +60,15 @@ the website and reminder worker. Direct route fixtures default it to false.
No new environment variables, migrations, outbound requests, or remote command
registration are needed by this module. Global command registration remains an
integration step. Successful mutations invoke the supplied live-card dirty hook.
`progress.ts` implements `/progress`: six-habit text pages, focused private
`progress.ts` implements `/progress`: six-habit component pages, focused private
previews, explicit destination confirmation for snapshots, and live-image
start/status/pause/resume. API composition injects the same `sharing.send` and
`live.start/pause/state` operations used by website routes. Keep these shared
instances when reconciling dispatch; do not construct a second live worker.
Rendering and network actions return ephemeral type-5 acknowledgements and edit
the original interaction response once. `DeferredDispatch.complete` and
Progress rendering and network actions acknowledge existing message interactions
with type 6 and edit the original message once; a flow without a source message
uses type 5 (ephemeral in servers). `DeferredDispatch.complete` and
`schedule` are injectable for tests. Interaction tokens live only in memory,
never receipts or controls. A process interruption after acknowledgement can
leave the private response unfinished; restarting never replays the public send.
@@ -116,12 +117,11 @@ showing the selected date's requirements, task completion, carryover where
applicable, and a 14-day progress strip ending on that date. `/reminders` includes
**View schedule card**, showing each time slot, weekday/date schedule, quiet-hour
conflicts, and delivery status. Schedule cards retain disabled/unavailable states.
Management forms and `/help` remain text-based because their controls already
communicate the relevant choices.
Management uses native component panels and forms. `/help` remains text-based.
These are private snapshots: **Refresh card**, **Toggle theme**, and pagination
request a fresh owned snapshot. Regular check-ins keep their existing immediate
text responses. Graphic actions acknowledge before rendering and attach a PNG to
request a fresh owned snapshot. Regular check-ins use immediate
component updates. Graphic actions acknowledge before rendering and attach a PNG to
a new private reply; they never publish to the sharing channel or write progress.
Each image has a text equivalent and attachment description. A render failure
retains the text and navigation. No new command registration or configuration is
@@ -129,3 +129,55 @@ needed after deploying this change.
Generate local visual review artifacts with
`COMMAND_IMAGE_REVIEW_DIR=/tmp/minabot-command-review bun test src/discord/command-graphics.test.ts`.
## Interactive daily dashboard
`/today` uses Discord Components V2 containers with existing habit accent colors,
a daily completion summary, and unfinished habits first. Five habits fit on each
page within Discord's 40-component limit. Manual habits have inline completion
and undo actions; counts have inline +1; routines open a checklist with individual
Complete/Undo buttons. Details retain exact amounts, carryover, and amount forms.
Checklists paginate eight tasks at a time.
Navigation, saves, and recoverable daily validation errors update the originating
message (including amount-modal submissions). Updates clear legacy content,
embeds, and attachments when upgrading an older message to V2. Server command
replies remain ephemeral; reminder buttons open a separate private flow. Optional
PNG cards still open as separate snapshots. Historical editors reuse the same component check-in screens while retaining
their dated requirements.
The shared HabitService, owner-bound controls, stale snapshot checks, replay
receipts, and local-midnight protections remain authoritative. No schema change,
new API, or command registration is required. Native Discord mobile/desktop layout
and real modal return behavior should be checked manually after deployment.
## Component command screens
`/habits`, `/history`, `/reminders`, and `/progress` share the native `screen`,
`panel`, and `section` builders in `ui.ts`. Six entries per page leave room for
navigation and actions within Discord's 40-component limit. Habit panels use the
existing saved habit colors; general screens use the same Mina accent as /today.
Each entry has an inline Manage, Open history, Manage reminder, or Preview action.
Settings and task-management screens also use native panels so navigation from
these commands never attempts to downgrade a V2 message.
History opens the selected date directly, with Previous/Next date and Change date
controls. Its checklists paginate six tasks to leave room for date navigation.
Manual/count/task saves still use the shared daily handlers and dated requirements.
Normal navigation, forms, confirmations, and recoverable errors edit the source
message. Only validated owner controls can update it; expired or cross-account
controls receive a separate private error. Existing dropdown controls remain
supported until they expire. No new command registration or migration is needed.
Progress previews include a V2 media gallery referencing the uploaded attachment.
The deferred PATCH preserves the V2 flag, clears legacy content/embeds, and replaces
or removes prior attachments. Theme changes, destination confirmation, live status,
and public-send results all stay in the private source message. Public sends still
require explicit destination confirmation and retain the shared quotas and replay
guards. Optional day/check-in/schedule PNG cards remain separate private snapshots.
`components.test.ts` checks layout limits and same-message form/navigation flows,
plus deferred attachment transport. `progress.test.ts` covers preview/theme/back
navigation, failures, and the existing sharing protections. Live client appearance
still requires a Discord desktop/mobile acceptance check.

View File

@@ -8,7 +8,7 @@ import type { Context } from './types';
import type { DeferredMessage } from './progress';
import { commandCard } from './command-graphics';
import { renderCommandCard, commandCardSize } from './command-image';
import { discordFixture, control, submit } from './test-fixture';
import { discordFixture, messageText, control, submit } from './test-fixture';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
function setup() {
@@ -29,17 +29,17 @@ for (const guild of [false, true]) test(`day graphics retain private context and
const payload = f.payload(click(control(t, 'View day card')), guild);
expect(await (await f.requestDiscord(payload)).json()).toEqual({ type: 5, data: guild ? { flags: 64 } : {} });
await f.requestDiscord(payload); expect(e.jobs).toHaveLength(1);
let card = await e.drain(); expect(card.data.content).toContain('0 of 1 habits complete');
let card = await e.drain(); expect(messageText(card)).toContain('0 of 1 habits complete');
expect(e.outputs[0]!.image!.slice(0, 4)).toEqual(new Uint8Array([137,80,78,71]));
expect(e.outputs[0]!.imageDescription).toContain('Today, at a glance');
expect(f.db.select().from(progressEvents).all()).toEqual([]);
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 12 });
await f.run(click(control(card, 'Refresh card')), guild); card = await e.drain();
expect(card.data.content).toContain('12/10 pages'); expect(card.data.content).toContain('1 of 1');
expect(messageText(card)).toContain('12/10 pages'); expect(messageText(card)).toContain('1 of 1');
t = await f.run({ data: { name: 'today' } }, guild);
const d = await f.run(click(control(t, 'Choose a habit'), [h.id]), guild);
const d = await f.run(click(control(t, 'Details'), [h.id]), guild);
await f.run(click(control(d, 'View check-in card')), guild); card = await e.drain();
expect(card.data.content).toContain('12/10 pages');
expect(messageText(card)).toContain('12/10 pages');
expect(f.changed).toHaveLength(0);
});
test('graphics retain ownership, expiry, pagination and selected historical requirements', async () => {
@@ -48,12 +48,13 @@ test('graphics retain ownership, expiry, pagination and selected historical requ
f.setDiscordTime('2026-09-05T12:00:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { name: 'New target', target: 10 });
const history = await f.run({ data: { name: 'history' } }, true);
const form = await f.run(click(control(history, 'Choose a habit'), [h.id]), true);
const editor = await f.run(click(control(history, 'Open history')), true);
const form = await f.run(click(control(editor, 'Change date')), true);
const day = await f.run(submit(form.data.custom_id, { date: '2026-09-04' }), true);
const id = control(day, 'View check-in card');
expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another');
await f.run(click(id), true); const card = await e.drain();
expect(card.data.content).toContain('Old target'); expect(card.data.content).toContain('3/4');
expect(messageText(card)).toContain('Old target'); expect(messageText(card)).toContain('3/4');
f.setDiscordTime('2026-09-06T12:00:00Z');
expect((await f.run(click(id), true)).data.content).toContain('expired');
});
@@ -63,14 +64,14 @@ test('six-row cards paginate complete habit and reminder collections', async ()
const lastName = (await f.json('/habits')).habits.at(-1).name;
const today = await f.run({ data: { name: 'today' } });
await f.run(click(control(today, 'View day card'))); let card = await e.drain();
expect(card.data.content).toContain('Page 1/2'); expect(card.data.content).not.toContain(lastName);
await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(card.data.content).toContain(lastName);
expect(messageText(card)).toContain('Page 1/2'); expect(messageText(card)).not.toContain(lastName);
await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(messageText(card)).toContain(lastName);
f.db.insert(reminderSettings).values({ userId: 'alice', enabled: false, quietStart: '22:00', quietEnd: '08:00', updatedAt: 1,
reminders: Array.from({ length: 8 }, (_, i) => ({ type: 'weekly' as const, days: [1, 3, 5], name: `Reminder ${i}`, time: `${String(i + 1).padStart(2, '0')}:00` })) }).run();
const panel = await f.run({ data: { name: 'reminders' } });
await f.run(click(control(panel, 'View schedule card'))); card = await e.drain();
expect(card.data.content).toContain('During quiet hours'); expect(card.data.content).toContain('Mon, Wed, Fri');
await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(card.data.content).toContain('Reminder 7');
expect(messageText(card)).toContain('During quiet hours'); expect(messageText(card)).toContain('Mon, Wed, Fri');
await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(messageText(card)).toContain('Reminder 7');
});
test('rendered cards include task progress, future/off states, weekly/date reminders and bounded image dimensions', async () => {
setup();

View File

@@ -0,0 +1,126 @@
import { afterEach, expect, test } from 'bun:test';
import { components, control, discordFixture, findHabitPage, habitControl, messageText, submit } from './test-fixture';
import { completeDeferred } from './routes';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
const click = (reply: any, label: string) => ({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label) } });
function native(reply: any, type = 7) {
expect(reply.type).toBe(type);
expect(reply.data.flags & 32768).toBe(32768);
expect(reply.data.content ?? null).toBeNull();
expect(reply.data.embeds ?? []).toEqual([]);
const all = components(reply.data.components);
expect(all.length).toBeLessThanOrEqual(40);
expect(all.filter(c => c.custom_id).map(c => c.custom_id).length).toBe(new Set(all.filter(c => c.custom_id).map(c => c.custom_id)).size);
expect(all.filter(c => c.type === 10).map(c => c.content).join('').length).toBeLessThanOrEqual(4000);
for (const c of all) {
if (c.type === 1) expect(c.components.length).toBeLessThanOrEqual(5);
if (c.type === 9) {
expect(c.components.every((child: any) => child.type === 10)).toBe(true);
expect(c.accessory.type).toBe(2);
}
}
}
for (const guild of [false, true]) test(`command lists use bounded native layouts and update paginated messages (${guild ? 'server' : 'DM'})`, async () => {
f = discordFixture(true);
for (let i = 0; i < 14; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual', color: '#123456' }, 201);
await f.json('/reminders', 'PUT', { enabled: false, reminders: Array.from({ length: 20 }, (_, i) => ({ name: `Reminder ${i}`, type: 'weekly', days: [1, 3, 5], time: '12:00' })), quietStart: '22:00', quietEnd: '08:00' });
for (const command of ['habits', 'history', 'progress', 'reminders']) {
let list = await f.run({ data: { name: command } }, guild);
native(list, 4);
expect(list.data.flags & 64).toBe(guild ? 64 : 0);
const titles = new Set<string>();
for (let page = 0; page < 4; page++) {
native(list, page ? 7 : 4);
for (const section of components(list.data.components).filter(c => c.type === 9)) titles.add(section.components[0].content.split('\n')[0]);
if (!components(list.data.components).some(c => c.label === 'Next')) break;
list = await f.run(click(list, 'Next'), guild);
}
expect(titles.size).toBe(command === 'reminders' ? 20 : 14);
native(await f.run(click(list, 'Previous'), guild));
if (command !== 'reminders') expect(list.data.components.filter((c: any) => c.type === 17).slice(1).every((c: any) => c.accent_color === 0x123456)).toBe(true);
}
});
test('habit forms, confirmations, stale errors and recovery retain the original message', async () => {
f = discordFixture();
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual', color: '#abcdef' }, 201);
let list = await f.run({ data: { name: 'habits' } }, true);
let detail = await f.run(click(list, 'Manage'), true);
native(detail);
expect(detail.data.components[0].accent_color).toBe(0xabcdef);
let form = await f.run(click(detail, 'Edit details'), true);
detail = await f.run(submit(form.data.custom_id, { name: 'Books', color: '#123456' }), true);
native(detail);
expect(messageText(detail)).toContain('Books');
const confirmation = await f.run(click(detail, 'Archive habit'), true);
native(confirmation);
detail = await f.run(click(confirmation, 'Confirm'), true);
native(detail);
list = await f.run(click(detail, 'Back to habits'), true);
expect(messageText(list)).toContain('Archived habits');
detail = await f.run(click(list, 'Manage'), true);
form = await f.run(click(detail, 'Edit details'), true);
await f.json(`/habits/${h.id}`, 'PATCH', { name: 'Website edit' });
const failed = await f.run(submit(form.data.custom_id, { name: 'Old edit', color: '#123456' }), true);
native(failed);
native(await f.run(click(failed, 'Back to habits'), true));
});
test('history checklists retain date controls, safe component limits and same-message saves', async () => {
f = discordFixture();
const h = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: Array.from({ length: 20 }, (_, i) => ({ name: `Task ${i}` })) }, 201);
const list = await f.run({ data: { name: 'history' } });
let detail = await f.run(click(list, 'Open history'));
native(detail);
detail = await f.run(click(detail, 'Next'));
native(detail);
expect(messageText(detail)).toContain('Page 2 of 4');
detail = await f.run(click(detail, 'Complete'));
native(detail);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(1);
const form = await f.run(click(detail, 'Change date'));
detail = await f.run(submit(form.data.custom_id, { date: '2026-09-05' }));
native(detail);
expect(messageText(detail)).toContain('Future dates are read-only');
expect(components(detail.data.components).some(c => c.label === 'Complete')).toBe(false);
});
test('reminder edits, validation retries and removal stay in the original message', async () => {
f = discordFixture(true);
let list = await f.run({ data: { name: 'reminders' } }, true);
let form = await f.run(click(list, 'Add reminder'), true);
const draft = { name: 'Evening', times: '99:00', days: 'every day', date: '' };
const error = await f.run(submit(form.data.custom_id, draft), true);
native(error);
form = await f.run(click(error, 'Try again'), true);
list = await f.run(submit(form.data.custom_id, { ...draft, times: '18:00' }), true);
native(list);
const page = await findHabitPage(f, list, 'Evening', true);
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: habitControl(page, 'Evening', 'Manage reminder') } }, true);
native(detail);
const confirmation = await f.run(click(detail, 'Remove reminder'), true);
native(confirmation);
native(await f.run(click(confirmation, 'Confirm removal'), true));
});
test('deferred native previews PATCH the original with V2 flags, gallery and attachment; text screens clear old media', async () => {
f = discordFixture();
const interaction = f.payload({ type: 3, message: { id: '123' } }, true);
const requests: { url: string; payload: any; image: FormDataEntryValue | null }[] = [];
const request = async (url: string, init: RequestInit) => {
expect(init.method).toBe('PATCH');
const body = init.body as FormData;
requests.push({ url, payload: JSON.parse(String(body.get('payload_json'))), image: body.get('files[0]') });
return Response.json({});
};
const gallery = { type: 12, items: [{ media: { url: 'attachment://mina-progress.png' } }] };
await completeDeferred(interaction, { message: { flags: 32768 | 64, components: [gallery] }, image: new Uint8Array([1, 2, 3]), imageDescription: 'Progress preview' }, request);
expect(requests[0]!.url).toEndWith('/messages/@original');
expect(requests[0]!.payload).toMatchObject({ flags: 32768, content: null, embeds: [], components: [gallery], attachments: [{ id: 0, filename: 'mina-progress.png', description: 'Progress preview' }] });
expect(requests[0]!.image).toBeInstanceOf(Blob);
await completeDeferred(interaction, { message: { flags: 32768, components: [{ type: 10, content: 'Back to progress' }] } }, request);
expect(requests[1]!.payload.attachments).toEqual([]);
expect(requests[1]!.image).toBeNull();
});

View File

@@ -1,17 +1,23 @@
import { afterEach, expect, test } from 'bun:test';
import { eq } from 'drizzle-orm';
import { discordFixture, control, submit } from './test-fixture';
import { discordFixture, components, messageText, habitControl, control, submit } from './test-fixture';
import { discordControls, reminderSettings } from '../db/schema';
import { defaultReminder } from '../reminders/contracts';
import { sendDueReminders } from '../reminders/worker';
import { reminderCheckIn } from './daily';
import { ControlStore } from './state';
function habitTaskControl(reply: any, name: string): string {
const panel = reply.data.components[0];
const section = panel.components.find((item: any) => item.type === 9 && item.components[0].content.endsWith(name));
return section.accessory.custom_id;
}
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
const click = (id: string, values?: string[]) => ({ type: 3, message: { id: '123' }, data: { custom_id: id, ...(values ? { values } : {}) } });
async function open(habitId: string, guild = false) {
const t = await f.run({ data: { name: 'today' } }, guild);
return f.run(click(control(t, 'Choose a habit'), [habitId]), guild);
const h = await f.json(`/habits/${habitId}`);
return f.run(click(habitControl(t, h.name)), guild);
}
for (const guild of [false, true]) {
test(`daily count carry, above-target, reversal and recoverable modal saves (${guild ? 'server' : 'DM'})`, async () => {
@@ -19,18 +25,18 @@ for (const guild of [false, true]) {
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'count', target: 10, carryPartialProgress: true }, 201);
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 });
f.setDiscordTime('2026-09-05T12:00:00Z');
let d = await open(h.id, guild); expect(d.data.content).toContain('Carried from 2026-09-04');
let d = await open(h.id, guild); expect(messageText(d)).toContain('Carried from 2026-09-04');
d = await f.run(click(control(d, '+1')), guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(8);
let m = await f.run(click(control(d, 'Enter amount')), guild);
const invalid = await f.run(submit(m.data.custom_id, { amount: '-1' }), guild);
expect(invalid.data.content).toContain('whole number'); expect(invalid.data.flags).toBe(guild ? 64 : undefined);
expect(messageText(invalid)).toContain('whole number'); expect(invalid.data.flags).toBe(32768);
m = await f.run(click(control(invalid, 'Try again')), guild);
d = await f.run(submit(m.data.custom_id, { amount: '12' }), guild);
expect(d.data.content).toContain('12/10');
expect(messageText(d)).toContain('12/10');
m = await f.run(click(control(d, 'Enter amount')), guild);
d = await f.run(submit(m.data.custom_id, { amount: '0' }), guild);
expect(d.data.content).toContain('Incomplete');
expect(messageText(d)).toContain('Not started');
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(0);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(7);
expect(f.changed).toHaveLength(3);
@@ -46,22 +52,22 @@ for (const guild of [false, true]) {
const h = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: Array.from({ length: 11 }, (_, n) => ({ name: `Task ${n + 1}` })) }, 201);
d = await open(h.id, guild);
d = await f.run(click(control(d, 'Next')), guild);
expect(d.data.content).toContain('Task 11'); expect(d.data.content).not.toContain('Task 10');
expect(messageText(d)).toContain('Task 11'); expect(messageText(d)).not.toContain('Task 8');
const day = await f.json(`/habits/${h.id}/days/2026-09-04`);
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
d = await f.run(click(habitTaskControl(d, 'Task 11'), [day.tasks[10].taskId]), guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(1);
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
d = await f.run(click(habitTaskControl(d, 'Task 11'), [day.tasks[10].taskId]), guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
// Website task changes invalidate an already-open Discord selection.
for (const task of day.tasks) await f.json(`/habits/${h.id}/days/2026-09-04/tasks/${task.taskId}`, 'PUT', { done: true });
const stale = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
expect(stale.data.content).toContain('Progress changed');
const stale = await f.run(click(habitTaskControl(d, 'Task 11'), [day.tasks[10].taskId]), guild);
expect(messageText(stale)).toContain('Progress changed');
d = await f.run(click(control(stale, 'Refresh')), guild);
expect(d.data.content).toContain('11/11 tasks · Complete');
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
expect(messageText(d)).toContain('11/11 tasks');
d = await f.run(click(habitTaskControl(d, 'Task 11'), [day.tasks[10].taskId]), guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).complete).toBe(false);
await f.json(`/habits/${manual.id}/days/2026-09-04/progress`, 'PUT', { done: true });
expect((await open(manual.id, guild)).data.content).toContain('Complete');
expect(messageText(await open(manual.id, guild))).toContain('Complete');
});
}
test('habit pagination clamps after website archives', async () => {
@@ -71,7 +77,7 @@ test('habit pagination clamps after website archives', async () => {
const next = control(t, 'Next');
for (const h of habits.slice(1)) await f.json(`/habits/${h.id}`, 'PATCH', { archived: true });
const refreshed = await f.run(click(next));
expect(refreshed.data.content).toContain('Habit 0');
expect(messageText(refreshed)).toContain('Habit 0');
});
test('daily controls reject midnight rollover even within their fifteen minute lifetime', async () => {
f = discordFixture(); f.setDiscordTime('2026-09-04T21:59:00Z');
@@ -79,9 +85,9 @@ test('daily controls reject midnight rollover even within their fifteen minute l
const d = await open(h.id);
f.setDiscordTime('2026-09-04T22:00:00Z');
const expired = await f.run(click(control(d, 'Mark complete')));
expect(expired.data.content).toContain('day changed');
expect(messageText(expired)).toContain('day changed');
const fresh = await f.run(click(control(expired, 'Today')));
expect(fresh.data.content).toContain('2026-09-05');
expect(messageText(fresh)).toContain('2026-09-05');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
});
test('reminder buttons are owner bound, open a new flow, expire at local midnight, and preserve deduplication', async () => {
@@ -98,8 +104,8 @@ test('reminder buttons are owner bound, open a new flow, expire at local midnigh
expect(f.db.select().from(discordControls).where(eq(discordControls.id, id.slice(5))).get()?.expiresAt).toBe(Date.parse('2026-09-04T22:00:00Z'));
expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another user');
for (const guild of [false, true]) {
const flow = await f.run(click(id), guild); expect(flow.type).toBe(4); expect(flow.data.flags).toBe(guild ? 64 : undefined);
expect(flow.data.content).toContain('Walk');
const flow = await f.run(click(id), guild); expect(flow.type).toBe(4); expect(flow.data.flags).toBe(32768 | (guild ? 64 : 0));
expect(messageText(flow)).toContain('Walk');
}
f.setDiscordTime('2026-09-04T22:00:00Z'); expect((await f.run(click(id))).data.content).toContain('expired');
});
@@ -110,3 +116,58 @@ test('reminder expiry follows DST boundary', () => {
const id = control({ data: { components } }, 'Check in');
expect(new ControlStore(f.db, 'alice', now).read(id).expiresAt).toBe(Date.parse('2026-10-25T23:00:00Z'));
});
for (const guild of [false, true]) test(`inline dashboard actions update the same message and finish the day (${guild ? 'server' : 'DM'})`, async () => {
f = discordFixture();
const walk = await f.json('/habits', 'POST', { name: 'Walk', method: 'manual' }, 201);
const water = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 1 }, 201);
let t = await f.run({ data: { name: 'today' } }, guild);
expect(t.data.content).toBeUndefined();
expect(t.data.flags).toBe(32768 | (guild ? 64 : 0));
const old = habitControl(t, 'Water', '+1');
t = await f.run(click(habitControl(t, 'Walk', 'Mark complete')), guild);
expect(t.type).toBe(7);
expect(t.data.content).toBeNull();
expect(messageText(t)).toContain('1/2 complete');
expect(messageText(t).indexOf('**Water**')).toBeLessThan(messageText(t).indexOf('**Walk**'));
t = await f.run(click(habitControl(t, 'Water', '+1')), guild);
expect(t.type).toBe(7);
expect(messageText(t)).toContain('All done for today');
expect((await f.json(`/habits/${water.id}/days/2026-09-04`)).value).toBe(1);
const stale = await f.run(click(old), guild);
expect(stale.type).toBe(7);
expect(messageText(stale)).toContain('Progress changed');
t = await f.run(click(habitControl(t, 'Walk', 'Mark incomplete')), guild);
expect(messageText(t)).not.toContain('All done for today');
expect((await f.json(`/habits/${walk.id}/days/2026-09-04`)).complete).toBe(false);
});
test('native layouts stay within Discord limits and use existing habit colors', async () => {
f = discordFixture();
for (let n = 0; n < 12; n++) await f.json('/habits', 'POST', { name: `Habit ${n}`, method: 'manual' }, 201);
let t = await f.run({ data: { name: 'today' } });
for (let page = 0; page < 3; page++) {
expect(components(t.data.components).length).toBeLessThanOrEqual(40);
expect(t.data.components.filter((c: any) => c.type === 17).slice(1).every((c: any) => c.accent_color === 0x196127)).toBe(true);
expect(messageText(t)).toContain(`Page ${page + 1} of 3`);
if (page < 2) t = await f.run(click(control(t, 'Next')));
}
await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: Array.from({ length: 20 }, (_, n) => ({ name: `Step ${n}` })) }, 201);
const d = await f.run(click(control(t, 'Details')));
expect(components(d.data.components).length).toBeLessThanOrEqual(40);
// Equal creation timestamps are ordered by random IDs, not insertion order.
let list = await f.run({ data: { name: 'today' } });
while (!messageText(list).includes('**Routine**') && components(list.data.components).some(c => c.label === 'Next')) {
list = await f.run(click(control(list, 'Next')));
}
expect(messageText(list)).toContain('**Routine**');
const checklist = await f.run(click(habitControl(list, 'Routine', 'Open checklist')));
expect(components(checklist.data.components).length).toBeLessThanOrEqual(40);
expect(messageText(checklist)).toContain('Page 1 of 3');
});
test('empty dashboard has no mutation controls', async () => {
f = discordFixture();
const t = await f.run({ data: { name: 'today' } });
expect(messageText(t)).toContain('Nothing scheduled today');
expect(messageText(t)).not.toContain('All done');
expect(components(t.data.components).filter(c => c.type === 2).map(c => c.label)).toEqual(['Refresh', 'View day card']);
});

View File

@@ -6,23 +6,60 @@ import type { AppDatabase } from '../auth';
import { progressInput } from '../habits/contracts';
import type { Context, ControlData, Reply } from './types';
import { fields } from './types';
import { button, message, modal, pages, row, select, text } from './ui';
import { button, message, modal, pages, row, select, text, componentMessage, display, panel, section } from './ui';
const pageFor = (page: number | undefined, total: number) => Math.min(Math.max(0, Math.trunc(page ?? 0)), Math.max(0, Math.ceil(total / 10) - 1));
const snapshot = (d: ReturnType<Context['service']['day']>) => JSON.stringify([d.value, d.revisionId, d.loggedCount, d.carriedFrom, d.tasks.map(t => [t.taskId, t.done])]);
export function reminderCheckIn(db: AppDatabase, userId: string, timezone: string, now: number) {
const date = localDate(now, timezone);
return [row({ type: 2, style: 1, label: 'Check in', custom_id: new ControlStore(db, userId, now).create('today', { date, mode: 'reminder' }, endOfDay(date, timezone) - now) })];
}
const dashboardSize = 5;
const taskSize = 8;
const clampPage = (page: number | undefined, total: number, size: number) => Math.min(Math.max(0, Math.trunc(page ?? 0)), Math.max(0, Math.ceil(total / size) - 1));
const status = (d: ReturnType<Context['service']['day']>) => d.complete ? '✓ Complete' : d.value > 0 ? '◐ In progress' : '○ Not started';
const meter = (ratio: number) => '▰'.repeat(Math.round(Math.min(1, Math.max(0, ratio)) * 8)) + '▱'.repeat(8 - Math.round(Math.min(1, Math.max(0, ratio)) * 8));
export function today(c: Context, data: ControlData = {}): Reply {
if (data.date && data.date !== c.service.today) throw new ApiError(409, 'That reminder is from an earlier day. Run /today for a fresh check-in.');
const all = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due);
const page = pageFor(data.page, all.length), visible = all.slice(page * 10, page * 10 + 10);
return message(c, `**Today · ${c.service.today}** · ${text(c.service.user.timezone)}\n${all.filter(h => h.complete).length}/${all.length} complete\n\n${visible.map(h => `${h.complete ? '✓' : '○'} ${text(h.name ?? 'Habit')} — ${h.value}/${h.target} ${text(h.unit ?? '')}`).join('\n') || 'No habits due. Use /habits to create or manage your habits.'}`,
[...(visible.length ? [select(c, 'Choose a habit', 'daily.select', { date: c.service.today, today: true }, visible.map(h => ({ id: h.habitId, name: h.name ?? 'Habit' })))] : []), ...pages(c, 'today', { date: c.service.today, page }, all.length), row(button(c, 'Refresh', 'today'), button(c, 'View day card', 'graphic.today', { date: c.service.today }))], c.interaction.data?.custom_id?.startsWith('mina:') && data.mode !== 'reminder');
const all = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due).sort((a, b) => Number(a.complete) - Number(b.complete));
const page = clampPage(data.page, all.length, dashboardSize), complete = all.filter(d => d.complete).length;
const components = [panel([display(`## ${all.length && complete === all.length ? '✓ All done for today' : 'Today'}\n${c.service.today} · ${text(c.service.user.timezone)}\n${all.length ? `${meter(complete / all.length)} **${complete}/${all.length} complete**` : 'Nothing scheduled today. Enjoy your day off.'}`)])];
for (const d of all.slice(page * dashboardSize, (page + 1) * dashboardSize)) {
const state: ControlData = { habitId: d.habitId, date: c.service.today, today: true, dashboardPage: page, expected: snapshot(d), mode: 'dashboard' };
const primary = d.method === 'manual'
? button(c, d.complete ? 'Mark incomplete' : 'Mark complete', 'daily.save', { ...state, done: !d.complete }, d.complete ? 2 : 3)
: d.method === 'count' && d.value < 1_000_000_000
? button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1)
: button(c, d.method === 'tasks' ? 'Open checklist' : 'Edit amount', 'daily.detail', { ...state, mode: undefined }, 1);
components.push(panel([
section(`**${text(d.name ?? 'Habit')}**\n${status(d)} · ${d.value}/${d.target} ${text(d.unit ?? '')}`, primary),
row(button(c, 'Details', 'daily.detail', { ...state, mode: undefined })),
], c.service.settings(d.habitId).mainColor));
}
components.push(display(all.length ? `Page ${page + 1} of ${Math.max(1, Math.ceil(all.length / dashboardSize))} · Unfinished habits first` : 'Use /habits to create or manage your habits.'));
components.push(...pages(c, 'today', { date: c.service.today, page }, all.length, dashboardSize), row(button(c, 'Refresh', 'today', { page }), button(c, 'View day card', 'graphic.today', { date: c.service.today })));
return componentMessage(c, components, data.mode !== 'reminder');
}
function checkInDetail(c: Context, data: ControlData): Reply {
const d = c.service.day(data.habitId!, data.date!);
const size = data.mode === 'history' ? 6 : taskSize;
const page = clampPage(data.page, d.tasks.length, size);
const state = { ...data, mode: data.mode === 'history' ? 'history' : undefined, page, expected: snapshot(d) };
const components = [display(`## ${text(d.name ?? 'Habit')}\n${data.date} · ${text(d.timezone)}\n${d.due ? `${meter(d.ratio ?? 0)} **${d.value}/${d.target} ${text(d.unit ?? '')}** · ${status(d)}` : 'Not scheduled on this date'}${d.carriedFrom ? `\nCarried from ${d.carriedFrom}` : ''}${d.future ? '\nFuture dates are read-only' : ''}`)];
if (d.due && !d.future) {
if (d.method === 'manual') components.push(row(button(c, d.complete ? 'Mark incomplete' : 'Mark complete', 'daily.save', { ...state, done: !d.complete }, d.complete ? 2 : 3)));
if (d.method === 'count') components.push(row(...(d.value > 0 ? [button(c, '−1', 'daily.save', { ...state, value: d.value - 1 })] : []), ...(d.value < 1_000_000_000 ? [button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1)] : []), button(c, 'Enter amount', 'daily.amount', state)));
if (d.method === 'tasks') {
for (const task of d.tasks.slice(page * size, (page + 1) * size)) components.push(section(`${task.done ? '✓' : '○'} ${text(task.name)}`, button(c, task.done ? 'Undo' : 'Complete', 'daily.task', { ...state, taskId: task.taskId }, task.done ? 2 : 3)));
if (d.tasks.length > size) components.push(display(`Page ${page + 1} of ${Math.ceil(d.tasks.length / size)}`));
components.push(...pages(c, 'daily.detail', state, d.tasks.length, size));
}
}
return componentMessage(c, [panel(components, c.service.settings(d.habitId).mainColor), row(button(c, 'Today', 'today', { page: data.dashboardPage }), button(c, 'Refresh', 'daily.detail', state), button(c, 'View check-in card', 'graphic.day', { ...state, page: 0 }))]);
}
export function detail(c: Context, data: ControlData): Reply {
const date = data.date ?? c.service.today;
if (data.today && date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today to check in.');
if (data.today || data.mode === 'history') return checkInDetail(c, { ...data, date });
const d = c.service.day(data.habitId!, date);
data = { ...data, date, page: pageFor(data.page, d.tasks.length) };
const state: ControlData = { ...data, expected: snapshot(d) };
@@ -50,14 +87,14 @@ function action(c: Context, action: string, data: ControlData): Reply | undefine
const d = c.service.day(data.habitId!, data.date!);
if (data.expected !== snapshot(d)) throw new ApiError(409, 'Progress changed since this message was opened. Refresh it before saving.');
if (action === 'daily.task') {
const task = d.tasks.find(t => t.taskId === c.interaction.data?.values?.[0]);
const task = d.tasks.find(t => t.taskId === (data.taskId ?? c.interaction.data?.values?.[0]));
if (!task) throw new ApiError(404, 'Task not found.');
c.service.completeTask(data.habitId!, data.date!, task.taskId, !task.done);
} else {
const amount = fields(c.interaction).amount;
c.service.progress(data.habitId!, data.date!, progressInput.parse(data.done !== undefined ? { done: data.done } : { count: amount === undefined ? data.value : /^\d+$/.test(amount) ? Number(amount) : NaN }));
}
c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return detail(c, data);
c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return data.mode === 'dashboard' ? today(c, { page: data.dashboardPage }) : detail(c, data);
}
}
@@ -67,6 +104,7 @@ export function dailyAction(c: Context, name: string, data: ControlData): Reply
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;
const invalid = error instanceof ZodError;
const recover = invalid ? button(c, 'Try again', 'daily.amount', data) : button(c, 'Refresh', data.habitId ? 'daily.detail' : 'today', data);
if (data.today || data.mode === 'history') return componentMessage(c, [panel([display(invalid ? 'Enter a whole number from 0 to 1,000,000,000. Nothing was saved.' : error.message)]), row(recover, button(c, 'Today', 'today'))]);
return message(c, invalid ? 'Enter a whole number from 0 to 1,000,000,000. This is the total for the date, including any carried progress. Nothing was saved.' : error.message,
[row(recover, button(c, 'Today', 'today'))], false);
}

View File

@@ -10,7 +10,7 @@ export function help(guild: boolean, origin: string): Reply {
'',
...commands.map(command => `**/${command.name}** — ${command.description}`),
'',
'Start with /today, then choose a habit and use its buttons to record progress. Use the forms for amounts and other details.',
'Start with /today and use the buttons beside each habit. Open Details to adjust an amount or undo a check-in; Open checklist lets you complete individual tasks.',
'If a control expires or progress changes elsewhere, run the command again to get fresh controls.',
'',
'Personal command replies in servers are visible only to you. Sharing progress requires an explicit sharing action.',

View File

@@ -1,16 +1,18 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { control, discordFixture, submit } from './test-fixture';
import { control, discordFixture, submit, messageText, components, habitControl, findHabitPage, taskControl } from './test-fixture';
import { commands } from './commands';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
const items = (reply: any): any[] => reply.data.components.flatMap((r: any) => r.components ?? []);
const items = (reply: any): any[] => components(reply.data.components);
async function click(reply: any, label: string, guild = false, values?: string[], user?: string) {
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), ...(values ? { values } : {}) } }, guild, user);
}
async function open(habitId: string, date: string, guild = false) {
const list = await f.run({ data: { name: 'history' } }, guild);
const form = await click(list, 'Choose a habit', guild, [habitId]);
const h = await f.json(`/habits/${habitId}`);
const list = await findHabitPage(f, await f.run({ data: { name: 'history' } }, guild), h.name, guild);
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: habitControl(list, h.name, 'Open history') } }, guild);
const form = await click(detail, 'Change date', guild);
expect(form.type).toBe(9);
return f.run(submit(form.data.custom_id, { date }), guild);
}
@@ -19,7 +21,7 @@ async function changeDate(reply: any, date: string, guild = false) {
return f.run(submit(form.data.custom_id, { date }), guild);
}
function readOnly(reply: any) {
expect(reply.data.content).toContain('Read-only');
expect(messageText(reply)).toContain('Read-only');
expect(items(reply).some(i => ['+1', 'Enter amount', 'Mark complete', 'Mark incomplete'].includes(i.label) || i.type === 3)).toBe(false);
}
@@ -44,33 +46,33 @@ describe('/history', () => {
await f.json(`/habits/${h.id}`, 'PATCH', { archived: true });
const list = await f.run({ data: { name: 'history' } }, guild);
expect(list.data.flags).toBe(guild ? 64 : undefined);
expect(items(list)[0].options[0].description).toContain('Archived');
expect(list.data.flags).toBe(32768 | (guild ? 64 : 0));
expect(messageText(list)).toContain('Archived');
let d = await open(h.id, '2026-09-04', guild);
expect(d.data.content).toContain('0/5 pages');
expect(messageText(d)).toContain('0/5 pages');
const amount = await click(d, 'Enter amount', guild);
d = await f.run(submit(amount.data.custom_id, { amount: '4' }), guild);
expect(d.data.content).toContain('4/5 pages');
expect(messageText(d)).toContain('4/5 pages');
expect(control(d, 'Change date')).toBeTruthy();
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(4);
d = await click(d, 'Next date', guild);
expect(d.data.content).toContain('0/20 chapters');
expect(messageText(d)).toContain('0/20 chapters');
d = await click(d, 'Next date', guild);
expect(d.data.content).toContain('Checkbox');
expect(messageText(d)).toContain('Checkbox');
d = await click(d, 'Mark complete', guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-06`)).complete).toBe(true);
d = await click(d, 'Mark incomplete', guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-06`)).complete).toBe(false);
d = await click(d, 'Next date', guild);
expect(d.data.content).toContain('Original task');
expect(d.data.content).not.toContain('New task');
d = await click(d, 'Choose a task to change its completion', guild, [task.id]);
expect(messageText(d)).toContain('Original task');
expect(messageText(d)).not.toContain('New task');
d = await click(d, 'Complete', guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-07`)).tasks[0].done).toBe(true);
d = await click(d, 'Next date', guild);
readOnly(d);
d = await click(d, 'Next date', guild);
readOnly(d);
expect(d.data.content).toContain('archived on this date');
expect(messageText(d)).toContain('archived on this date');
expect(f.changed).toEqual(['alice', 'alice', 'alice', 'alice']);
expect((await f.json(`/habits/${h.id}`)).archived).toBe(true);
});
@@ -79,21 +81,21 @@ describe('/history', () => {
f = discordFixture();
const h = await f.json('/habits', 'POST', { name: 'Friday', method: 'manual', schedule: { type: 'weekdays', days: [5] } }, 201);
let d = await open(h.id, '2026-02-30');
expect(d.data.content).toContain('real calendar date');
expect(messageText(d)).toContain('real calendar date');
for (const date of ['2025-02-29', '09/04/2026', '1969-12-31', '9999-01-01']) {
d = await changeDate(d, date);
expect(d.data.content).toContain('real calendar date');
expect(messageText(d)).toContain('real calendar date');
}
d = await changeDate(d, '2024-02-29');
readOnly(d);
expect(d.data.content).toContain('not tracked yet');
expect(messageText(d)).toContain('not tracked yet');
d = await changeDate(d, '2026-09-05');
expect(d.data.content).toContain('Future dates are read-only');
expect(messageText(d)).toContain('Future dates are read-only');
expect(items(d).some(i => i.label === 'Mark complete')).toBe(false);
f.setDiscordTime('2026-09-06T12:00:00Z');
d = await open(h.id, '2026-09-05');
readOnly(d);
expect(d.data.content).toContain('Friday');
expect(messageText(d)).toContain('Friday');
d = await changeDate(d, '1970-01-01');
expect(items(d).some(i => i.label === 'Previous date')).toBe(false);
d = await changeDate(d, '9998-12-31');
@@ -106,19 +108,18 @@ describe('/history', () => {
for (let i = 0; i < 12; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201);
const h = await f.json('/habits', 'POST', { name: 'Checklist', method: 'tasks', tasks: Array.from({ length: 23 }, (_, i) => ({ name: `Task ${i}` })) }, 201);
let list = await f.run({ data: { name: 'history' } });
expect(items(list)[0].options).toHaveLength(10);
expect(items(list).filter(c => c.label === 'Open history')).toHaveLength(6);
list = await click(list, 'Next');
expect(items(list)[0].options).toHaveLength(3);
expect((await click(list, 'Previous')).data.content).toContain('Page 1 of 2');
const form = await click(list, 'Choose a habit', false, [h.id]);
let d = await f.run(submit(form.data.custom_id, { date: '2026-09-04' }));
expect(items(list).filter(c => c.label === 'Open history')).toHaveLength(6);
expect(messageText((await click(list, 'Previous')))).toContain('Page 1 of 3');
let d = await open(h.id, '2026-09-04');
d = await click(d, 'Next');
expect(items(d)[0].options[0].label).toBe('○ Task 10');
expect(d.data.components.length).toBeLessThanOrEqual(5);
d = await click(d, 'Choose a task to change its completion', false, [h.tasks[10].id]);
expect(d.data.content).toContain('✓ Task 10');
expect(messageText(d)).toContain('○ Task 6');
expect(items(d).length).toBeLessThanOrEqual(40);
d = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: taskControl(d, 'Task 10') } });
expect(messageText(d)).toContain('✓ Task 10');
d = await click(d, 'Refresh');
expect(d.data.content).toContain('✓ Task 10');
expect(messageText(d)).toContain('✓ Task 10');
expect(control(d, 'Change date')).toBeTruthy();
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).tasks[10].done).toBe(true);
});
@@ -128,14 +129,15 @@ describe('/history', () => {
const h = await f.json('/habits', 'POST', { name: 'Secret', method: 'manual' }, 201);
await f.json('/habits', 'POST', { name: 'Bobs habit', method: 'manual' }, 201, 'b');
const a = await f.run({ data: { name: 'history' } }, guild);
const rejected = await click(a, 'Choose a habit', guild, [h.id], f.bob);
expect(rejected.data.content).toContain('belongs to another user');
const rejected = await click(a, 'Open history', guild, [h.id], f.bob);
expect(messageText(rejected)).toContain('belongs to another user');
expect(rejected.data.flags).toBe(guild ? 64 : undefined);
const b = await f.run({ data: { name: 'history' } }, guild, f.bob);
expect(JSON.stringify(b)).not.toContain('Secret');
expect((await click(b, 'Choose a habit', guild, [h.id], f.bob)).data.content).toBe('Not found');
// Inline controls bind the habit ID; injected select values cannot redirect them.
expect(messageText(await click(b, 'Open history', guild, [h.id], f.bob))).toContain('Bobs habit');
const d = await open(h.id, '2026-09-04', guild);
expect((await click(d, 'Mark complete', guild, undefined, f.bob)).data.content).toContain('belongs to another user');
expect(messageText((await click(d, 'Mark complete', guild, undefined, f.bob)))).toContain('belongs to another user');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
expect(f.changed).toEqual([]);
});
@@ -147,20 +149,20 @@ describe('/history', () => {
let d = await open(h.id, '2026-09-04');
const oldAmount = await click(d, 'Enter amount');
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 8 });
expect((await f.run(submit(oldAmount.data.custom_id, { amount: '2' }))).data.content).toContain('Progress changed');
expect(messageText((await f.run(submit(oldAmount.data.custom_id, { amount: '2' }))))).toContain('Progress changed');
d = await click(d, 'Refresh');
expect(d.data.content).toContain('8/10');
expect(messageText(d)).toContain('8/10');
const interaction = f.payload({ type: 3, message: { id: '123' }, data: { custom_id: control(d, '+1') } });
const saved = await (await f.requestDiscord(interaction)).json();
f.restart();
expect(await (await f.requestDiscord(interaction)).json()).toEqual(saved);
expect((await click(d, '+1')).data.content).toContain('expired');
expect(messageText((await click(d, '+1')))).toContain('expired');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(9);
expect((await f.json(`/habits/${h.id}/days/2026-09-04/audit`)).records[0].events).toHaveLength(2);
expect(f.changed).toEqual(['alice']);
d = await open(h.id, '2026-09-04');
f.setDiscordTime('2026-09-05T12:16:00Z');
expect((await click(d, '+1')).data.content).toContain('expired');
expect(messageText((await click(d, '+1')))).toContain('expired');
});
test('uses account-local dates at midnight and retains historical record timezone', async () => {
@@ -168,31 +170,31 @@ describe('/history', () => {
const h = await f.json('/habits', 'POST', { name: 'Local day', method: 'manual' }, 201);
f.setDiscordTime('2026-09-04T22:05:00Z');
const list = await f.run({ data: { name: 'history' } });
const form = await click(list, 'Choose a habit', false, [h.id]);
const form = await click(await click(list, 'Open history'), 'Change date');
expect(form.data.components[0].components[0].value).toBe('2026-09-05');
await f.json('/me', 'PATCH', { timezone: 'America/New_York' });
const d = await open(h.id, '2026-09-04');
expect(d.data.content).toContain('Europe/Belgrade');
expect(messageText(d)).toContain('Europe/Belgrade');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).timezone).toBe('Europe/Belgrade');
});
test('keeps earlier schedule requirements editable after a schedule change', async () => {
f = discordFixture();
const empty = await f.run({ data: { name: 'history' } });
expect(empty.data.content).toContain('No habits yet');
expect(items(empty)).toEqual([]);
expect(messageText(empty)).toContain('No habits yet');
expect(items(empty).filter(c => c.type === 2)).toEqual([]);
const h = await f.json('/habits', 'POST', { name: 'Practice', method: 'manual' }, 201);
f.setDiscordTime('2026-09-05T12:00:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
let d = await open(h.id, '2026-09-04');
expect(d.data.content).toContain('Every day');
expect(messageText(d)).toContain('Every day');
d = await click(d, 'Mark complete');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).complete).toBe(true);
d = await click(d, 'Next date');
readOnly(d);
expect(d.data.content).toContain('Monday');
expect(messageText(d)).toContain('Monday');
d = await changeDate(d, '2026-09-07');
expect(d.data.content).toContain('Future dates are read-only');
expect(messageText(d)).toContain('Future dates are read-only');
expect(items(d).some(i => i.label === 'Mark complete')).toBe(false);
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).due).toBe(false);
});

View File

@@ -2,7 +2,7 @@ import { addDays } from '../habits/calendar';
import { dateSchema, type Schedule } from '../habits/contracts';
import { dailyAction, detail } from './daily';
import { fields, type Context, type ControlData, type Message, type Reply } from './types';
import { button, message, modal, pages, row, select, text } from './ui';
import { button, screen, modal, pages, row, text, componentMessage, display, panel, section, listSize, listPage, pageLabel } from './ui';
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
function scheduleLabel(schedule: Schedule): string {
@@ -14,11 +14,15 @@ function scheduleLabel(schedule: Schedule): string {
export function history(c: Context, data: ControlData = {}): Reply {
const all = c.service.list(true);
const page = Math.max(0, Math.min(data.page ?? 0, Math.ceil(all.length / 10) - 1));
const state: ControlData = { date: data.date ?? c.service.today, page, mode: 'history', today: false };
const visible = all.slice(page * 10, page * 10 + 10);
return message(c, `**History** · ${text(c.service.user.timezone)}\nChoose a habit, then enter a calendar date. Archived habits are included.${all.length ? `\nPage ${page + 1} of ${Math.ceil(all.length / 10)}` : '\nNo habits yet. Use /habits to create one.'}`,
[...(visible.length ? [select(c, 'Choose a habit', 'history.select', state, visible.map(h => ({ id: h.id, name: h.name, description: `${h.archived ? 'Archived · ' : ''}Tracked since ${h.createdDate}` })))] : []), ...pages(c, 'history', state, all.length)]);
const page = listPage(data.page, all.length);
const state: ControlData = { date: data.date ?? c.service.today, mode: 'history', today: false };
return componentMessage(c, [
panel([display(`## History\n${text(c.service.user.timezone)} · ${pageLabel(page, all.length)}\n${all.length ? 'Open a habit to inspect or correct a date. Archived habits are included.' : 'No habits yet. Use /habits to create one.'}`)]),
...all.slice(page * listSize, (page + 1) * listSize).map(h => panel([
section(`**${text(h.name)}**\n${h.archived ? 'Archived · ' : ''}Tracked since ${h.createdDate}`, button(c, 'Open history', 'history.detail', { ...state, habitId: h.id })),
], c.service.settings(h.id).mainColor)),
...pages(c, 'history', { ...state, page }, all.length, listSize),
]);
}
function dateForm(c: Context, data: ControlData): Reply {
@@ -43,7 +47,13 @@ function withNavigation(c: Context, data: ControlData, reply: Reply): Reply {
: !d.due ? 'Read-only · Day off; nothing is scheduled for this date.' : '';
const body = reply.data as Message;
const requirements = d.requirements ? `${d.method === 'manual' ? 'Checkbox' : d.method === 'count' ? 'Count' : 'Tasks'} · ${scheduleLabel(d.requirements.schedule)}\n` : '';
return { ...reply, data: { ...body, content: `${requirements}${text(d.timezone)}\n${explanation ? explanation + '\n' : ''}${body.content ?? ''}`.slice(0, 2000), components: [...(body.components ?? []), row(...navigation)] } };
const [card, ...rest] = body.components ?? [];
if (card?.type === 17) {
const children = card.components as import('./types').DiscordComponent[];
const metadata = `${requirements}${explanation}`.trim();
return { ...reply, data: { ...body, components: [{ ...card, components: [children[0]!, ...(metadata ? [display(metadata)] : []), ...children.slice(1)] }, ...rest, row(...navigation)] } };
}
return reply;
}
export function historyAction(c: Context, action: string, data: ControlData): Reply | undefined {
@@ -52,7 +62,7 @@ export function historyAction(c: Context, action: string, data: ControlData): Re
if (action === 'history.form') return dateForm(c, data);
if (action === 'history.date') {
const parsed = dateSchema.safeParse(fields(c.interaction).date?.trim());
if (!parsed.success) return message(c, 'Enter a real calendar date as YYYY-MM-DD, from 1970 through 9998.', [row(button(c, 'Change date', 'history.form', data))]);
if (!parsed.success) return screen(c, 'Enter a real calendar date as YYYY-MM-DD, from 1970 through 9998.', [row(button(c, 'Change date', 'history.form', data))]);
data = { ...data, date: parsed.data, page: 0 };
action = 'history.detail';
}

View File

@@ -1,5 +1,5 @@
import { afterEach, expect, test } from 'bun:test';
import { discordFixture, control, submit } from './test-fixture';
import { discordFixture, control, submit, messageText, components, habitControl } from './test-fixture';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
async function click(reply: any, label: string, guild = false, values?: string[]) {
@@ -7,7 +7,7 @@ async function click(reply: any, label: string, guild = false, values?: string[]
}
async function list(id: string, guild = false) {
const home = await f.run({ data: { name: 'tasks' } }, guild);
expect(home.data.flags).toBe(guild ? 64 : undefined);
expect(home.data.flags).toBe(32768 | (guild ? 64 : 0));
return click(home, 'Choose a task habit', guild, [id]);
}
async function task(id: string, taskId: string, guild = false) { return click(await list(id, guild), 'Choose a task', guild, [taskId]); }
@@ -30,7 +30,7 @@ for (const guild of [false, true]) test(`task add, rename, schedule and delete m
detail = await f.run(submit(form.data.custom_id, { every: '2', anchor: '2026-09-04', weekday: 'Friday' }), guild);
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'weekly', every: 2, anchor: '2026-09-04', weekday: 5 });
const confirm = await click(detail, 'Delete task', guild);
expect(confirm.data.content).toContain('completion total may change');
expect(messageText(confirm)).toContain('completion total may change');
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks).toHaveLength(1);
await click(confirm, 'Confirm task deletion', guild);
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks).toHaveLength(0);
@@ -46,12 +46,12 @@ test('task scheduling supports daily, selected weekdays, day intervals and overl
expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'interval', every: 3, anchor: '2026-09-01' });
let menu = await click(await click(detail, 'Change task schedule'), 'Recurrence', false, ['weekdays']);
detail = await click(menu, 'Select weekdays', false, ['2']);
expect(detail.data.content).toContain('never coincides');
expect(messageText(detail)).toContain('never coincides');
menu = await click(detail, 'Change task schedule');
detail = await click(menu, 'Recurrence', false, ['daily']);
expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'daily' });
form = await click(detail, 'Rename task');
expect((await f.run(submit(form.data.custom_id, { name: ' ' }))).data.content).toContain('Check the entered');
expect(messageText((await f.run(submit(form.data.custom_id, { name: ' ' }))))).toContain('Check the entered');
expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].name).toBe('Plan');
});
test('task changes preserve prior dates; stale controls and cross-owner task IDs are rejected', async () => {
@@ -59,18 +59,18 @@ test('task changes preserve prior dates; stale controls and cross-owner task IDs
const id = h.tasks[0].id;
const rename = await click(await task(h.id, id), 'Rename task');
await f.json(`/habits/${h.id}/tasks/${id}`, 'PATCH', { name: 'Website rename' });
expect((await f.run(submit(rename.data.custom_id, { name: 'Stale' }))).data.content).toContain('habit changed');
expect(messageText((await f.run(submit(rename.data.custom_id, { name: 'Stale' }))))).toContain('habit changed');
f.setDiscordTime('2026-09-05T12:00:00Z');
await click(await click(await task(h.id, id), 'Delete task'), 'Confirm task deletion');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).tasks[0].name).toBe('Website rename');
expect((await f.json(`/habits/${h.id}/tasks`)).tasks).toHaveLength(0);
const bob = await f.json('/habits', 'POST', { name: 'Private', method: 'tasks', tasks: [{ name: 'Secret' }] }, 201, 'b');
const add = await click(await list(h.id), 'Add task');
expect((await f.run(submit(add.data.custom_id, { name: 'Attack' }), true, f.bob)).data.content).toContain('belongs to another');
expect(messageText((await f.run(submit(add.data.custom_id, { name: 'Attack' }), true, f.bob)))).toContain('belongs to another');
const bobMenu = await f.run({ data: { name: 'tasks' } }, false, f.bob);
expect((await click(bobMenu, 'Choose a task habit', false, [bob.id])).data.content).toContain('belongs to another');
expect(messageText((await click(bobMenu, 'Choose a task habit', false, [bob.id])))).toContain('belongs to another');
const aliceMenu = await f.run({ data: { name: 'tasks' } });
expect((await click(aliceMenu, 'Choose a task habit', false, [bob.id])).data.content).toContain('available options');
expect(messageText((await click(aliceMenu, 'Choose a task habit', false, [bob.id])))).toContain('available options');
});
test('add forms are replay safe after restart and task pagination stays within limits', async () => {
f = discordFixture(); const h = await setup();
@@ -81,9 +81,9 @@ test('add forms are replay safe after restart and task pagination stays within l
expect(f.changed).toHaveLength(1);
for (let i = 0; i < 11; i++) await f.json(`/habits/${h.id}/tasks`, 'POST', { name: `Task ${i}` }, 201);
const first = await list(h.id);
expect(first.data.components[0].components[0].options).toHaveLength(10);
expect(components(first.data.components).find(c => c.type === 3).options).toHaveLength(10);
const next = await click(first, 'Next');
expect(next.data.components[0].components[0].options).toHaveLength(2);
expect(components(next.data.components).find(c => c.type === 3).options).toHaveLength(2);
expect(control(next, 'Previous')).toBeTruthy();
});
test('midnight, color-only website edits, and count progress invalidate management snapshots', async () => {
@@ -91,16 +91,16 @@ test('midnight, color-only website edits, and count progress invalidate manageme
const h = await setup();
let add = await click(await list(h.id), 'Add task');
f.setDiscordTime('2026-09-04T22:01:00Z');
expect((await f.run(submit(add.data.custom_id, { name: 'Wrong day' }))).data.content).toContain('habit changed');
expect(messageText((await f.run(submit(add.data.custom_id, { name: 'Wrong day' }))))).toContain('habit changed');
add = await click(await list(h.id), 'Add task');
await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#abcdef' });
expect((await f.run(submit(add.data.custom_id, { name: 'Old color' }))).data.content).toContain('habit changed');
expect(messageText((await f.run(submit(add.data.custom_id, { name: 'Old color' }))))).toContain('habit changed');
const c = await f.json('/habits', 'POST', { name: 'Count', method: 'count', target: 10 }, 201);
const home = await f.run({ data: { name: 'habits' } });
const detail = await click(home, 'Choose a habit to manage', false, [c.id]);
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: habitControl(home, 'Count', 'Manage') } });
const form = await click(detail, 'Edit details');
await f.json(`/habits/${c.id}/days/2026-09-05/progress`, 'PUT', { count: 3 });
expect((await f.run(submit(form.data.custom_id, { name: 'Lost progress', target: '20', unit: 'steps', color: '#123456' }))).data.content).toContain('habit changed');
expect(messageText((await f.run(submit(form.data.custom_id, { name: 'Lost progress', target: '20', unit: 'steps', color: '#123456' }))))).toContain('habit changed');
});
test('a complete checklist routine can be created entirely in Discord', async () => {
f = discordFixture();
@@ -111,7 +111,7 @@ test('a complete checklist routine can be created entirely in Discord', async ()
const tasks = await click(habit, 'Manage tasks', true);
const add = await click(tasks, 'Add task', true);
const task = await f.run(submit(add.data.custom_id, { name: 'Prepare tomorrow' }), true);
expect(task.data.content).toContain('Prepare tomorrow');
expect(messageText(task)).toContain('Prepare tomorrow');
const website = (await f.json('/habits')).habits[0];
expect(website.method).toBe('tasks'); expect(website.tasks[0].name).toBe('Prepare tomorrow');
const settings = await f.json(`/habits/${website.id}/calendar-settings`);
@@ -119,5 +119,5 @@ test('a complete checklist routine can be created entirely in Discord', async ()
const other = await f.json('/habits', 'POST', { name: 'Other', method: 'tasks', tasks: [{ name: 'Private task' }] }, 201, 'b');
const taskMenu = await list(website.id);
const blocked = await click(taskMenu, 'Choose a task', false, [other.tasks[0].id]);
expect(blocked.data.content).toContain('available options');
expect(messageText(blocked)).toContain('available options');
});

View File

@@ -1,18 +1,22 @@
import { afterEach, expect, test } from 'bun:test';
import { discordFixture, control, submit } from './test-fixture';
import { discordFixture, control, submit, messageText, components, habitControl, findHabitPage } from './test-fixture';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
async function click(reply: any, label: string, guild = false, values?: string[]) {
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), ...(values ? { values } : {}) } }, guild);
}
async function open(id: string, guild = false) { return click(await f.run({ data: { name: 'habits' } }, guild), 'Choose a habit to manage', guild, [id]); }
async function open(id: string, guild = false) {
const h = await f.json(`/habits/${id}`);
const list = await findHabitPage(f, await f.run({ data: { name: 'habits' } }, guild), h.name, guild);
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: habitControl(list, h.name, 'Manage') } }, guild);
}
async function create(method: string, guild = false) {
const list = await f.run({ data: { name: 'habits' } }, guild);
expect(list.data.flags).toBe(guild ? 64 : undefined);
expect(list.data.flags).toBe(32768 | (guild ? 64 : 0));
const methodMenu = await click(list, 'Create habit', guild);
const form = await click(methodMenu, 'Tracking method', guild, [method]);
const result = await f.run(submit(form.data.custom_id, { name: 'Read', color: '#123456', ...(method === 'count' ? { target: '10', unit: 'pages' } : {}) }), guild);
expect(result.data.content).toContain('Read');
expect(messageText(result)).toContain('Read');
return { result, habit: (await f.json('/habits')).habits[0] };
}
for (const guild of [false, true]) test(`habit lifecycle and shared website state in ${guild ? 'server' : 'DM'}`, async () => {
@@ -30,9 +34,9 @@ for (const guild of [false, true]) test(`habit lifecycle and shared website stat
expect((await f.json('/habits')).habits[0].id).toBe(habit.id);
detail = await click(await click(detail, 'Archive habit', guild), 'Confirm', guild);
const archived = await click(await f.run({ data: { name: 'habits' } }, guild), 'Browse archived', guild);
expect(control(archived, 'Choose a habit to manage')).toBeTruthy();
expect(control(archived, 'Manage')).toBeTruthy();
detail = await click(await click(detail, 'Permanently delete', guild), 'Confirm', guild);
expect(detail.data.content).toContain('No habits');
expect(messageText(detail)).toContain('No habits');
expect((await f.request(`/habits/${habit.id}/days/2026-09-04`)).status).toBe(404);
expect(f.changed.length).toBe(7);
});
@@ -54,7 +58,7 @@ test('all recurrences use validated friendly controls', async () => {
await click(menu, 'Recurrence', false, ['daily']);
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'daily' });
const invalid = await click(await click(await open(habit.id), 'Change schedule'), 'Recurrence', false, ['interval']);
expect((await f.run(submit(invalid.data.custom_id, { every: '0', anchor: '2026-02-30' }))).data.content).toContain('Check the entered');
expect(messageText((await f.run(submit(invalid.data.custom_id, { every: '0', anchor: '2026-02-30' }))))).toContain('Check the entered');
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'daily' });
});
test('method conversion warns, resets current progress and preserves past dated requirements', async () => {
@@ -63,7 +67,7 @@ test('method conversion warns, resets current progress and preserves past dated
f.setDiscordTime('2026-09-05T12:00:00Z');
const menu = await click(await open(habit.id), 'Change method');
const confirmation = await click(menu, 'New tracking method', false, ['count']);
expect(confirmation.data.content).toContain('resets today');
expect(messageText(confirmation)).toContain('resets today');
const form = await click(confirmation, 'Continue conversion');
await f.run(submit(form.data.custom_id, { name: 'Read', color: '#123456', target: '5', unit: 'pages' }));
const past = await f.json(`/habits/${habit.id}/days/2026-09-04`);
@@ -78,21 +82,23 @@ test('stale, replayed, and cross-account management controls cannot write', asyn
f = discordFixture(); const { result, habit } = await create('manual');
const form = await click(result, 'Edit details');
await f.json(`/habits/${habit.id}`, 'PATCH', { name: 'Website edit' });
expect((await f.run(submit(form.data.custom_id, { name: 'Stale', color: '#123456' }))).data.content).toContain('habit changed');
expect(messageText((await f.run(submit(form.data.custom_id, { name: 'Stale', color: '#123456' }))))).toContain('habit changed');
const fresh = await click(await open(habit.id), 'Edit details');
const payload = f.payload(submit(fresh.data.custom_id, { name: 'Final', color: '#123456' }));
await f.requestDiscord(payload); const changes = f.changed.length;
f.restart(); await f.requestDiscord(payload);
expect(f.changed.length).toBe(changes);
expect((await f.run(submit(fresh.data.custom_id, { name: 'Replay', color: '#123456' }))).data.content).toContain('expired');
expect((await f.run(submit(fresh.data.custom_id, { name: 'Attack', color: '#123456' }), true, f.bob)).data.content).toContain('belongs to another');
expect(messageText((await f.run(submit(fresh.data.custom_id, { name: 'Replay', color: '#123456' }))))).toContain('expired');
expect(messageText((await f.run(submit(fresh.data.custom_id, { name: 'Attack', color: '#123456' }), true, f.bob)))).toContain('belongs to another');
expect((await f.json('/habits')).habits[0].name).toBe('Final');
});
test('pagination keeps Discord component and option limits', async () => {
f = discordFixture(); for (let i = 0; i < 12; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201);
const page = await f.run({ data: { name: 'habits' } });
expect(page.data.components[0].components[0].options.length).toBe(10);
expect(components(page.data.components).filter(c => c.label === 'Manage')).toHaveLength(6);
expect(components(page.data.components).length).toBeLessThanOrEqual(40);
const next = await click(page, 'Next');
expect(next.data.components[0].components[0].options.length).toBe(2);
expect(components(next.data.components).filter(c => c.label === 'Manage')).toHaveLength(6);
expect(components(next.data.components).length).toBeLessThanOrEqual(40);
expect(control(next, 'Previous')).toBeTruthy();
});

View File

@@ -1,7 +1,7 @@
import { habitInput, habitPatch, taskInput, taskPatch, scheduleSchema, type Schedule } from '../habits/contracts';
import { ApiError } from '../habits/service';
import { fields, type Context, type ControlData, type Reply } from './types';
import { button, message, modal, pages, row, select, text } from './ui';
import { button, screen, modal, pages, row, select, text, componentMessage, display, panel, section, listSize, listPage, pageLabel } from './ui';
const prefix = 'manage:';
const methods = [{ id: 'manual', name: 'Manual check-in' }, { id: 'count', name: 'Count toward a target' }, { id: 'tasks', name: 'Checklist of tasks' }];
@@ -31,22 +31,25 @@ export function scheduleLabel(s: Schedule): string {
export function manageHabits(c: Context, d: ControlData = {}): Reply {
const archived = d.mode === 'archived';
const habits = c.service.list(true).filter(h => h.archived === archived);
const page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(habits.length / 10) - 1)));
const page = listPage(d.page, habits.length);
const state = { mode: archived ? 'archived' : 'active', page };
return message(c, `**${archived ? 'Archived' : 'Active'} habits** · Page ${page + 1}\n${habits.length ? 'Choose a habit to manage.' : 'No habits here yet.'}`, [
...(habits.length ? [select(c, 'Choose a habit to manage', prefix + 'open', state, habits.slice(page * 10, page * 10 + 10).map(h => ({ id: h.id, name: h.name, description: scheduleLabel(h.schedule) })))] : []),
row(button(c, 'Create habit', prefix + 'create'), button(c, archived ? 'Browse active' : 'Browse archived', prefix + 'list', { mode: archived ? 'active' : 'archived' })),
...pages(c, prefix + 'list', state, habits.length),
return componentMessage(c, [
panel([display(`## ${archived ? 'Archived' : 'Active'} habits\n${pageLabel(page, habits.length)} · ${habits.length} habits\n${habits.length ? 'Edit details, schedules, and tracking methods.' : 'No habits here yet.'}`)]),
...habits.slice(page * listSize, (page + 1) * listSize).map(h => panel([
section(`**${text(h.name)}**\n${scheduleLabel(h.schedule)} · ${h.method === 'count' ? `${h.target} ${text(h.unit)}` : h.method === 'tasks' ? 'Checklist' : 'Manual check-in'}`, button(c, 'Manage', prefix + 'detail', { habitId: h.id })),
], c.service.settings(h.id).mainColor)),
...pages(c, prefix + 'list', state, habits.length, listSize),
row(button(c, 'Create habit', prefix + 'create', {}, 1), button(c, archived ? 'Browse active' : 'Browse archived', prefix + 'list', { mode: archived ? 'active' : 'archived' })),
]);
}
function detail(c: Context, id: string): Reply {
const h = c.service.current(id), d = snapshot(c, id);
return message(c, `**${text(h.name)}**${h.archived ? ' · Archived' : ''}\n${h.method === 'count' ? `${h.target} ${text(h.unit)} · Carry partial progress: ${h.carryPartialProgress ? 'on' : 'off'}` : h.method === 'tasks' ? `${h.tasks.length} tasks` : 'Manual check-in'}\n${scheduleLabel(h.schedule)}\nColor: ${c.service.settings(id).mainColor}\nChanges apply from ${c.service.today} (${text(c.service.user.timezone)}); earlier requirements remain unchanged.`, [
return screen(c, `**${text(h.name)}**${h.archived ? ' · Archived' : ''}\n${h.method === 'count' ? `${h.target} ${text(h.unit)} · Carry partial progress: ${h.carryPartialProgress ? 'on' : 'off'}` : h.method === 'tasks' ? `${h.tasks.length} tasks` : 'Manual check-in'}\n${scheduleLabel(h.schedule)}\nColor: ${c.service.settings(id).mainColor}\nChanges apply from ${c.service.today} (${text(c.service.user.timezone)}); earlier requirements remain unchanged.`, [
row(button(c, 'Edit details', prefix + 'edit', d), button(c, 'Change schedule', prefix + 'schedule', d), button(c, 'Change method', prefix + 'method', d)),
...(h.method === 'tasks' && !h.archived ? [row(button(c, 'Manage tasks', prefix + 'tasks-list', d))] : []),
...(h.method === 'count' ? [row(button(c, h.carryPartialProgress ? 'Disable carryover' : 'Enable carryover', prefix + 'carry', d))] : []),
row(button(c, h.archived ? 'Restore habit' : 'Archive habit', prefix + 'confirm', { ...d, mode: h.archived ? 'restore' : 'archive' }), ...(h.archived ? [button(c, 'Permanently delete', prefix + 'confirm', { ...d, mode: 'delete' }, 4)] : []), button(c, 'Back to habits', prefix + 'list')),
]);
row(button(c, h.archived ? 'Restore habit' : 'Archive habit', prefix + 'confirm', { ...d, mode: h.archived ? 'restore' : 'archive' }), ...(h.archived ? [button(c, 'Permanently delete', prefix + 'confirm', { ...d, mode: 'delete' }, 4)] : []), button(c, 'Back to habits', prefix + 'list', { mode: h.archived ? 'archived' : 'active' })),
], true, c.service.settings(id).mainColor);
}
function detailsForm(c: Context, d: ControlData, creating = false): Reply {
const h = creating ? undefined : check(c, d), method = d.method ?? h!.method;
@@ -58,7 +61,7 @@ function detailsForm(c: Context, d: ControlData, creating = false): Reply {
}
function schedulePicker(c: Context, d: ControlData): Reply {
check(c, d);
return message(c, 'Choose a recurrence. Dates use your account timezone.', [select(c, 'Recurrence', prefix + 'schedule-kind', d, recurrence), row(button(c, 'Cancel', prefix + (d.taskId ? 'task-detail' : 'detail'), d))]);
return screen(c, 'Choose a recurrence. Dates use your account timezone.', [select(c, 'Recurrence', prefix + 'schedule-kind', d, recurrence), row(button(c, 'Cancel', prefix + (d.taskId ? 'task-detail' : 'detail'), d))]);
}
function saveSchedule(c: Context, d: ControlData, schedule: Schedule): Reply {
check(c, d);
@@ -74,7 +77,7 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
if (a === 'list') return manageHabits(c, d);
if (a === 'open') return detail(c, chosen(c, c.service.list(true).filter(h => h.archived === (d.mode === 'archived')).map(h => h.id)));
if (a === 'detail') return detail(c, d.habitId!);
if (a === 'create') return message(c, 'Choose how to track your habit. New habits start with a daily schedule; you can change it after creating.', [select(c, 'Tracking method', prefix + 'create-method', {}, methods)]);
if (a === 'create') return screen(c, 'Choose how to track your habit. New habits start with a daily schedule; you can change it after creating.', [select(c, 'Tracking method', prefix + 'create-method', {}, methods), row(button(c, 'Back to habits', prefix + 'list'))]);
if (a === 'create-method') return detailsForm(c, { method: chosen(c, methods.map(m => m.id)) }, true);
if (a === 'save-create') {
const f = fields(c.interaction);
@@ -88,10 +91,10 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
consume(c); return detail(c, d.habitId!);
}
if (a === 'carry') { const h = check(c, d); if (h.method !== 'count') throw new ApiError(409, 'This habit no longer uses counts.'); c.service.edit(h.id, { carryPartialProgress: !h.carryPartialProgress }); consume(c); return detail(c, h.id); }
if (a === 'method') { const h = check(c, d); return message(c, 'Choose a new tracking method.', [select(c, 'New tracking method', prefix + 'method-confirm', d, methods.filter(m => m.id !== h.method))]); }
if (a === 'method') { const h = check(c, d); return screen(c, 'Choose a new tracking method.', [select(c, 'New tracking method', prefix + 'method-confirm', d, methods.filter(m => m.id !== h.method)), row(button(c, 'Cancel', prefix + 'detail', d))]); }
if (a === 'method-confirm') {
const h = check(c, d), method = chosen(c, methods.filter(m => m.id !== h.method).map(m => m.id));
return message(c, 'Changing method resets today’s progress. Switching away from tasks removes the current checklist; switching to tasks starts an empty checklist. Earlier dated history stays available. Count carryover starts off when switching to count. Continue?', [row(button(c, 'Continue conversion', prefix + 'convert', { ...d, method }, 4), button(c, 'Cancel', prefix + 'detail', d))]);
return screen(c, 'Changing method resets today’s progress. Switching away from tasks removes the current checklist; switching to tasks starts an empty checklist. Earlier dated history stays available. Count carryover starts off when switching to count. Continue?', [row(button(c, 'Continue conversion', prefix + 'convert', { ...d, method }, 4), button(c, 'Cancel', prefix + 'detail', d))]);
}
if (a === 'convert') {
check(c, d);
@@ -100,7 +103,7 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
}
if (a === 'confirm') {
const h = check(c, d);
return message(c, d.mode === 'delete' ? `Permanently delete ${text(h.name)} and all its history? This cannot be undone and removes it from combined charts.` : `${d.mode === 'restore' ? 'Restore' : 'Archive'} ${text(h.name)} from today? Earlier history is preserved.`, [row(button(c, 'Confirm', prefix + 'apply-status', d, d.mode === 'delete' ? 4 : 1), button(c, 'Cancel', prefix + 'detail', d))]);
return screen(c, d.mode === 'delete' ? `Permanently delete ${text(h.name)} and all its history? This cannot be undone and removes it from combined charts.` : `${d.mode === 'restore' ? 'Restore' : 'Archive'} ${text(h.name)} from today? Earlier history is preserved.`, [row(button(c, 'Confirm', prefix + 'apply-status', d, d.mode === 'delete' ? 4 : 1), button(c, 'Cancel', prefix + 'detail', d))]);
}
if (a === 'apply-status') { check(c, d); if (d.mode === 'delete') c.service.permanentlyDelete(d.habitId!); else c.service.edit(d.habitId!, { archived: d.mode === 'archive' }); consume(c); return d.mode === 'delete' ? manageHabits(c, { mode: 'archived' }) : detail(c, d.habitId!); }
if (a === 'schedule') return schedulePicker(c, d);
@@ -110,7 +113,7 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
if (kind === 'weekdays') {
const menu = select(c, 'Select weekdays', prefix + 'save-weekdays', d, weekdays.map((name, i) => ({ id: String(i), name })));
Object.assign((menu.components as Record<string, unknown>[])[0]!, { min_values: 1, max_values: 7 });
return message(c, 'Select all days on which this repeats.', [menu]);
return screen(c, 'Select all days on which this repeats.', [menu, row(button(c, 'Cancel', prefix + (d.taskId ? 'task-detail' : 'detail'), d))]);
}
const s = d.taskId ? c.service.tasks(h.id).find(t => t.id === d.taskId)?.schedule : h.schedule;
if (!s) throw new ApiError(404, 'Task not found. Run /tasks again.');
@@ -132,7 +135,7 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
export function manageTasks(c: Context, d: ControlData = {}): Reply {
const habits = c.service.list().filter(h => h.method === 'tasks');
const page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(habits.length / 10) - 1)));
return message(c, `**Task habits** · Page ${page + 1}\n${habits.length ? 'Choose a habit to manage its checklist.' : 'Create a habit with the checklist method to add tasks.'}`, [
return screen(c, `**Task habits** · Page ${page + 1}\n${habits.length ? 'Choose a habit to manage its checklist.' : 'Create a habit with the checklist method to add tasks.'}`, [
...(habits.length ? [select(c, 'Choose a task habit', prefix + 'tasks-open', {}, habits.slice(page * 10, page * 10 + 10).map(h => ({ id: h.id, name: h.name })))] : []),
row(button(c, 'Create habit', prefix + 'create'), button(c, 'All habits', prefix + 'list')),
...pages(c, prefix + 'tasks-home', { page }, habits.length),
@@ -143,7 +146,7 @@ function taskList(c: Context, d: ControlData): Reply {
if (h.archived) throw new ApiError(409, 'Restore the habit in /habits before editing tasks.');
const tasks = c.service.tasks(h.id), page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(tasks.length / 10) - 1)));
const state = { ...snapshot(c, h.id), page };
return message(c, `**${text(h.name)} · Tasks** · Page ${page + 1}\nHabit recurrence: ${scheduleLabel(h.schedule)}\nTasks are due only when their own and the habit’s schedules coincide. ${tasks.length ? '' : 'Add your first task.'}`, [
return screen(c, `**${text(h.name)} · Tasks** · Page ${page + 1}\nHabit recurrence: ${scheduleLabel(h.schedule)}\nTasks are due only when their own and the habit’s schedules coincide. ${tasks.length ? '' : 'Add your first task.'}`, [
...(tasks.length ? [select(c, 'Choose a task', prefix + 'task-open', state, tasks.slice(page * 10, page * 10 + 10).map(t => ({ id: t.id, name: t.name, description: scheduleLabel(t.schedule) })))] : []),
row(button(c, 'Add task', prefix + 'task-add', state), button(c, 'Habit details', prefix + 'detail', state), button(c, 'Other task habits', prefix + 'tasks-home')),
...pages(c, prefix + 'tasks-list', state, tasks.length),
@@ -155,7 +158,7 @@ function taskDetail(c: Context, d: ControlData): Reply {
if (h.archived) throw new ApiError(409, 'Restore the habit in /habits before editing tasks.');
const state = { ...snapshot(c, h.id), taskId: task.id };
const warning = h.warnings.find(w => w.taskId === task.id);
return message(c, `**${text(task.name)}** · ${text(h.name)}\n${scheduleLabel(task.schedule)}\n${warning ? text(warning.message) : 'Due when the task and habit schedules coincide.'}`, [
return screen(c, `**${text(task.name)}** · ${text(h.name)}\n${scheduleLabel(task.schedule)}\n${warning ? text(warning.message) : 'Due when the task and habit schedules coincide.'}`, [
row(button(c, 'Rename task', prefix + 'task-rename', state), button(c, 'Change task schedule', prefix + 'schedule', state), button(c, 'Delete task', prefix + 'task-delete', state, 4)),
row(button(c, 'Back to tasks', prefix + 'tasks-list', state)),
]);
@@ -181,7 +184,7 @@ function taskAction(c: Context, a: string, d: ControlData): Reply | undefined {
if (a === 'task-delete') {
check(c, d); const task = c.service.tasks(d.habitId!).find(t => t.id === d.taskId);
if (!task) throw new ApiError(404, 'Task not found.');
return message(c, `Delete ${text(task.name)} from this checklist starting today? Today’s completion total may change. Earlier dated history remains. Adding it again creates a new task without its old completion state.`, [row(button(c, 'Confirm task deletion', prefix + 'task-delete-confirm', d, 4), button(c, 'Cancel', prefix + 'task-detail', d))]);
return screen(c, `Delete ${text(task.name)} from this checklist starting today? Today’s completion total may change. Earlier dated history remains. Adding it again creates a new task without its old completion state.`, [row(button(c, 'Confirm task deletion', prefix + 'task-delete-confirm', d, 4), button(c, 'Cancel', prefix + 'task-detail', d))]);
}
if (a === 'task-delete-confirm') { check(c, d); c.service.changeTask(d.habitId!, d.taskId, null); consume(c); return taskList(c, { habitId: d.habitId }); }
}

View File

@@ -1,14 +1,14 @@
import { afterEach, expect, test } from 'bun:test';
import { eq } from 'drizzle-orm';
import { reminderDeliveries, users } from '../db/schema';
import { discordFixture, control, submit } from './test-fixture';
import { discordFixture, control, submit, messageText, components } from './test-fixture';
import { commands } from './commands';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
const click = (reply: any, label: string) => ({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label) } });
const draft = { name: 'Take a breath', times: '09:00, 18:30', days: 'Mon, Wed, Fri', date: '' };
async function choose(list: any, guild = false, index = '0') {
return f.run({ ...click(list, 'Choose a reminder'), data: { custom_id: control(list, 'Choose a reminder'), values: [index] } }, guild);
return f.run(click(list, 'Manage reminder'), guild);
}
test('settings and reminders are global commands in both contexts', () => {
for (const name of ['settings', 'reminders']) expect(commands.find(c => c.name === name)?.contexts).toEqual([0, 1]);
@@ -17,7 +17,7 @@ for (const guild of [false, true]) test(`full named reminder management shares w
f = discordFixture(true);
await f.json('/reminders', 'PUT', { enabled: false, reminders: [], quietStart: '22:00', quietEnd: '08:00' });
let list = await f.run({ data: { name: 'reminders' } }, guild);
expect(list.data.flags).toBe(guild ? 64 : undefined);
expect(list.data.flags).toBe(32768 | (guild ? 64 : 0));
const form = await f.run(click(list, 'Add reminder'), guild);
const interaction = f.payload(submit(form.data.custom_id, draft), guild);
list = await (await f.requestDiscord(interaction)).json();
@@ -40,7 +40,7 @@ for (const guild of [false, true]) test(`full named reminder management shares w
list = await f.run(click(confirmation, 'Confirm removal'), guild);
expect((await f.json('/reminders')).reminders).toEqual([]);
expect((await f.json('/reminders')).enabled).toBe(false);
expect(list.data.content).toContain('No reminders');
expect(messageText(list)).toContain('No reminders');
const settings = await f.run({ data: { name: 'settings' } }, guild);
const tz = await f.run(click(settings, 'Change timezone'), guild);
await f.run(submit(tz.data.custom_id, { timezone: 'America/New_York' }), guild);
@@ -55,20 +55,20 @@ test('bad input can reopen a prefilled form without consuming the valid control'
let form = await f.run(click(list, 'Add reminder'));
for (const invalid of [{ ...draft, times: '25:00' }, { ...draft, days: 'Funday' }, { ...draft, days: '', date: '2026-02-30' }, { ...draft, date: '2026-09-30' }]) {
const failed = await f.run(submit(form.data.custom_id, invalid));
expect(failed.type).toBe(4);
expect(failed.type).toBe(7);
form = await f.run(click(failed, 'Try again'));
expect(form.type).toBe(9);
expect(form.data.components[0].components[0].value).toBe(draft.name);
}
const saved = await f.run(submit(form.data.custom_id, draft));
expect(saved.data.content).toContain(draft.name);
expect(messageText(saved)).toContain(draft.name);
const enabled = await f.run(click(saved, 'Enable delivery'));
expect(enabled.data.content).toContain('not configured');
expect(messageText(enabled)).toContain('not configured');
expect((await f.json('/reminders')).enabled).toBe(false);
const settings = await f.run({ data: { name: 'settings' } });
const timezone = await f.run(click(settings, 'Change timezone'));
const invalid = await f.run(submit(timezone.data.custom_id, { timezone: 'Invalid/Zone' }));
expect(invalid.data.content).toContain('Use a timezone');
expect(messageText(invalid)).toContain('Use a timezone');
const retry = await f.run(click(invalid, 'Try again'));
await f.run(submit(retry.data.custom_id, { timezone: 'UTC' }));
expect((await f.json('/me')).timezone).toBe('UTC');
@@ -82,17 +82,17 @@ test('website edits and timezone changes invalidate old controls even within one
const prefs = { enabled: false, reminders: before.reminders, quietStart: '22:00', quietEnd: '08:00' };
await f.json('/reminders', 'PUT', prefs);
const blocked = await f.run(submit(form.data.custom_id, draft));
expect(blocked.data.content).toContain('Settings changed');
expect(messageText(blocked)).toContain('Settings changed');
const newer = await f.run({ data: { name: 'reminders' } });
const newerForm = await f.run(click(newer, 'Add reminder'));
await f.json('/reminders', 'PUT', { ...prefs, quietStart: '21:00' });
await f.json('/reminders', 'PUT', prefs);
expect((await f.run(submit(newerForm.data.custom_id, draft))).data.content).toContain('Settings changed');
expect(messageText((await f.run(submit(newerForm.data.custom_id, draft))))).toContain('Settings changed');
const settings = await f.run({ data: { name: 'settings' } });
const tz = await f.run(click(settings, 'Change timezone'));
await f.json('/me', 'PATCH', { timezone: 'UTC' });
await f.json('/me', 'PATCH', { timezone: 'Europe/Belgrade' });
expect((await f.run(submit(tz.data.custom_id, { timezone: 'Asia/Tokyo' }))).data.content).toContain('Settings changed');
expect(messageText((await f.run(submit(tz.data.custom_id, { timezone: 'Asia/Tokyo' }))))).toContain('Settings changed');
expect((await f.json('/me')).timezone).toBe('Europe/Belgrade');
});
@@ -102,11 +102,11 @@ test('old forms cannot cross accounts; new Discord accounts discover UTC setup',
const form = await f.run(click(list, 'Add reminder'));
const blocked = await f.run(submit(form.data.custom_id, draft), true, f.bob);
expect(blocked.data.flags).toBe(64);
expect(blocked.data.content).toContain('belongs to another user');
expect(messageText(blocked)).toContain('belongs to another user');
expect((await f.json('/reminders', 'GET', undefined, 200, 'b')).reminders).toHaveLength(1);
const id = '555555555555555555';
const settings = await f.run({ data: { name: 'settings' } }, false, id);
expect(settings.data.content).toContain('New accounts start in UTC');
expect(messageText(settings)).toContain('New accounts start in UTC');
expect(f.db.select().from(users).where(eq(users.discordId, id)).get()?.timezone).toBe('UTC');
});
@@ -116,19 +116,19 @@ test('paginated list, 20-slot limit, delivery status and quiet-hour validation',
await f.json('/reminders', 'PUT', { enabled: false, reminders, quietStart: '22:00', quietEnd: '08:00' });
f.db.insert(reminderDeliveries).values({ id: 'delivery', userId: 'alice', date: '2026-09-04', status: 'pending', updatedAt: 1 }).run();
const list = await f.run({ data: { name: 'reminders' } });
expect(list.data.content).toContain('Delivery unconfirmed');
expect(messageText(list)).toContain('Delivery unconfirmed');
const next = await f.run(click(list, 'Next'));
expect(next.data.content).toContain('Reminder 20');
expect(messageText(next)).toContain('Reminder 12');
const form = await f.run(click(next, 'Add reminder'));
const fail = await f.run(submit(form.data.custom_id, draft));
expect(fail.data.content).toContain('up to 20');
expect(messageText(fail)).toContain('up to 20');
expect((await f.json('/reminders')).reminders).toHaveLength(20);
const quiet = await f.run(click(list, 'Quiet hours'));
const bad = await f.run(submit(quiet.data.custom_id, { start: '25:00', end: '00:00' }));
const retry = await f.run(click(bad, 'Try again'));
const done = await f.run(submit(retry.data.custom_id, { start: '00:00', end: '00:00' }));
expect(done.data.content).toContain('(off)');
expect(messageText(done)).toContain('(off)');
const expired = await f.run({ data: { name: 'settings' } });
f.setDiscordTime('2026-09-04T12:16:00Z');
expect((await f.run(click(expired, 'Change timezone'))).data.content).toContain('expired');
expect(messageText((await f.run(click(expired, 'Change timezone'))))).toContain('expired');
});

View File

@@ -6,7 +6,7 @@ import { ReminderService } from '../reminders/service';
import type { ReminderEntry } from '../reminders/contracts';
import type { Context, ControlData, Reply } from './types';
import { fields } from './types';
import { button, message, modal, pages, row, select, text } from './ui';
import { button, screen, modal, pages, row, text, componentMessage, display, panel, section, listSize, listPage, pageLabel } from './ui';
const deliveryLabels: Record<string, string> = {
sent: 'Delivered', deferred: 'Waiting to retry', uncertain: 'Delivery unconfirmed; check Discord. This attempt will not be retried.',
@@ -40,12 +40,13 @@ const schedule = (e: ReminderEntry) => e.type === 'date' ? e.date : e.days.lengt
export function preferences(c: Context, command: string, data: ControlData = {}): Reply {
const prefs = service(c).read(), tz = user(c).timezone, state = snapshot(c, data);
const status = `Delivery: ${prefs.available ? prefs.enabled ? 'enabled' : 'disabled' : 'not configured on this server'}\nLast delivery: ${prefs.lastDelivery ? `${prefs.lastDelivery.date} · ${deliveryLabels[prefs.lastDelivery.status] ?? 'Unknown status'}` : 'none yet'}\nQuiet hours: ${prefs.quietStart}–${prefs.quietEnd}${prefs.quietStart === prefs.quietEnd ? ' (off)' : ''}`;
if (command === 'settings') return message(c, `**Settings**\nTimezone: ${text(tz)}${tz === 'UTC' ? '\nNew accounts start in UTC. Set your timezone for local habit days and reminder times.' : ''}\n${status}`, [row(button(c, 'Change timezone', 'prefs.timezone', state), button(c, 'Manage reminders', 'prefs.list'))]);
const all = groups(c), page = Math.max(0, Math.min(data.page ?? 0, Math.ceil(all.length / 10) - 1)), visible = all.slice(page * 10, page * 10 + 10);
return message(c, `**Reminders · ${text(tz)}**\n${status}\nReminders arrive by DM only when habits remain unfinished. Times use your timezone.\n${tz === 'UTC' ? 'Use Settings to choose your local timezone.\n' : ''}\n${visible.map(g => `• ${text(g.name)}: ${g.times.join(', ')} · ${schedule(g.entry)}`).join('\n') || 'No reminders. Add one to get started.'}\n${prefs.reminders.length}/20 time slots`, [
...(visible.length ? [select(c, 'Choose a reminder', 'prefs.select', state, visible.map((g, i) => ({ id: String(page * 10 + i), name: g.name, description: `${g.times.join(', ')} · ${schedule(g.entry)}` })))] : []),
...pages(c, 'prefs.list', { page }, all.length),
row(button(c, 'Add reminder', 'prefs.edit', { ...state, index: undefined }), button(c, prefs.enabled ? 'Disable delivery' : 'Enable delivery', 'prefs.toggle', { ...state, done: !prefs.enabled }), button(c, 'Quiet hours', 'prefs.quiet', state)),
if (command === 'settings') return screen(c, `**Settings**\nTimezone: ${text(tz)}${tz === 'UTC' ? '\nNew accounts start in UTC. Set your timezone for local habit days and reminder times.' : ''}\n${status}`, [row(button(c, 'Change timezone', 'prefs.timezone', state), button(c, 'Manage reminders', 'prefs.list'))]);
const all = groups(c), page = listPage(data.page, all.length), visible = all.slice(page * listSize, (page + 1) * listSize);
return componentMessage(c, [
panel([display(`## Reminders · ${text(tz)}\n${status}\nReminders arrive by DM only when habits remain unfinished.\n${tz === 'UTC' ? 'Use Settings to choose your local timezone.\n' : ''}${prefs.reminders.length}/20 time slots · ${pageLabel(page, all.length)}${all.length ? '' : '\nNo reminders. Add one to get started.'}`)]),
...visible.map((g, i) => panel([section(`**${text(g.name)}**\n${g.times.join(', ')} · ${schedule(g.entry)}`, button(c, 'Manage reminder', 'prefs.select', { ...state, index: page * listSize + i }))])),
...pages(c, 'prefs.list', { page }, all.length, listSize),
row(button(c, 'Add reminder', 'prefs.edit', { ...state, index: undefined }, 1), button(c, prefs.enabled ? 'Disable delivery' : 'Enable delivery', 'prefs.toggle', { ...state, done: !prefs.enabled }), button(c, 'Quiet hours', 'prefs.quiet', state)),
row(button(c, 'Settings', 'prefs.settings'), button(c, 'Refresh', 'prefs.list', { page }), button(c, 'View schedule card', 'graphic.reminders')),
]);
}
@@ -84,12 +85,12 @@ export function preferencesAction(c: Context, action: string, data: ControlData)
if (action === 'prefs.quiet') { const p = service(c).read(); return modal(c, 'Quiet hours (same times = off)', 'prefs.quietSave', data, [{ id: 'start', label: 'Start (24-hour HH:MM)', value: data.draft?.start ?? p.quietStart, max: 5 }, { id: 'end', label: 'End (24-hour HH:MM)', value: data.draft?.end ?? p.quietEnd, max: 5 }]); }
if (action === 'prefs.edit') return edit(c, data);
if (action === 'prefs.select') {
const selected = c.interaction.data?.values?.[0] ?? '';
const selected = data.index === undefined ? c.interaction.data?.values?.[0] ?? '' : String(data.index);
if (!/^\d+$/.test(selected)) throw new ApiError(404, 'Reminder not found.');
const state = { ...data, index: Number(selected) }, g = group(c, state);
return message(c, `**${text(g.name)}**\n${g.times.join(', ')} · ${schedule(g.entry)}\nTimezone: ${text(user(c).timezone)}`, [row(button(c, 'Edit reminder', 'prefs.edit', state), button(c, 'Remove reminder', 'prefs.remove', state, 4), button(c, 'Back', 'prefs.list'))]);
return screen(c, `**${text(g.name)}**\n${g.times.join(', ')} · ${schedule(g.entry)}\nTimezone: ${text(user(c).timezone)}`, [row(button(c, 'Edit reminder', 'prefs.edit', state), button(c, 'Remove reminder', 'prefs.remove', state, 4), button(c, 'Back', 'prefs.list'))]);
}
if (action === 'prefs.remove') return message(c, `Remove **${text(group(c, data).name)}** and all its times?`, [row(button(c, 'Confirm removal', 'prefs.removeSave', data, 4), button(c, 'Cancel', 'prefs.list'))]);
if (action === 'prefs.remove') return screen(c, `Remove **${text(group(c, data).name)}** and all its times?`, [row(button(c, 'Confirm removal', 'prefs.removeSave', data, 4), button(c, 'Cancel', 'prefs.list'))]);
try {
if (action === 'prefs.timezoneSave') {
const parsed = timezoneInput.safeParse({ timezone: fields(c.interaction).timezone?.trim() });
@@ -108,6 +109,6 @@ export function preferencesAction(c: Context, action: string, data: ControlData)
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 422) throw error;
const retry = { 'prefs.save': 'prefs.edit', 'prefs.quietSave': 'prefs.quiet', 'prefs.timezoneSave': 'prefs.timezone' }[action];
return message(c, error.message, [row(...(retry ? [button(c, 'Try again', retry, { ...data, draft: fields(c.interaction) })] : []), button(c, 'Reminders', 'prefs.list'), button(c, 'Settings', 'prefs.settings'))], false);
return screen(c, error.message, [row(...(retry ? [button(c, 'Try again', retry, { ...data, draft: fields(c.interaction) })] : []), button(c, 'Reminders', 'prefs.list'), button(c, 'Settings', 'prefs.settings'))]);
}
}

View File

@@ -2,7 +2,7 @@ import { afterEach, expect, test } from 'bun:test';
import { createAuth } from '../auth';
import { createSharingRoutes } from '../sharing/routes';
import { createLiveDiscord } from '../sharing/live-discord';
import { discordFixture, control } from './test-fixture';
import { discordFixture, control, messageText, habitControl, findHabitPage } from './test-fixture';
import type { DeferredMessage } from './progress';
let f: ReturnType<typeof discordFixture>;
let stop = () => {};
@@ -23,24 +23,25 @@ function setup(settings: { allowed?: string[]; fail?: boolean; writes?: boolean
return { jobs, replies, sends, drain: async () => { while (jobs.length) await jobs.shift()!(); return { data: replies.at(-1)!.message }; } };
}
async function click(reply: any, label: string, guild = false, values?: string[]) {
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), values } }, guild);
const id = label === 'Preview' && values?.[0] ? habitControl(reply, (await f.json(`/habits/${values[0]}`)).name, label) : control(reply, label);
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: id, values } }, guild);
}
for (const guild of [false, true]) test(`progress preview, explicit snapshot and live lifecycle in ${guild ? 'server' : 'DM'}`, async () => {
const env = setup();
const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201);
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
const panel = await f.run({ data: { name: 'progress' } }, guild);
expect(panel.data.content).toContain('Reading — 1/1');
expect(panel.data.flags).toBe(guild ? 64 : undefined);
expect(await click(panel, 'Preview a habit', guild, [habit.id])).toEqual({ type: 5, data: { flags: 64 } });
expect(messageText(panel)).toContain('1/1 scheduled days complete');
expect(panel.data.flags).toBe(32768 | (guild ? 64 : 0));
expect(await click(panel, 'Preview', guild, [habit.id])).toEqual({ type: 6 });
expect(env.sends).toHaveLength(0);
const preview = await env.drain();
expect(env.replies.at(-1)!.image!.slice(0, 8)).toEqual(new Uint8Array([137,80,78,71,13,10,26,10]));
expect(preview.data.content).toContain('1/1');
expect(messageText(preview)).toContain('1/1');
await click(preview, 'Choose public destination', guild);
const confirmation = await env.drain();
expect(confirmation.data.content).toContain('habit-sharing');
expect(confirmation.data.content).toContain('https://discord.com/channels/666666666666666666/555555555555555555');
expect(messageText(confirmation)).toContain('habit-sharing');
expect(messageText(confirmation)).toContain('https://discord.com/channels/666666666666666666/555555555555555555');
expect(env.sends).toHaveLength(0);
const i = f.payload({ type: 3, data: { custom_id: control(confirmation, 'Share snapshot publicly') } }, guild);
await f.requestDiscord(i); await env.drain();
@@ -62,28 +63,30 @@ test('pagination, cross-account controls, stale snapshot and uncertain delivery
const env = setup({ fail: true });
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
for (let n = 0; n < 6; n++) await f.json('/habits', 'POST', { name: `Habit ${n}`, method: 'manual' }, 201);
const panel = await f.run({ data: { name: 'progress' } });
let panel = await f.run({ data: { name: 'progress' } });
expect(control(panel, 'Next')).toBeString();
const stolen = await f.run({ type: 3, data: { custom_id: control(panel, 'Preview a habit'), values: [h.id] } }, true, f.bob);
expect(stolen.data.content).toContain('belongs to another user');
await click(panel, 'Preview a habit', false, [h.id]); const preview = await env.drain();
panel = await findHabitPage(f, panel, 'Read');
const previewId = habitControl(panel, 'Read', 'Preview');
const stolen = await f.run({ type: 3, data: { custom_id: previewId, values: [h.id] } }, true, f.bob);
expect(messageText(stolen)).toContain('belongs to another user');
await click(panel, 'Preview', false, [h.id]); const preview = await env.drain();
await click(preview, 'Choose public destination'); const confirm = await env.drain();
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true });
expect((await click(confirm, 'Share snapshot publicly')).data.content).toContain('changed');
expect(messageText((await click(confirm, 'Share snapshot publicly')))).toContain('changed');
expect(env.sends).toHaveLength(0);
expect((await click(preview, 'Choose public destination')).data.content).toContain('changed');
await click(panel, 'Preview a habit', false, [h.id]); const newPreview = await env.drain();
expect(messageText((await click(preview, 'Choose public destination')))).toContain('changed');
await click(panel, 'Preview', false, [h.id]); const newPreview = await env.drain();
await click(newPreview, 'Choose public destination'); const fresh = await env.drain();
await click(fresh, 'Share snapshot publicly'); const result = await env.drain();
expect(result.data.content).toContain('could not confirm');
expect((await click(fresh, 'Share snapshot publicly')).data.content).toContain('expired');
expect(messageText(result)).toContain('could not confirm');
expect(messageText((await click(fresh, 'Share snapshot publicly')))).toContain('expired');
expect(env.sends).toHaveLength(1);
});
test('allowlist and disabled writes apply to command shares', async () => {
const env = setup({ allowed: ['222222222222222222'] });
const panel = await f.run({ data: { name: 'progress' } });
await click(panel, 'Live image controls'); const result = await env.drain();
expect(result.data.content).toContain('approved community');
expect(messageText(result)).toContain('approved community');
expect(env.sends).toHaveLength(0);
});
test('disabled writes, shared snapshot quotas, and stale live confirmations', async () => {
@@ -91,38 +94,65 @@ test('disabled writes, shared snapshot quotas, and stale live confirmations', as
const panel = await f.run({ data: { name: 'progress' } });
await click(panel, 'Live image controls'); const live = await env.drain();
await click(live, 'Start live image publicly'); const denied = await env.drain();
expect(denied.data.content).toContain('disabled in development');
expect(messageText(denied)).toContain('disabled in development');
expect(env.sends).toHaveLength(0);
});
test('snapshot quotas are shared with website operations and new controls cannot bypass them', async () => {
const env = setup();
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
const panel = await f.run({ data: { name: 'progress' } });
await click(panel, 'Preview a habit', false, [h.id]); const preview = await env.drain();
await click(panel, 'Preview', false, [h.id]); const preview = await env.drain();
await click(preview, 'Choose public destination'); const first = await env.drain();
await click(first, 'Share snapshot publicly'); await env.drain();
await click(preview, 'Choose public destination'); const next = await env.drain();
await click(next, 'Share snapshot publicly'); const limited = await env.drain();
expect(limited.data.content).toContain('Sharing limit reached');
expect(messageText(limited)).toContain('Sharing limit reached');
expect(env.sends).toHaveLength(1);
await click(panel, 'Live image controls'); const live = await env.drain();
await f.json('/habits', 'POST', { name: 'New private habit', method: 'manual' }, 201);
expect((await click(live, 'Start live image publicly')).data.content).toContain('changed');
expect(messageText((await click(live, 'Start live image publicly')))).toContain('changed');
expect(env.sends).toHaveLength(1);
f.setDiscordTime('2026-09-04T12:16:00Z');
expect((await click(preview, 'Choose public destination')).data.content).toContain('expired');
expect(messageText((await click(preview, 'Choose public destination')))).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(panel, 'Preview', 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(messageText(result)).toContain('changed');
expect(env.sends).toHaveLength(0);
});
for (const guild of [false, true]) test(`progress previews, theme changes, errors and back navigation edit the original (${guild ? 'server' : 'DM'})`, async () => {
const env = setup();
await f.json('/habits', 'POST', { name: 'Read', method: 'manual', color: '#123456' }, 201);
const list = await f.run({ data: { name: 'progress' } }, guild);
expect(await click(list, 'Preview', guild)).toEqual({ type: 6 });
let preview = await env.drain();
expect(preview.data.flags).toBe(32768);
expect(preview.data.content).toBeNull();
expect(preview.data.components![0]!.accent_color).toBe(0x123456);
expect(preview.data.components!.at(-1)).toMatchObject({ type: 12, items: [{ media: { url: 'attachment://mina-progress.png' } }] });
expect(await click(preview, 'Toggle theme', guild)).toEqual({ type: 6 });
preview = await env.drain();
const back = await click(preview, 'Back to private progress', guild);
expect(back.type).toBe(7);
expect(back.data.attachments).toEqual([]);
expect(back.data.components.some((c: any) => c.type === 12)).toBe(false);
expect(env.sends).toHaveLength(0);
// Change the website after previewing; the error must still be a usable V2 update.
const h = (await f.json('/habits')).habits[0];
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true });
const failed = await click(preview, 'Choose public destination', guild);
expect(failed.type).toBe(7);
expect(failed.data.flags).toBe(32768);
expect(messageText(failed)).toContain('Progress changed');
expect((await click(failed, 'Back to private progress', guild)).type).toBe(7);
});

View File

@@ -5,7 +5,7 @@ import { liveCardData } from '../sharing/live-card-image';
import type { createSharingRoutes } from '../sharing/routes';
import type { createLiveDiscord } from '../sharing/live-discord';
import type { Context, ControlData, Message, Reply } from './types';
import { button, message, pages, row, select, text } from './ui';
import { button, screen, pages, row, text, componentMessage, display, panel, section, listSize, listPage, pageLabel } from './ui';
export type ProgressOperations = {
sharing: Pick<ReturnType<typeof createSharingRoutes>, 'send' | 'destination'>;
@@ -28,25 +28,34 @@ function imageData(c: Context, data: ControlData) {
async function render(c: Context, data: ControlData, source = imageData(c, data)) {
return renderProgressImage(source, data.theme ?? 'light', c.service.user.id);
}
function output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; }
function output(reply: Reply, image?: Uint8Array): DeferredMessage {
const message = reply.data as Message;
if (image) message.components = [...(message.components ?? []), { type: 12, items: [{ media: { url: 'attachment://mina-progress.png' }, description: 'Habit progress for the selected period. Exact totals are shown above.' }] }];
return { message, image, imageDescription: image ? 'Habit progress for the selected period. Exact totals are shown in the message.' : undefined };
}
function defer(c: Context, job: () => Promise<DeferredMessage>): Reply {
if (!c.defer) throw new ApiError(503, 'Progress rendering is unavailable. Try again later.');
c.defer(job);
return { type: 5, data: { flags: 64 } };
c.defer(async () => {
try { return await job(); }
catch (error) {
return output(screen(c, error instanceof ApiError ? error.message : 'Could not complete this action. Check the destination before trying another share.', [row(button(c, 'Back to private progress', 'progress'))]));
}
});
return c.interaction.message && [3, 5].includes(c.interaction.type) ? { type: 6 } : { type: 5, data: { flags: c.interaction.guild_id ? 64 : 0 } };
}
export function progress(c: Context, data: ControlData = {}): Reply {
const habits = c.service.list(), page = data.page ?? 0;
const visible = habits.slice(page * 6, page * 6 + 6);
const habits = c.service.list(), page = listPage(data.page, habits.length);
const result = imageData(c, {});
const state = c.progress?.live.state(c.service.user.id);
return message(c, `**Progress · ${result.from} to ${result.to}**\n${text(c.service.user.timezone)} · Live image: ${state?.status ?? 'unavailable'}\n\n${visible.map(h => {
const stats = result.habits[habits.findIndex(v => v.id === h.id)]!;
return `${text(h.name)} — ${stats.completed}/${stats.scheduled} scheduled days complete`;
}).join('\n') || 'No active habits. Create a habit on the website or with /habits.'}\n\nPreviews are private. A public share requires confirmation of the configured destination.`, [
...(visible.length ? [select(c, 'Preview a habit', 'progress.preview', {}, visible.map(h => ({ id: h.id, name: h.name })))] : []),
...pages(c, 'progress', data, habits.length, 6),
row(button(c, 'Live image controls', 'progress.live'), button(c, 'Refresh progress', 'progress')),
], false);
return componentMessage(c, [
panel([display(`## Progress\n${result.from} to ${result.to} · ${text(c.service.user.timezone)}\nLive image: ${state?.status ?? 'unavailable'} · ${pageLabel(page, habits.length)}\nPreviews are private. Public sharing requires confirmation.${habits.length ? '' : '\nNo active habits. Create one with /habits.'}`)]),
...habits.slice(page * listSize, (page + 1) * listSize).map(h => {
const stats = result.habits[habits.findIndex(v => v.id === h.id)]!;
return panel([section(`**${text(h.name)}**\n${stats.completed}/${stats.scheduled} scheduled days complete`, button(c, 'Preview', 'progress.preview', { habitId: h.id, page }))], c.service.settings(h.id).mainColor);
}),
...pages(c, 'progress', { page }, habits.length, listSize),
row(button(c, 'Live image controls', 'progress.live'), button(c, 'Refresh progress', 'progress', { page })),
]);
}
export function progressAction(c: Context, action: string, data: ControlData): Reply | undefined {
if (action === 'progress') return progress(c, data);
@@ -54,15 +63,15 @@ export function progressAction(c: Context, action: string, data: ControlData): R
const ops = c.progress;
if (!ops) throw new ApiError(503, 'Progress sharing is unavailable.');
if (action === 'progress.preview') {
const habitId = c.interaction.data?.values?.[0] ?? data.habitId;
const habitId = data.habitId ?? c.interaction.data?.values?.[0];
c.service.current(habitId!);
const selection = { habitId, theme: data.theme ?? 'light' as const };
const selection = { habitId, page: data.page, theme: data.theme ?? 'light' as const };
return defer(c, async () => {
const content = imageData(c, selection);
const image = await render(c, selection, content);
const confirmed = { ...selection, expected: fingerprint(content) };
const stats = content.habits[0]!;
return output(message(c, `**Private preview · ${text(stats.name)}**\n${content.from} to ${content.to} · ${text(c.service.user.timezone)}\n${stats.completed}/${stats.scheduled} scheduled days complete.\nThe snapshot includes your display name and this habit's name and progress.`, [row(button(c, 'Choose public destination', 'progress.confirm', confirmed), button(c, 'Toggle theme', 'progress.preview', { ...selection, theme: selection.theme === 'light' ? 'dark' : 'light' }))], false), image);
return output(screen(c, `**Private preview · ${text(stats.name)}**\n${content.from} to ${content.to} · ${text(c.service.user.timezone)}\n${stats.completed}/${stats.scheduled} scheduled days complete.\nThe snapshot includes your display name and this habit's name and progress.`, [row(button(c, 'Choose public destination', 'progress.confirm', confirmed), button(c, 'Toggle theme', 'progress.preview', { ...selection, theme: selection.theme === 'light' ? 'dark' : 'light' }), button(c, 'Back to private progress', 'progress', { page: data.page }))], true, c.service.settings(habitId!).mainColor), image);
});
}
if (action === 'progress.confirm' && data.expected !== fingerprint(imageData(c, data))) throw new ApiError(409, 'Progress changed. Open a fresh preview before sharing.');
@@ -71,7 +80,7 @@ export function progressAction(c: Context, action: string, data: ControlData): R
const destination = await ops.sharing.destination(c.service.user);
const state = ops.live.state(c.service.user.id);
const selection = { ...data, expected: action === 'progress.confirm' ? data.expected : fingerprint(liveSnapshot()), deliveryId: crypto.randomUUID(), mode: destination.channelUrl };
return output(message(c, `${action === 'progress.confirm' ? '**Share snapshot?**' : `**Live image · ${state.status}**`}\nPublic destination: [${text(destination.name)}](${destination.channelUrl})\n${action === 'progress.confirm' ? 'Shares your display name and the selected habit’s progress.' : 'The live image includes your display name and all active habit names and progress. It updates in the same message as progress changes.'}${state.messageUrl ? `\n[Open live image](${state.messageUrl})` : ''}${state.error ? `\n${text(state.error)}` : ''}`, [row(...(action === 'progress.confirm' ? [button(c, 'Share snapshot publicly', 'progress.send', selection, 1)] : [button(c, state.status === 'off' ? 'Start live image publicly' : 'Resume live image publicly', 'progress.start', selection, 1), button(c, 'Pause live image', 'progress.pause', selection)])), row(button(c, 'Back to private progress', 'progress'))], false));
return output(screen(c, `${action === 'progress.confirm' ? '**Share snapshot?**' : `**Live image · ${state.status}**`}\nPublic destination: [${text(destination.name)}](${destination.channelUrl})\n${action === 'progress.confirm' ? 'Shares your display name and the selected habit’s progress.' : 'The live image includes your display name and all active habit names and progress. It updates in the same message as progress changes.'}${state.messageUrl ? `\n[Open live image](${state.messageUrl})` : ''}${state.error ? `\n${text(state.error)}` : ''}`, [row(...(action === 'progress.confirm' ? [button(c, 'Share snapshot publicly', 'progress.send', selection, 1)] : [button(c, state.status === 'off' ? 'Start live image publicly' : 'Resume live image publicly', 'progress.start', selection, 1), button(c, 'Pause live image', 'progress.pause', selection)])), row(button(c, 'Back to private progress', 'progress'))]));
});
if (['progress.send', 'progress.start', 'progress.pause'].includes(action)) {
const current = action === 'progress.send' ? imageData(c, data) : liveSnapshot();
@@ -90,10 +99,10 @@ export function progressAction(c: Context, action: string, data: ControlData): R
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([new Uint8Array(image)], 'mina-habits-progress.png', { type: 'image/png' }));
return output(message(c, delivery.status === 'sent' ? `Snapshot shared.${delivery.messageUrl ? ` [Open snapshot](${delivery.messageUrl})` : ''}` : 'Discord could not confirm delivery. Check the destination before creating another snapshot; this action will not be retried.', [], false));
return output(screen(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.', [row(button(c, 'Back to private progress', 'progress'))]));
}
const state = action === 'progress.pause' ? await ops.live.pause(c.service.user.id) : await ops.live.start(c.service.user, data.theme ?? 'light');
return output(message(c, `Live image: **${state.status}**.${state.messageUrl ? ` [Open live image](${state.messageUrl})` : ''}${state.error ? `\n${text(state.error)}` : ''}`, [row(button(c, 'Live image status', 'progress.live'), button(c, 'Private progress', 'progress'))], false));
return output(screen(c, `Live image: **${state.status}**.${state.messageUrl ? ` [Open live image](${state.messageUrl})` : ''}${state.error ? `\n${text(state.error)}` : ''}`, [row(button(c, 'Live image status', 'progress.live'), button(c, 'Private progress', 'progress'))]));
});
}
}

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { discordFixture, control, submit } from './test-fixture';
import { discordFixture, messageText, control, submit } from './test-fixture';
import { commands } from './commands';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
@@ -17,8 +17,8 @@ describe('signed Discord interactions', () => {
for (const guild of [false, true]) test(`records progress from ${guild ? 'server' : 'DM'} and shares website state`, async () => {
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Reading', method: 'count', target: 10, unit: 'pages' }, 201);
const today = await f.run({ data: { name: 'today' } }, guild);
expect(today.data.flags).toBe(guild ? 64 : undefined);
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(today, 'Choose a habit'), values: [h.id] } }, guild);
expect(today.data.flags).toBe(32768 | (guild ? 64 : 0));
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(today, 'Details'), values: [h.id] } }, guild);
const click = f.payload({ type: 3, message: { id: '123' }, data: { custom_id: control(detail, '+1') } }, guild);
expect((await (await f.requestDiscord(click)).json()).type).toBe(7);
f.restart(); await f.requestDiscord(click);
@@ -26,20 +26,20 @@ describe('signed Discord interactions', () => {
expect(f.changed).toEqual(['alice']);
const stale = await f.run({ type: 3, data: { custom_id: control(detail, 'Enter amount') } }, guild);
const failed = await f.run(submit(stale.data.custom_id, { amount: '7' }), guild);
expect(failed.data.content).toContain('Progress changed');
expect(messageText(failed)).toContain('Progress changed');
});
test('owner-bound buttons and habit IDs prevent cross-account access', async () => {
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Private', method: 'manual' }, 201);
const a = await f.run({ data: { name: 'today' } });
const blocked = await f.run({ type: 3, data: { custom_id: control(a, 'Choose a habit'), values: [h.id] } }, true, f.bob);
expect(blocked.data.content).toContain('belongs to another user'); expect(blocked.data.flags).toBe(64);
const blocked = await f.run({ type: 3, data: { custom_id: control(a, 'Details'), values: [h.id] } }, true, f.bob);
expect(messageText(blocked)).toContain('belongs to another user'); expect(blocked.data.flags).toBe(64);
const b = await f.run({ data: { name: 'today' } }, false, f.bob);
expect(b.data.content).not.toContain('Private');
expect(messageText(b)).not.toContain('Private');
});
test('old daily buttons cannot correct history accidentally', async () => {
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
const t = await f.run({ data: { name: 'today' } });
const d = await f.run({ type: 3, data: { custom_id: control(t, 'Choose a habit'), values: [h.id] } });
const d = await f.run({ type: 3, data: { custom_id: control(t, 'Details'), values: [h.id] } });
f.setDiscordTime('2026-09-05T12:00:00Z');
expect((await f.run({ type: 3, data: { custom_id: control(d, 'Mark complete') } })).data.content).toContain('expired');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);

View File

@@ -13,7 +13,7 @@ import { manageHabits, manageTasks, managementAction } from './management';
import { history, historyAction } from './history';
import { preferences, preferencesAction } from './preferences';
import { progress, progressAction, type DeferredMessage, type ProgressOperations } from './progress';
import { message } from './ui';
import { message, screen, button, row } from './ui';
import { help } from './help';
import { commandGraphicAction } from './command-graphics';
@@ -24,12 +24,13 @@ export type DeferredDispatch = {
schedule?: (job: () => Promise<void>) => void;
};
/** Edit the deferred original exactly once. Never retry an uncertain network result. */
export async function completeDeferred(i: import('./types').Interaction, result: DeferredMessage) {
export async function completeDeferred(i: import('./types').Interaction, result: DeferredMessage, request: (url: string, init: RequestInit) => Promise<Response> = fetch) {
const body = new FormData();
const payload = { ...result.message, flags: undefined, allowed_mentions: { parse: [] }, attachments: result.image ? [{ id: 0, filename: 'mina-progress.png', description: (result.imageDescription ?? result.message.content)?.slice(0, 1024) }] : [] };
const native = !!(result.message.flags && (result.message.flags & 32768));
const payload = { ...result.message, flags: native ? 32768 : undefined, ...(native ? { content: null, embeds: [] } : {}), allowed_mentions: { parse: [] }, attachments: result.image ? [{ id: 0, filename: 'mina-progress.png', description: (result.imageDescription ?? result.message.content)?.slice(0, 1024) }] : [] };
body.set('payload_json', JSON.stringify(payload));
if (result.image) body.set('files[0]', new Blob([new Uint8Array(result.image)], { type: 'image/png' }), 'mina-progress.png');
const response = await fetch(`https://discord.com/api/v10/webhooks/${i.application_id}/${encodeURIComponent(i.token!)}/messages/@original`, { method: 'PATCH', body, redirect: 'error', signal: AbortSignal.timeout(15000) });
const response = await request(`https://discord.com/api/v10/webhooks/${i.application_id}/${encodeURIComponent(i.token!)}/messages/@original`, { method: 'PATCH', body, redirect: 'error', signal: AbortSignal.timeout(15000) });
if (!response.ok) throw new Error('Deferred Discord response was rejected.');
}
export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now = Date.now, changed: (userId: string) => void = () => {}, dispatch: DeferredDispatch = {}) {
@@ -67,6 +68,7 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
const controls = new ControlStore(db, userId, now()); controls.prune();
const ctx: Context = { db, service, controls, interaction: i, now: now(), origin: config.origin, remindersAvailable: config.remindersAvailable ?? false, changed: () => changed(userId), progress: dispatch.progress, defer: i.token ? job => { pending = job; } : undefined };
let result: Reply;
let ownedAction: string | undefined;
try {
if (i.type === 4) result = { type: 8, data: { choices: [] } };
else if (i.type === 2 && i.data?.name === 'tasks') result = manageTasks(ctx);
@@ -77,11 +79,14 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
else if (i.type === 2 && i.data?.name === 'history') result = history(ctx);
else if ([3, 5].includes(i.type)) {
const state = controls.read(i.data?.custom_id ?? '');
ownedAction = state.action;
result = commandGraphicAction(ctx, state.action, state.payload) ?? progressAction(ctx, state.action, state.payload) ?? preferencesAction(ctx, state.action, state.payload) ?? historyAction(ctx, state.action, state.payload) ?? dailyAction(ctx, state.action, state.payload) ?? managementAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false);
} else result = message(ctx, 'Run /help to see available commands.', [], false);
} catch (error) {
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;
result = message(ctx, error instanceof ApiError ? error.message : 'Check the entered values and try again.', [], false);
const reason = error instanceof ApiError ? error.message : 'Check the entered values and try again.';
const home = ownedAction?.startsWith('manage:') ? ['Back to habits', 'manage:list'] : ownedAction?.startsWith('prefs.') ? ['Reminders', 'prefs.list'] : ownedAction?.startsWith('progress') ? ['Back to private progress', 'progress'] : ownedAction?.startsWith('history') ? ['History', 'history'] : undefined;
result = home ? screen(ctx, reason, [row(button(ctx, home[0]!, home[1]!))]) : message(ctx, reason, [], false);
}
db.insert(discordReceipts).values({ id: i.id, userId, response: result, expiresAt: now() + 86400000 }).run();
return result;

View File

@@ -28,10 +28,36 @@ export function discordFixture(setup?: boolean | ((f: ReturnType<typeof fixture>
return { ...f, alice, bob, changed, payload, requestDiscord: request, run, restart: () => { app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin, remindersAvailable }, () => time, id => changed.push(id), dispatch); }, setDiscordTime: (value: string) => { time = Date.parse(value); f.setTime(value); } };
}
export function control(reply: any, label: string): string {
const item = reply.data.components.flatMap((r: any) => r.components ?? []).find((c: any) => c.label === label || c.placeholder === label);
const item = components(reply.data.components).find((c: any) => c.label === label || c.placeholder === label);
if (!item) throw new Error(`Missing control ${label}: ${JSON.stringify(reply)}`);
return item.custom_id;
}
export function submit(custom_id: string, values: Record<string, string>) {
return { type: 5, message: { id: '123' }, data: { custom_id, components: Object.entries(values).map(([custom_id, value]) => ({ type: 1, components: [{ type: 4, custom_id, value }] })) } };
}
export function components(items: any[] = []): any[] {
return items.flatMap(item => [item, ...components(item.components), ...(item.accessory ? components([item.accessory]) : [])]);
}
export function messageText(reply: any): string {
return [reply.data.content, ...components(reply.data.components).map(item => item.content)].filter(Boolean).join('\n');
}
export function habitControl(reply: any, name: string, label = 'Details'): string {
const item = reply.data.components.find((item: any) => item.type === 17 && messageText({ data: { components: [item] } }).includes(`**${name}**`));
if (!item) throw new Error(`Missing habit ${name}: ${JSON.stringify(reply)}`);
return control({ data: { components: [item] } }, label);
}
export async function findHabitPage(f: ReturnType<typeof discordFixture>, reply: any, name: string, guild = false) {
for (let page = 0; page < 100; page++) {
if (messageText(reply).includes(`**${name}**`)) return reply;
if (!components(reply.data.components).some(c => c.label === 'Next')) break;
reply = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, 'Next') } }, guild);
}
throw new Error(`Missing habit ${name}: ${JSON.stringify(reply)}`);
}
export function taskControl(reply: any, name: string): string {
const item = components(reply.data.components).find(c => c.type === 9 && [`○ ${name}`, `✓ ${name}`].includes(c.components[0].content));
if (!item) throw new Error(`Missing task ${name}: ${JSON.stringify(reply)}`);
return item.accessory.custom_id;
}

View File

@@ -12,9 +12,9 @@ type ComponentInput = { type: number; custom_id?: string; value?: string; values
export const interactionSchema = z.object({ id: z.string().regex(/^\d{17,20}$/), application_id: z.string(), type: z.number(), token: z.string().max(2048).optional(), guild_id: z.string().optional(), user: profile.optional(), member: z.object({ user: profile }).optional(), message: z.object({ id: z.string() }).optional(), data: z.object({ name: z.string().optional(), custom_id: z.string().max(100).optional(), values: z.array(z.string()).optional(), options: z.array(option).optional(), components: z.array(component).optional() }).optional() });
export type Interaction = z.infer<typeof interactionSchema>;
export type DiscordComponent = { type: number; [key: string]: unknown };
export type Message = { content?: string; components?: DiscordComponent[]; flags?: number; allowed_mentions?: { parse: string[] }; attachments?: unknown[]; embeds?: unknown[] };
export type Message = { content?: string | null; components?: DiscordComponent[]; flags?: number; allowed_mentions?: { parse: string[] }; attachments?: unknown[]; embeds?: unknown[] };
export type Reply = { type: number; data?: Message | { custom_id: string; title: string; components: DiscordComponent[] } | { choices: { name: string; value: string }[] } };
export type ControlData = { habitId?: string; taskId?: string; date?: string; today?: boolean; page?: number; mode?: string; method?: string; expected?: string; value?: number; done?: boolean; revision?: number; index?: number; theme?: 'light' | 'dark'; deliveryId?: string; preferenceVersion?: string; timezoneVersion?: string; draft?: Record<string, string> };
export type ControlData = { dashboardPage?: number; habitId?: string; taskId?: string; date?: string; today?: boolean; page?: number; mode?: string; method?: string; expected?: string; value?: number; done?: boolean; revision?: number; index?: number; theme?: 'light' | 'dark'; deliveryId?: string; preferenceVersion?: string; timezoneVersion?: string; draft?: Record<string, string> };
export type Context = { db: AppDatabase; service: HabitService; controls: ControlStore; interaction: Interaction; now: number; origin: string; remindersAvailable?: boolean; changed: () => void; progress?: ProgressOperations; defer?: (job: () => Promise<DeferredMessage>) => void };
export function fields(i: Interaction) {
const result: Record<string, string> = {};

View File

@@ -19,3 +19,20 @@ export function pages(c: Context, action: string, data: ControlData, total: numb
if ((page + 1) * size < total) buttons.push(button(c, 'Next', action, { ...data, page: page + 1 }));
return buttons.length ? [row(...buttons)] : [];
}
/** Native Discord layouts; colors come from the existing habit settings. */
export const display = (content: string): DiscordComponent => ({ type: 10, content });
export const section = (content: string, accessory: DiscordComponent): DiscordComponent => ({ type: 9, components: [display(content)], accessory });
export const panel = (components: DiscordComponent[], color = '#285d49'): DiscordComponent => ({ type: 17, accent_color: parseInt(color.replace('#', ''), 16), components });
export function componentMessage(c: Context, components: DiscordComponent[], update = true): Reply {
const edit = update && !!c.interaction.message && [3, 5].includes(c.interaction.type);
return { type: edit ? 7 : 4, data: { components, flags: 32768 | (!edit && c.interaction.guild_id ? 64 : 0), allowed_mentions: { parse: [] }, ...(edit ? { content: null, embeds: [], attachments: [] } : {}) } };
}
/** Shared command screen using the same native panels as /today. */
export function screen(c: Context, content: string, controls: DiscordComponent[] = [], update = true, color?: string): Reply {
return componentMessage(c, [panel([display(content), ...controls], color)], update);
}
export const listSize = 6;
export const listPage = (page: number | undefined, total: number, size = listSize) => Math.max(0, Math.min(Math.trunc(page ?? 0), Math.max(0, Math.ceil(total / size) - 1)));
export const pageLabel = (page: number, total: number, size = listSize) => `Page ${page + 1} of ${Math.max(1, Math.ceil(total / size))}`;

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

@@ -326,7 +326,7 @@ export class HabitService {
const ratio = due ? Math.min(1, value / target!) : 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 };
}
@@ -409,15 +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.map(detail => {
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,4 +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";
@@ -30,6 +31,9 @@ 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),

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)

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,4 +1,6 @@
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";
const ArchivedHabits = deferred(() => import("../components/ArchivedHabits").then(module => module.ArchivedHabits));
@@ -9,12 +11,10 @@ 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";
const HabitForm = deferred(() => import("../components/HabitForm").then(module => module.HabitForm));
@@ -30,39 +30,41 @@ import type { Schedule } from "../habits/contracts";
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 ? (
<CalendarCacheProvider key={`${user.id}:${user.timezone}`}><Dashboard user={user} onExpired={retry} /></CalendarCacheProvider>
@@ -74,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>
<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 disabled={!online} onClick={onSignIn} />
</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>
<div className="ds-actions">
<DiscordSignInButton onClick={onSignIn} />
<ButtonLink href="#try-it">Try it below ↓</ButtonLink>
</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
@@ -141,22 +122,6 @@ 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>
</>
);
}
@@ -174,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);
@@ -230,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);
};
}, []);
@@ -366,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>
@@ -391,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>
@@ -440,8 +406,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
}}
/>
)}
{today.habits.length > 0 && (
<section
<section
className="ds-section ds-habits-section"
aria-labelledby="habits-title"
aria-busy={busy || loading}
@@ -449,18 +414,14 @@ 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
@@ -475,9 +436,9 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
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>
)}
</>
)}
</>

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,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

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