Compare commits
6 Commits
e081e9acbf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7be401d105 | ||
|
|
61001188b7 | ||
|
|
36706c6734 | ||
|
|
c9b1ba850f | ||
|
|
7a0cae8f08 | ||
|
|
ccd998ab04 |
21
README.md
21
README.md
@@ -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
|
||||
|
||||
|
||||
3
build.ts
3
build.ts
@@ -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
4
bun-env.d.ts
vendored
@@ -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;
|
||||
|
||||
5
bun.lock
5
bun.lock
@@ -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
103
docs/PWA.md
Normal 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.
|
||||
@@ -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
27
scripts/build-pwa.ts
Normal 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
17
scripts/pwa-icons.ts
Normal 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
194
scripts/pwa-smoke.ts
Normal 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 });
|
||||
}
|
||||
@@ -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")!;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
47
src/components/OverviewDayGraphic.tsx
Normal file
47
src/components/OverviewDayGraphic.tsx
Normal 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>;
|
||||
}
|
||||
196
src/components/ProgressOverview.test.tsx
Normal file
196
src/components/ProgressOverview.test.tsx
Normal 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');
|
||||
});
|
||||
187
src/components/ProgressOverview.tsx
Normal file
187
src/components/ProgressOverview.tsx
Normal 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>;
|
||||
}
|
||||
43
src/components/PwaControls.tsx
Normal file
43
src/components/PwaControls.tsx
Normal 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>}
|
||||
</>;
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" })}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ export type CalendarDay = {
|
||||
date: string;
|
||||
value: number;
|
||||
target: number;
|
||||
future?: boolean;
|
||||
state: "due" | "not-due" | "future";
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
26
src/pwa/assets.ts
Normal 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
90
src/pwa/client.ts
Normal 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
9
src/pwa/manifest.ts
Normal 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 }[];
|
||||
};
|
||||
38
src/pwa/manifest.webmanifest
Normal file
38
src/pwa/manifest.webmanifest
Normal 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
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
BIN
src/pwa/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
src/pwa/public/icon-maskable-512.png
Normal file
BIN
src/pwa/public/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
66
src/pwa/sw.js
Normal file
66
src/pwa/sw.js
Normal 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
99
src/pwa/sw.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
|
||||
@@ -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]!;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user