Compare commits
32 Commits
833d5673eb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c7ac488b5 | ||
|
|
17b3714861 | ||
|
|
1a97036112 | ||
|
|
7be401d105 | ||
|
|
61001188b7 | ||
|
|
36706c6734 | ||
|
|
c9b1ba850f | ||
|
|
7a0cae8f08 | ||
|
|
ccd998ab04 | ||
|
|
e081e9acbf | ||
|
|
b8df2001ec | ||
|
|
95f3d9a2c7 | ||
|
|
8f15419703 | ||
|
|
7c7a048b32 | ||
|
|
27beb01144 | ||
|
|
149ec36c77 | ||
|
|
c3b52e8fe1 | ||
|
|
ba049efdc8 | ||
|
|
e3e07404f8 | ||
|
|
17190219fa | ||
|
|
b0950cb4b4 | ||
|
|
82fe4a1db1 | ||
|
|
c91202bbfc | ||
|
|
03eb8d9e7a | ||
|
|
9f552bf608 | ||
|
|
b22c933fc4 | ||
|
|
9a0fc108a9 | ||
|
|
b4619dec33 | ||
|
|
31e4f3b2ef | ||
|
|
30dee55799 | ||
|
|
4b8c7dff05 | ||
|
|
85376a54b3 |
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
|
||||
|
||||
|
||||
11
build.ts
11
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 });
|
||||
@@ -10,8 +11,9 @@ const result = await Bun.build({
|
||||
outdir,
|
||||
plugins: [tailwind],
|
||||
minify: true,
|
||||
splitting: true,
|
||||
target: "bun",
|
||||
external: ["@napi-rs/canvas"],
|
||||
external: ["@napi-rs/canvas", "/fonts/browser/*"],
|
||||
sourcemap: "linked",
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||
@@ -30,3 +32,10 @@ await mkdir(path.join(outdir, "fonts"), { recursive: true });
|
||||
for (const file of ["InstrumentSerif-Regular.ttf", "NotoSans-Regular.ttf", "NotoSans-Medium.ttf", "NotoSans-SemiBold.ttf"]) {
|
||||
await copyFile(`src/assets/fonts/${file}`, path.join(outdir, "fonts", file));
|
||||
}
|
||||
|
||||
await mkdir(path.join(outdir, "fonts/browser"), { recursive: true });
|
||||
for (const file of ["InstrumentSerif-Regular", "InstrumentSerif-Italic", "NotoSans-Regular", "NotoSans-Medium", "NotoSans-SemiBold"]) {
|
||||
await copyFile(`src/assets/fonts/${file}.woff2`, path.join(outdir, `fonts/browser/${file}.woff2`));
|
||||
}
|
||||
|
||||
await buildPwa(outdir, result.outputs);
|
||||
|
||||
4
bun-env.d.ts
vendored
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=="],
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
env = false
|
||||
|
||||
[serve.static]
|
||||
plugins = ["bun-plugin-tailwind"]
|
||||
plugins = ["bun-plugin-tailwind", "./scripts/browser-fonts-plugin.ts"]
|
||||
env = "BUN_PUBLIC_*"
|
||||
|
||||
103
docs/PWA.md
Normal file
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.
|
||||
66
docs/performance.md
Normal file
66
docs/performance.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Performance improvements
|
||||
|
||||
The September 2026 audit produced twelve changes, implemented in separate backend,
|
||||
frontend, and worker worktrees and verified together before release.
|
||||
|
||||
## Changes
|
||||
|
||||
| Area | Improvement |
|
||||
| --- | --- |
|
||||
| Database reads | Fetch the latest day snapshots, tasks, and effective revisions for the requested dates. Carry-enabled counts look back to the necessary prior reset or log. |
|
||||
| Synchronization | Skip already-materialized habits and unchanged marker writes; expire tasks using an index of open occurrences. |
|
||||
| Catch-up | Materialize bounded batches with bulk task inserts, yield between batches, and serialize same-account operations until their data is ready. |
|
||||
| Calendar payloads | `compact=true` omits task lists and repeated requirements. Dated editing endpoints retain full detail. |
|
||||
| Calendar caching | Cache account-scoped ranges using persisted per-habit change versions; fetch visible months first and extend on demand. Historical edits and local midnight invalidate the appropriate data. |
|
||||
| React rendering | Preserve unchanged habit objects and callbacks; memoize calendar data and geometry. |
|
||||
| Initial JavaScript | Split the showcase and optional forms, editors, settings, and sharing interfaces into lazy-loaded modules. |
|
||||
| Browser fonts | Serve complete WOFF2 fonts separately with conditional caching; retain the original TTF files for native image rendering. |
|
||||
| Image rendering | Share a bounded queue across server renderers, deduplicate identical in-flight requests within their owner, and release completed canvas surfaces. |
|
||||
| Backups | Run SQLite snapshots, verification, durability, and replication in a subprocess, preserving startup health and overlap prevention. |
|
||||
| Reminders | Check delivery-slot eligibility before evaluating habits; revalidate preferences and progress immediately before sending. |
|
||||
| Avatars | Bound cached images/downloads, reuse concurrent downloads, and briefly cache failures. |
|
||||
|
||||
## Local measurements
|
||||
|
||||
These are synthetic measurements on a development Mac, not production capacity
|
||||
estimates. Absolute timings vary with hardware and load.
|
||||
|
||||
| Scenario | Before | After |
|
||||
| --- | ---: | ---: |
|
||||
| Dashboard service work, ten task habits with five years of history | 169.81 ms | 2.76 ms |
|
||||
| Unchanged synchronization, ten habits | 34 queries / 10 row changes | 4 queries / 0 row changes |
|
||||
| Combined year calendar, ten habits with ten tasks each | 10,693,864 JSON bytes | 1,267,884 compact JSON bytes |
|
||||
| Catching up a year, ten habits with ten tasks each | ~1.17 s without yielding | ~1.11 s total; 112 timer ticks, largest gap 11.75 ms |
|
||||
| Initial calendars, ten new manual habits | 1,947,301 JSON characters | 476,954 JSON characters |
|
||||
| Unchanged focus refresh, same ten habits | 11 requests / 10 calendar requests | 1 request / 0 calendar requests |
|
||||
| Switching identical habit filters | 920 `Date.parse` calls | 0 |
|
||||
| Initial JavaScript, including static dependencies | 716,094 bytes | 286,641 bytes |
|
||||
| Initial JavaScript, locally gzip-compressed | 199,622 bytes | 99,192 bytes |
|
||||
| Main stylesheet | 273,268 bytes | 85,733 bytes |
|
||||
| Main stylesheet, locally gzip-compressed | 106,410 bytes | 13,636 bytes |
|
||||
| Fifty already-delivered reminder checks | 210 ms / 1,000 day evaluations | 14 ms / 0 day evaluations |
|
||||
| Three renders with the same avatar URL | 3 downloads | 1 download |
|
||||
| Sixteen identical concurrent image renders: timer delay | 68 ms | 1.1 ms |
|
||||
| ~98 MiB replicated backup: main-thread timer delay | 421 ms | 1.9 ms |
|
||||
|
||||
Calendar byte/character counts describe serialized JSON, not compressed wire
|
||||
traffic. JavaScript/CSS gzip sizes are local compression estimates. Image RSS
|
||||
measurements concern transient native allocations, not memory leaks.
|
||||
|
||||
## Operation and verification
|
||||
|
||||
The application remains one Bun process with SQLite/WAL and one set of Discord
|
||||
delivery workers. The backup subprocess owns a separate connection. Render
|
||||
admission defaults to two active jobs, sixteen total pending jobs, and two jobs
|
||||
per account; an overloaded renderer returns a retryable busy response.
|
||||
|
||||
Migrations add a persisted calendar revision and a partial index for open task
|
||||
occurrences. Existing historical requirements, carryover rules, privacy boundaries,
|
||||
rate-limit handling, and confirmed-deleted-message replacement remain covered by
|
||||
regression tests.
|
||||
|
||||
Run `bun run typecheck`, `bun test`, and `bun run test:smoke`. The smoke test uses
|
||||
a disposable production database. Browser verification should cover lazy route
|
||||
and dialog loading, count/task saves, historical corrections, calendar ranges,
|
||||
both themes, narrow layouts, and separately served fonts. Timing tests should
|
||||
measure event-loop availability as well as total job duration.
|
||||
1
drizzle/0013_habit_calendar_revision.sql
Normal file
1
drizzle/0013_habit_calendar_revision.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE habits ADD COLUMN calendar_revision integer NOT NULL DEFAULT 0;
|
||||
1
drizzle/0014_open_occurrences.sql
Normal file
1
drizzle/0014_open_occurrences.sql
Normal file
@@ -0,0 +1 @@
|
||||
CREATE INDEX task_occurrences_open_idx ON task_occurrences (day_id) WHERE closed_at IS NULL;
|
||||
1225
drizzle/meta/0013_snapshot.json
Normal file
1225
drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1233
drizzle/meta/0014_snapshot.json
Normal file
1233
drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,20 @@
|
||||
"when": 1788847978168,
|
||||
"tag": "0012_discord_commands",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1788860000000,
|
||||
"tag": "0013_habit_calendar_revision",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1788860000001,
|
||||
"tag": "0014_open_occurrences",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { databasePath } from "../src/db/config";
|
||||
import { backupConfig, createBackup } from "../src/ops/backups";
|
||||
const db = new Database(databasePath, { readonly: true, strict: true });
|
||||
// SQLite needs writable WAL bookkeeping when the first snapshot opens a cold database.
|
||||
// Only snapshot SQL runs here; never create a missing source file.
|
||||
const db = new Database(databasePath, { readwrite: true, create: false, strict: true });
|
||||
try { console.log(createBackup(db, backupConfig(databasePath))); } finally { db.close(); }
|
||||
|
||||
9
scripts/browser-fonts-plugin.ts
Normal file
9
scripts/browser-fonts-plugin.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { BunPlugin } from "bun";
|
||||
|
||||
/** Match the production bundler's external font URLs in the HTML dev server. */
|
||||
export default {
|
||||
name: "external-browser-fonts",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^\/fonts\/browser\// }, args => ({ path: args.path, external: true }));
|
||||
},
|
||||
} satisfies BunPlugin;
|
||||
27
scripts/build-pwa.ts
Normal file
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`);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { browserFont } from "../src/browser-fonts";
|
||||
// Isolated browser QA server. Never opens or changes the user's database.
|
||||
import { sendDueReminders } from "../src/reminders/worker";
|
||||
import type { DiscordFetch } from "../src/sharing/routes";
|
||||
@@ -55,6 +56,7 @@ const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
routes: {
|
||||
"/fonts/browser/*": browserFont,
|
||||
"/__preview/reminder": () => new Response(reminderMessage ?? "No reminder sent"),
|
||||
"/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }),
|
||||
"/__preview/login": () =>
|
||||
|
||||
17
scripts/pwa-icons.ts
Normal file
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 });
|
||||
}
|
||||
18
scripts/scheduled-backup.ts
Normal file
18
scripts/scheduled-backup.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { createBackup, latestBackupTime, type BackupConfig } from '../src/ops/backups';
|
||||
|
||||
const path = process.argv[2];
|
||||
if (!path || !process.argv[3]) throw new Error('Expected database path and backup configuration');
|
||||
const config = JSON.parse(process.argv[3]) as BackupConfig;
|
||||
const latest = latestBackupTime(config);
|
||||
if (latest && Date.now() - latest < config.intervalMs) console.log(JSON.stringify({ timestamp: latest, created: false }));
|
||||
else {
|
||||
// SQLite needs writable WAL bookkeeping when the first snapshot opens a cold database.
|
||||
// Only snapshot SQL runs here; never create a missing source file.
|
||||
const db = new Database(path, { readwrite: true, create: false, strict: true });
|
||||
try {
|
||||
db.exec('PRAGMA busy_timeout=5000');
|
||||
createBackup(db, config);
|
||||
console.log(JSON.stringify({ timestamp: latestBackupTime(config), created: true }));
|
||||
} finally { db.close(); }
|
||||
}
|
||||
133
src/App.test.tsx
133
src/App.test.tsx
@@ -91,6 +91,12 @@ function LocationProbe() {
|
||||
</>;
|
||||
}
|
||||
|
||||
async function settleDeferred() {
|
||||
for (let attempt = 0; attempt < 100 && [...container.querySelectorAll('[role="status"]')].some(node => node.textContent === "Loading…"); attempt++) {
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 1)); });
|
||||
}
|
||||
}
|
||||
|
||||
async function render(path = "/design-system") {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
@@ -100,6 +106,7 @@ async function render(path = "/design-system") {
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
await settleDeferred();
|
||||
}
|
||||
|
||||
function connectAccount() {
|
||||
@@ -131,6 +138,7 @@ describe("homepage", () => {
|
||||
const opener = buttonNamed("Settings");
|
||||
opener.focus();
|
||||
await act(async () => opener.click());
|
||||
await settleDeferred();
|
||||
const dialog = container.querySelector<HTMLDialogElement>("dialog#account-settings")!;
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||
@@ -158,6 +166,7 @@ describe("homepage", () => {
|
||||
expect(inner.open).toBe(true);
|
||||
expect(inner.querySelector<HTMLButtonElement>('[aria-label="Close dialog"]')?.disabled).toBe(true);
|
||||
await act(async () => buttonNamed("Finish save").click());
|
||||
await settleDeferred();
|
||||
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
|
||||
expect(container.querySelector("#inner")).toBeNull();
|
||||
expect(container.querySelector<HTMLDialogElement>("#outer")?.open).toBe(true);
|
||||
@@ -175,10 +184,13 @@ describe("homepage", () => {
|
||||
await f.request(`/habits/${h.id}`, "DELETE");
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Archived habits").click());
|
||||
await settleDeferred();
|
||||
expect(buttonNamed("Restore Reading")).toBeDefined();
|
||||
await act(async () => buttonNamed("View history").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
|
||||
await act(async () => buttonNamed("Restore Reading").click());
|
||||
await settleDeferred();
|
||||
expect(container.textContent).toContain("No archived habits.");
|
||||
expect((await f.json("/today")).habits[0].name).toBe("Reading");
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-03`)).complete).toBe(true);
|
||||
@@ -190,12 +202,16 @@ describe("homepage", () => {
|
||||
await f.request(`/habits/${habit.id}`, "DELETE");
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Archived habits").click());
|
||||
await settleDeferred();
|
||||
await act(async () => buttonNamed("Delete permanently Discard me").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("This cannot be undone.");
|
||||
expect(document.activeElement).toBe(buttonNamed("Keep habit"));
|
||||
await act(async () => buttonNamed("Keep habit").click());
|
||||
await settleDeferred();
|
||||
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
|
||||
await act(async () => buttonNamed("Delete permanently Discard me").click());
|
||||
await settleDeferred();
|
||||
const confirm = () => container.querySelector<HTMLButtonElement>("#delete-archived-habit .ds-action-row button:last-child")!;
|
||||
fetchMock.mockResolvedValueOnce(Response.json({ error: "Temporary failure" }, { status: 500 }));
|
||||
await act(async () => confirm().click());
|
||||
@@ -210,7 +226,7 @@ describe("homepage", () => {
|
||||
|
||||
test("historical check-ins use the selected date's tracking method and preserve today", async () => {
|
||||
const f = connectAccount();
|
||||
f.setTime("2026-09-03T12:00:00Z");
|
||||
f.setTime("2026-01-03T12:00:00Z");
|
||||
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||
f.setTime("2026-09-04T12:00:00Z");
|
||||
await f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" });
|
||||
@@ -220,19 +236,29 @@ describe("homepage", () => {
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label^="2026-09-03:"]')!.click());
|
||||
expect(buttonNamed("Edit selected check-in").closest(".ds-date-inspector")?.textContent).toContain("September 3, 2026");
|
||||
await act(async () => buttonNamed("Edit selected check-in").click());
|
||||
await settleDeferred();
|
||||
const past = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!;
|
||||
expect(past).toBeDefined();
|
||||
await act(async () => past.click());
|
||||
expect((await f.json(`/habits/${habit.id}/days/2026-09-03`)).complete).toBe(true);
|
||||
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).value).toBe(0);
|
||||
expect(container.textContent).toContain("Saved check-in for 2026-09-03");
|
||||
fetchMock.mockClear();
|
||||
await act(async () => {
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="date"]')!;
|
||||
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "2026-01-03");
|
||||
input.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event);
|
||||
});
|
||||
expect(container.querySelector('[aria-label^="2026-01-03:"]')).not.toBeNull();
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).includes("from=2025-11-01") && String(url).includes("compact=true"))).toBe(true);
|
||||
});
|
||||
|
||||
test("signed-out visitors can try progress without calling private habit APIs", async () => {
|
||||
await render("/");
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm.");
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Track your habits");
|
||||
expect(fetchMock.mock.calls.map(call => call[0])).toEqual(["/api/me"]);
|
||||
await act(async () => buttonNamed("Increase glasses of water").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector<HTMLInputElement>('[aria-label="Total glasses of water"]')?.value).toBe("4");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -252,7 +278,7 @@ describe("homepage", () => {
|
||||
let resolve!: (response: Response) => void;
|
||||
fetchMock.mockImplementation(Object.assign(() => new Promise<Response>(done => { resolve = done; }), { preconnect: fetch.preconnect }));
|
||||
await render("/");
|
||||
expect(container.textContent).toContain("Getting things ready");
|
||||
expect(container.textContent).toContain("Loading account");
|
||||
expect(container.querySelector("#welcome-title")).toBeNull();
|
||||
await act(async () => resolve(new Response(null, { status: 401 })));
|
||||
expect(container.querySelector("#welcome-title")).not.toBeNull();
|
||||
@@ -264,6 +290,7 @@ describe("homepage", () => {
|
||||
expect(container.textContent).toContain("Account unavailable");
|
||||
expect(container.querySelector("#welcome-title")).toBeNull();
|
||||
await act(async () => buttonNamed("Try again").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector("#welcome-title")).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -271,8 +298,9 @@ describe("homepage", () => {
|
||||
const f = connectAccount();
|
||||
await render("/");
|
||||
expect(container.textContent).toContain("Welcome back, alice.");
|
||||
expect(container.textContent).toContain("Start with one habit.");
|
||||
expect(container.textContent).toContain("No habits yet.");
|
||||
await act(async () => buttonNamed("Add a habit +").click());
|
||||
await settleDeferred();
|
||||
const input = container.querySelector<HTMLInputElement>("form input")!;
|
||||
expect(document.activeElement).toBe(input);
|
||||
await act(async () => {
|
||||
@@ -285,6 +313,7 @@ describe("homepage", () => {
|
||||
expect(container.textContent).not.toContain("Read a little created.");
|
||||
expect(container.querySelector(".ds-habit-chart")).not.toBeNull();
|
||||
await act(async () => buttonNamed("Sign out").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector("#welcome-title")).not.toBeNull();
|
||||
expect((await f.request("/me")).status).toBe(401);
|
||||
});
|
||||
@@ -310,12 +339,14 @@ describe("homepage", () => {
|
||||
expect(container.querySelectorAll('.ds-goal--tasks .ds-routine-items > li')).toHaveLength(1);
|
||||
|
||||
await act(async () => buttonNamed("Increase Water").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3");
|
||||
expect(container.querySelectorAll(".ds-goal-graph .ds-calendar")).toHaveLength(3);
|
||||
expect(countGoal.querySelector(".ds-goal-status")?.textContent).toBe("Complete");
|
||||
expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)?.textContent).toContain("8 of 8 glasses");
|
||||
expect(container.textContent).not.toContain("Sunday walk");
|
||||
await act(async () => buttonNamed("All habits · 4").click());
|
||||
await settleDeferred();
|
||||
const checkbox = (name: string) => [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => (input.getAttribute("aria-label") || input.closest("label")?.textContent) === name)!;
|
||||
expect(checkbox("Sunday walk done").disabled).toBe(true);
|
||||
await act(async () => checkbox("Reading done").click());
|
||||
@@ -323,9 +354,81 @@ describe("homepage", () => {
|
||||
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("3 / 3");
|
||||
expect((await f.json("/today")).completed).toBe(3);
|
||||
await act(async () => buttonNamed("Decrease Water").click());
|
||||
await settleDeferred();
|
||||
expect((await f.json("/today")).completed).toBe(2);
|
||||
});
|
||||
|
||||
test("focus retains unchanged calendars, detects remote historical edits and loads expanded history on demand", async () => {
|
||||
const f = connectAccount();
|
||||
f.setTime("2026-09-03T12:00:00Z");
|
||||
const reading = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||
await f.json("/habits", "POST", { name: "Walking", method: "manual" }, 201);
|
||||
f.setTime("2026-09-04T12:00:00Z");
|
||||
await render("/");
|
||||
const calendarRequests = () => fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?"));
|
||||
expect(calendarRequests()).toHaveLength(2);
|
||||
expect(calendarRequests()[0]).toContain("from=2026-07-01");
|
||||
expect(calendarRequests()[0]).toContain("compact=true");
|
||||
const parse = spyOn(Date, "parse");
|
||||
await act(async () => buttonNamed("All habits · 2").click());
|
||||
await settleDeferred();
|
||||
expect(parse).not.toHaveBeenCalled();
|
||||
parse.mockRestore();
|
||||
fetchMock.mockClear();
|
||||
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
|
||||
expect(calendarRequests()).toHaveLength(0);
|
||||
await f.json(`/habits/${reading.id}/days/2026-09-03/progress`, "PUT", { done: true });
|
||||
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
|
||||
expect(calendarRequests()).toHaveLength(1);
|
||||
expect(calendarRequests()[0]).toContain(reading.id);
|
||||
const history = container.querySelector(`#history-${reading.id}`)!;
|
||||
fetchMock.mockClear();
|
||||
await act(async () => history.querySelector<HTMLButtonElement>('[aria-label="12 months"]')!.click());
|
||||
expect(calendarRequests()).toHaveLength(1);
|
||||
expect(calendarRequests()[0]).toContain("from=2025-10-01");
|
||||
expect(history.querySelectorAll(".ds-day")).toHaveLength(365);
|
||||
fetchMock.mockClear();
|
||||
await act(async () => history.querySelector<HTMLButtonElement>('[aria-label="3 months"]')!.click());
|
||||
expect(calendarRequests()).toHaveLength(0);
|
||||
expect(history.querySelectorAll(".ds-day")).toHaveLength(92);
|
||||
expect(history.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
|
||||
fetchMock.mockClear();
|
||||
f.setTime("2026-09-05T12:00:00Z");
|
||||
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
|
||||
expect(calendarRequests()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("month rollover preserves a twelve-month preset and an explicit custom window", async () => {
|
||||
const f = connectAccount();
|
||||
f.setTime("2026-09-30T12:00:00Z");
|
||||
const annual = await f.json("/habits", "POST", { name: "Annual", method: "manual" }, 201);
|
||||
const custom = await f.json("/habits", "POST", { name: "Custom", method: "manual" }, 201);
|
||||
await render("/");
|
||||
const annualHistory = container.querySelector(`#history-${annual.id}`)!;
|
||||
const customHistory = container.querySelector(`#history-${custom.id}`)!;
|
||||
await act(async () => annualHistory.querySelector<HTMLButtonElement>('[aria-label="12 months"]')!.click());
|
||||
await act(async () => [...customHistory.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Custom")!.click());
|
||||
await act(async () => {
|
||||
for (const [name, value] of [["from", "2026-08-05"], ["to", "2026-09-10"]]) {
|
||||
const input = customHistory.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
|
||||
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, value);
|
||||
input.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event);
|
||||
}
|
||||
customHistory.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event);
|
||||
});
|
||||
fetchMock.mockClear();
|
||||
f.setTime("2026-10-01T12:00:00Z");
|
||||
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
|
||||
const requests = fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?"));
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests.find(url => url.includes(annual.id))).toContain("from=2025-11-01&to=2026-10-31");
|
||||
expect(requests.find(url => url.includes(custom.id))).toContain("from=2026-08-05&to=2026-09-10");
|
||||
expect(annualHistory.querySelector(".ds-calendar")?.getAttribute("data-months")).toBe("12");
|
||||
expect(annualHistory.querySelectorAll(".ds-day")).toHaveLength(365);
|
||||
expect(customHistory.querySelector(".ds-calendar")?.getAttribute("data-months")).toBe("custom");
|
||||
expect(customHistory.querySelectorAll(".ds-day")).toHaveLength(37);
|
||||
});
|
||||
|
||||
test("a pending counter save leaves other habits usable and refreshes only its history", async () => {
|
||||
const f = connectAccount();
|
||||
const water = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201);
|
||||
@@ -341,6 +444,7 @@ describe("homepage", () => {
|
||||
}, { preconnect: fetch.preconnect }));
|
||||
fetchMock.mockClear();
|
||||
await act(async () => buttonNamed("Increase Water").click());
|
||||
await settleDeferred();
|
||||
expect(buttonNamed("Increase Water").disabled).toBe(true);
|
||||
const check = container.querySelector<HTMLInputElement>('[aria-label="Reading done"]')!;
|
||||
expect(check.disabled).toBe(false);
|
||||
@@ -367,6 +471,7 @@ describe("homepage", () => {
|
||||
f.setTime("2026-09-04T12:00:00Z");
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Edit habit Water").click());
|
||||
await settleDeferred();
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit habit Water"]')!;
|
||||
expect(document.activeElement).toBe(form.querySelector("input"));
|
||||
await act(async () => {
|
||||
@@ -384,11 +489,15 @@ describe("homepage", () => {
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
expect(container.querySelector("h3")?.textContent).toContain("Daily water");
|
||||
await act(async () => buttonNamed("Archive habit Daily water").click());
|
||||
await settleDeferred();
|
||||
expect((await f.json("/habits")).habits).toHaveLength(1);
|
||||
await act(async () => buttonNamed("Cancel").click());
|
||||
await settleDeferred();
|
||||
expect((await f.json("/habits")).habits).toHaveLength(1);
|
||||
await act(async () => buttonNamed("Archive habit Daily water").click());
|
||||
await settleDeferred();
|
||||
await act(async () => buttonNamed("Archive habit").click());
|
||||
await settleDeferred();
|
||||
expect((await f.json("/habits")).habits).toHaveLength(0);
|
||||
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
||||
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
|
||||
@@ -407,6 +516,7 @@ describe("homepage", () => {
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${task.id}`, "PUT", { done: true });
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Edit task Stretch").click());
|
||||
await settleDeferred();
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Stretch"]')!;
|
||||
await act(async () => {
|
||||
const input = form.querySelector("input")!;
|
||||
@@ -419,7 +529,9 @@ describe("homepage", () => {
|
||||
expect(saved.schedule).toEqual({ type: "weekdays", days: [4, 5] });
|
||||
expect((await f.json("/today")).habits[0].value).toBe(1);
|
||||
await act(async () => buttonNamed("Delete task Stretch gently").click());
|
||||
await settleDeferred();
|
||||
await act(async () => buttonNamed("Delete task").click());
|
||||
await settleDeferred();
|
||||
const today = (await f.json("/today")).habits[0];
|
||||
expect(today.target).toBe(1);
|
||||
expect(today.value).toBe(0);
|
||||
@@ -435,9 +547,11 @@ describe("homepage", () => {
|
||||
await render("/");
|
||||
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
||||
await act(async () => buttonNamed("All habits · 1").click());
|
||||
await settleDeferred();
|
||||
expect(container.textContent).toContain("Not scheduled today");
|
||||
expect(container.querySelector<HTMLInputElement>('.ds-editable-task input[type="checkbox"]')?.disabled).toBe(true);
|
||||
await act(async () => buttonNamed("Edit task Walk").click());
|
||||
await settleDeferred();
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
|
||||
await act(async () => {
|
||||
const input = form.querySelector("input")!;
|
||||
@@ -477,6 +591,7 @@ describe("homepage", () => {
|
||||
{ type: "daily" },
|
||||
] as const) {
|
||||
await act(async () => buttonNamed("Edit task Walk").click());
|
||||
await settleDeferred();
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
|
||||
await selectValue(form.querySelector("select")!, schedule.type);
|
||||
if (schedule.type === "interval" || schedule.type === "weekly") {
|
||||
@@ -489,6 +604,7 @@ describe("homepage", () => {
|
||||
if (schedule.type === "interval") expect(buttonNamed("All habits · 1").getAttribute("aria-pressed")).toBe("true");
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
await act(async () => buttonNamed("Edit task Walk").click());
|
||||
await settleDeferred();
|
||||
const reopened = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
|
||||
expect(reopened.querySelector("select")?.value).toBe(schedule.type);
|
||||
if (schedule.type === "interval" || schedule.type === "weekly") {
|
||||
@@ -496,6 +612,7 @@ describe("homepage", () => {
|
||||
expect(reopened.querySelector<HTMLInputElement>('input[type="date"]')?.value).toBe(schedule.anchor);
|
||||
}
|
||||
await act(async () => buttonNamed("Cancel").click());
|
||||
await settleDeferred();
|
||||
}
|
||||
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
|
||||
expect(previous.tasks[0].done).toBe(true);
|
||||
@@ -515,17 +632,20 @@ describe("homepage", () => {
|
||||
};
|
||||
const submit = async () => act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||
await act(async () => buttonNamed("Edit habit Reading").click());
|
||||
await settleDeferred();
|
||||
await selectMethod("count");
|
||||
await act(async () => container.querySelector<HTMLInputElement>('form input[type="checkbox"]')!.click());
|
||||
await submit();
|
||||
expect((await f.json(`/habits/${habit.id}`)).method).toBe("count");
|
||||
expect((await f.json(`/habits/${habit.id}`)).carryPartialProgress).toBe(true);
|
||||
await act(async () => buttonNamed("Edit habit Reading").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector<HTMLInputElement>('form input[type="checkbox"]')?.checked).toBe(true);
|
||||
await selectMethod("tasks");
|
||||
await submit();
|
||||
expect((await f.json(`/habits/${habit.id}`)).method).toBe("tasks");
|
||||
await act(async () => buttonNamed("Add task").click());
|
||||
await settleDeferred();
|
||||
const form = container.querySelector<HTMLFormElement>('form[aria-label="Add task"]')!;
|
||||
await act(async () => {
|
||||
const input = form.querySelector("input")!;
|
||||
@@ -545,16 +665,19 @@ describe("homepage", () => {
|
||||
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||
await render("/");
|
||||
await act(async () => buttonNamed("Archive habit Reading").click());
|
||||
await settleDeferred();
|
||||
fetchMock.mockImplementationOnce((async (input, init) => {
|
||||
return f.request(String(input).replace("/api", ""), init?.method);
|
||||
}) as typeof fetch);
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }));
|
||||
await act(async () => buttonNamed("Archive habit").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh");
|
||||
expect(buttonNamed("Archive habit Reading").disabled).toBe(true);
|
||||
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
|
||||
await act(async () => buttonNamed("Try again").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector(".ds-habit-chart")).toBeNull();
|
||||
expect(fetchMock.mock.calls.filter(call => call[1]?.method === "DELETE")).toHaveLength(1);
|
||||
});
|
||||
@@ -565,10 +688,12 @@ describe("homepage", () => {
|
||||
await render("/");
|
||||
fetchMock.mockResolvedValueOnce(Response.json({ error: "Save unavailable" }, { status: 500 }));
|
||||
await act(async () => buttonNamed("Increase Water").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save unavailable");
|
||||
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("0");
|
||||
expect(buttonNamed("Increase Water").disabled).toBe(false);
|
||||
await act(async () => buttonNamed("Increase Water").click());
|
||||
await settleDeferred();
|
||||
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("1");
|
||||
expect((await f.json("/today")).habits[0].value).toBe(1);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Navigate, Route, Routes } from "react-router";
|
||||
import { DesignSystem } from "./pages/DesignSystem";
|
||||
import { deferred } from "./components/design-system/deferred";
|
||||
const DesignSystem = deferred(() => import("./pages/DesignSystem").then(module => module.DesignSystem));
|
||||
import { Home } from "./pages/Home";
|
||||
import { AuthProvider } from "./components/AuthProvider";
|
||||
|
||||
|
||||
@@ -8,9 +8,8 @@ import { HabitService } from "../habits/service";
|
||||
export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, now: () => number) {
|
||||
const app = new Hono<AuthEnv>();
|
||||
app.use("*", auth.requireAuth);
|
||||
app.get("/export", c => {
|
||||
app.get("/export", c => HabitService.withPrepared(db, c.get("user"), now, () => {
|
||||
const user = c.get("user");
|
||||
const service = new HabitService(db, user, now()); service.sync();
|
||||
const data = db.transaction(tx => {
|
||||
const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, user.id));
|
||||
const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned));
|
||||
@@ -31,11 +30,12 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
});
|
||||
c.header("Content-Disposition", 'attachment; filename="mina-habits-export.json"');
|
||||
return c.json(data);
|
||||
});
|
||||
}));
|
||||
app.delete("/", auth.requireSameOrigin, bodyLimit({ maxSize: 1024 }), async c => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (body?.confirmation !== "DELETE") return c.json({ error: "Type DELETE to confirm account deletion." }, 422);
|
||||
const userId = c.get("user").id;
|
||||
return HabitService.withPrepared(db, c.get("user"), now, () => {
|
||||
db.transaction(tx => {
|
||||
const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, userId));
|
||||
const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned));
|
||||
@@ -50,6 +50,7 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
});
|
||||
auth.clearSession(c);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
BIN
src/assets/fonts/InstrumentSerif-Italic.woff2
Normal file
BIN
src/assets/fonts/InstrumentSerif-Italic.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/InstrumentSerif-Regular.woff2
Normal file
BIN
src/assets/fonts/InstrumentSerif-Regular.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/NotoSans-Medium.woff2
Normal file
BIN
src/assets/fonts/NotoSans-Medium.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/NotoSans-Regular.woff2
Normal file
BIN
src/assets/fonts/NotoSans-Regular.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/NotoSans-SemiBold.woff2
Normal file
BIN
src/assets/fonts/NotoSans-SemiBold.woff2
Normal file
Binary file not shown.
@@ -12,3 +12,24 @@ Downloaded September 7, 2026. The files are versioned in this repository and cop
|
||||
into the production image. Browser and native canvas renderers use the alias
|
||||
`Mina Sans`; no system sans-serif font is selected for card text. The native font
|
||||
loader fails startup if a required font cannot be registered.
|
||||
|
||||
Browser WOFF2 copies are lossless conversions of these exact TTF files using
|
||||
FontTools with Brotli; no subsetting is applied. All glyphs and Unicode mappings
|
||||
are retained (3,748 glyphs for each Noto Sans weight, 377 for each Instrument
|
||||
Serif style). Native canvas keeps using the original TTFs. CSS uses separately
|
||||
cacheable `/fonts/browser/*.woff2` URLs so font bytes do not block CSS parsing.
|
||||
|
||||
Regenerate with `uv run --with fonttools --with brotli python`:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from fontTools.ttLib import TTFont
|
||||
for path in Path("src/assets/fonts").glob("*.ttf"):
|
||||
font = TTFont(path)
|
||||
cmap, glyphs = font.getBestCmap(), font.getGlyphOrder()
|
||||
font.flavor = "woff2"
|
||||
font.save(path.with_suffix(".woff2"))
|
||||
restored = TTFont(path.with_suffix(".woff2"))
|
||||
assert restored.getBestCmap() == cmap
|
||||
assert restored.getGlyphOrder() == glyphs
|
||||
```
|
||||
|
||||
14
src/browser-fonts.test.ts
Normal file
14
src/browser-fonts.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { browserFont } from "./browser-fonts";
|
||||
test("browser fonts are external WOFF2 files with conditional caching and a fixed allowlist", async () => {
|
||||
const url = "http://localhost/fonts/browser/InstrumentSerif-Regular.woff2";
|
||||
const response = browserFont(new Request(url));
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("font/woff2");
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
expect(new TextDecoder().decode(bytes.slice(0, 4))).toBe("wOF2");
|
||||
expect(browserFont(new Request(url, { headers: { "If-None-Match": response.headers.get("ETag")! } })).status).toBe(304);
|
||||
expect(browserFont(new Request("http://localhost/fonts/browser/.env")).status).toBe(404);
|
||||
expect(browserFont(new Request("http://localhost/fonts/browser/%2e%2e%2f.env")).status).toBe(404);
|
||||
expect(await browserFont(new Request(url, { method: "HEAD" })).text()).toBe("");
|
||||
});
|
||||
18
src/browser-fonts.ts
Normal file
18
src/browser-fonts.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import path from "node:path";
|
||||
|
||||
const names = ["InstrumentSerif-Regular", "InstrumentSerif-Italic", "NotoSans-Regular", "NotoSans-Medium", "NotoSans-SemiBold"];
|
||||
const directory = process.env.NODE_ENV === "production" ? "fonts/browser" : "src/assets/fonts";
|
||||
const fonts: Map<string, { file: ReturnType<typeof Bun.file>; etag: string }> = new Map(names.map(name => {
|
||||
const file = Bun.file(path.resolve(directory, `${name}.woff2`));
|
||||
return [`${name}.woff2`, { file, etag: `"${file.size.toString(16)}-${file.lastModified.toString(16)}"` }] as const;
|
||||
}));
|
||||
|
||||
/** Explicit allowlist: font URLs never grant access to other filesystem paths. */
|
||||
export function browserFont(request: Request): Response {
|
||||
const name = new URL(request.url).pathname.slice("/fonts/browser/".length);
|
||||
const font = fonts.get(name);
|
||||
if (!font || !["GET", "HEAD"].includes(request.method)) return new Response(null, { status: 404 });
|
||||
const headers = { "Content-Type": "font/woff2", "Cache-Control": "public, max-age=3600", ETag: font.etag };
|
||||
if (request.headers.get("If-None-Match") === font.etag) return new Response(null, { status: 304, headers });
|
||||
return new Response(request.method === "HEAD" ? null : font.file, { headers });
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
|
||||
<div ref={archiveFocus} tabIndex={-1} aria-label="Archive status"><FormMessage>{notice}</FormMessage></div>
|
||||
<FormMessage error>{error}</FormMessage>
|
||||
{error && <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry archive</Button>}
|
||||
{loading ? <p className="ds-supporting-copy" role="status">Loading archive…</p> : !habits.length && <div className="ds-empty-panel"><Archive size={24} aria-hidden="true" /><div><p className="type-ui-heading">No archived habits.</p><p className="ds-supporting-copy">When you put a habit aside, its history will be waiting here.</p></div></div>}
|
||||
{loading ? <p className="ds-supporting-copy" role="status">Loading archive…</p> : !habits.length && <div className="ds-empty-panel"><Archive size={24} aria-hidden="true" /><div><p className="type-ui-heading">No archived habits.</p></div></div>}
|
||||
<div className="ds-record-list">
|
||||
{habits.map(habit => <article key={habit.habitId} className="ds-record" aria-labelledby={`archive-title-${habit.habitId}`}>
|
||||
<header className="ds-record-heading">
|
||||
@@ -71,7 +71,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
|
||||
<Button variant="text" disabled={busy || loading} aria-label={`Delete permanently ${habit.name}`} aria-haspopup="dialog" onClick={() => { setDeleteError(""); setDeleting(habit); }}><Trash2 size={16} aria-hidden="true" />Delete permanently</Button>
|
||||
</div>
|
||||
</header>
|
||||
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
|
||||
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={habit.calendarRevision ?? revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
|
||||
</article>)}
|
||||
</div>
|
||||
{deleting && <Modal id="delete-archived-habit" size="compact" eyebrow="PERMANENT DELETION" title="Delete this habit?" description={`“${deleting.name ?? "Habit"}” and all its check-ins, tasks, and progress history will be permanently deleted. It will be removed from combined charts; charts with no remaining habits will also be deleted. This cannot be undone.`} busy={busy} initialFocus="[data-cancel-delete]" returnFocus={() => archiveFocus.current} onClose={() => setDeleting(null)}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
27
src/components/CalendarCache.test.ts
Normal file
27
src/components/CalendarCache.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { afterAll, afterEach, expect, spyOn, test } from "bun:test";
|
||||
import { CalendarCache } from "./CalendarCache";
|
||||
const fetchSpy = spyOn(globalThis, "fetch");
|
||||
afterEach(() => fetchSpy.mockReset());
|
||||
afterAll(() => fetchSpy.mockRestore());
|
||||
const range = { from: "2026-07-01", to: "2026-09-30" };
|
||||
test("calendar cache shares in-flight reads, retains wider history and invalidates changed versions", async () => {
|
||||
fetchSpy.mockImplementation(Object.assign(async () => Response.json({ from: range.from, to: range.to, days: [{ date: "2026-07-01" }, { date: "2026-09-01" }] }), { preconnect: fetch.preconnect }));
|
||||
const cache = new CalendarCache();
|
||||
await Promise.all([cache.load("habit", "v1", range), cache.load("habit", "v1", range)]);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchSpy.mock.calls[0]![0])).toContain("compact=true");
|
||||
const subset = await cache.load("habit", "v1", { from: "2026-09-01", to: range.to });
|
||||
expect(subset.days).toHaveLength(1);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
await cache.load("habit", "v2", range);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
await new CalendarCache().load("habit", "v2", range);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
test("a failed history request can be retried without retaining a rejected cache entry", async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new Error("offline")).mockResolvedValue(Response.json({ ...range, days: [] }));
|
||||
const cache = new CalendarCache();
|
||||
await expect(cache.load("habit", "v1", range)).rejects.toThrow("offline");
|
||||
await expect(cache.load("habit", "v1", range)).resolves.toMatchObject({ days: [] });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
34
src/components/CalendarCache.tsx
Normal file
34
src/components/CalendarCache.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createContext, useContext, useRef, type ReactNode } from "react";
|
||||
import type { CalendarResponse } from "../shared/calendar";
|
||||
import { habitRequest } from "../lib/dashboard";
|
||||
|
||||
type Range = { from: string; to: string };
|
||||
type Entry = Range & { version: string; value: Promise<CalendarResponse> };
|
||||
/** Account-scoped, bounded cache; never survives signing out or changing accounts. */
|
||||
export class CalendarCache {
|
||||
private entries = new Map<string, Entry[]>();
|
||||
load(habitId: string, version: string, range: Range) {
|
||||
const entries = (this.entries.get(habitId) ?? []).filter(entry => entry.version === version);
|
||||
const cached = entries.find(entry => entry.from <= range.from && entry.to >= range.to);
|
||||
if (cached) return cached.value.then(value => ({ ...value, ...range, days: value.days.filter(day => day.date >= range.from && day.date <= range.to) }));
|
||||
const entry: Entry = { ...range, version, value: habitRequest<CalendarResponse>(`/habits/${habitId}/calendar?${new URLSearchParams({ ...range, compact: "true" })}`) };
|
||||
entries.push(entry);
|
||||
this.entries.delete(habitId);
|
||||
this.entries.set(habitId, entries.slice(-8));
|
||||
if (this.entries.size > 100) this.entries.delete(this.entries.keys().next().value!);
|
||||
entry.value.catch(() => { const current = this.entries.get(habitId); if (current) this.entries.set(habitId, current.filter(item => item !== entry)); });
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
const Context = createContext<CalendarCache | null>(null);
|
||||
export function CalendarCacheProvider({ children }: { children: ReactNode }) {
|
||||
const cache = useRef<CalendarCache | null>(null);
|
||||
cache.current ??= new CalendarCache();
|
||||
return <Context.Provider value={cache.current}>{children}</Context.Provider>;
|
||||
}
|
||||
export function useCalendarCache() {
|
||||
const shared = useContext(Context);
|
||||
const local = useRef<CalendarCache | null>(null);
|
||||
local.current ??= new CalendarCache();
|
||||
return shared ?? local.current;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useCalendarCache } from "./CalendarCache";
|
||||
import { Modal } from "./design-system/Modal";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import type { CalendarResponse } from "../shared/calendar";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { toHeatmapDays, type CalendarResponse } from "../shared/calendar";
|
||||
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
||||
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
|
||||
import { monthWindow } from "./design-system/calendar-model";
|
||||
import { monthWindow, type MonthView } from "./design-system/calendar-model";
|
||||
import { Button } from "./design-system/primitives";
|
||||
import { HabitChart } from "./design-system/HabitChart";
|
||||
import { CalendarLegend } from "./design-system/CalendarLegend";
|
||||
import { shade } from "../habits/calendar";
|
||||
import { CheckInEditor } from "./CheckInEditor";
|
||||
import { deferred } from "./design-system/deferred";
|
||||
const CheckInEditor = deferred(() => import("./CheckInEditor").then(module => module.CheckInEditor));
|
||||
import { Field } from "./design-system/Field";
|
||||
import { ItemActions } from "./design-system/ItemActions";
|
||||
|
||||
@@ -40,7 +42,29 @@ export function HabitHistory({
|
||||
onHistorySaved?: () => void;
|
||||
calendarOnly?: boolean;
|
||||
}) {
|
||||
const cache = useCalendarCache();
|
||||
const [rangeSelection, setRangeSelection] = useState(() => ({ date, months: 3 as MonthView | "custom", range: monthWindow(date, 3) }));
|
||||
const range = useMemo(() => {
|
||||
if (rangeSelection.date === date || rangeSelection.months === "custom") return rangeSelection.range;
|
||||
// A preset follows the current month across midnight; an explicitly inspected
|
||||
// older window and custom dates retain the user's chosen historical period.
|
||||
const previousCurrent = monthWindow(rangeSelection.date, rangeSelection.months);
|
||||
const anchor = rangeSelection.range.to < previousCurrent.to ? rangeSelection.range.to : date;
|
||||
return monthWindow(anchor, rangeSelection.months);
|
||||
}, [date, rangeSelection]);
|
||||
function requestRange(next: { from: string; to: string }, months: MonthView | "custom" = rangeSelection.months) {
|
||||
setRangeSelection({ date, months, range: next });
|
||||
}
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<string>();
|
||||
function selectDate(selected: string) {
|
||||
setSelectedDate(selected);
|
||||
const available = monthWindow(date, 12);
|
||||
if (rangeSelection.months !== "custom" && selected >= available.from && selected <= available.to && (selected < range.from || selected > range.to)) {
|
||||
const requested = monthWindow(selected, rangeSelection.months);
|
||||
requestRange({ from: requested.from < available.from ? available.from : requested.from, to: requested.to });
|
||||
}
|
||||
}
|
||||
const [editingDay, setEditingDay] = useState(false);
|
||||
const [savingDay, setSavingDay] = useState(false);
|
||||
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
|
||||
@@ -49,45 +73,27 @@ export function HabitHistory({
|
||||
const expired = useRef(onExpired);
|
||||
expired.current = onExpired;
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let active = true;
|
||||
setError("");
|
||||
const range = monthWindow(date, 12);
|
||||
habitRequest<CalendarResponse>(
|
||||
`/habits/${habit.habitId}/calendar?${new URLSearchParams(range)}`,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((result) => {
|
||||
if (!controller.signal.aborted) setCalendar(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (error.message.includes("session has expired") && expired.current)
|
||||
expired.current();
|
||||
setHistoryLoading(true);
|
||||
cache.load(habit.habitId, `${date}:${revision}:${attempt}`, range)
|
||||
.then(result => { if (active) setCalendar(result); })
|
||||
.catch(error => {
|
||||
if (!active) return;
|
||||
if (error.message.includes("session has expired") && expired.current) expired.current();
|
||||
else setError(error.message);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [habit.habitId, date, revision, attempt]);
|
||||
}).finally(() => { if (active) setHistoryLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, [cache, habit.habitId, date, revision, attempt, range.from, range.to]);
|
||||
|
||||
const days =
|
||||
calendar?.days.map((day) => ({
|
||||
date: day.date,
|
||||
color: day.color,
|
||||
unit: day.habits[0]?.unit,
|
||||
value: day.habits[0]?.value ?? 0,
|
||||
target: day.habits[0]?.target ?? 0,
|
||||
state: day.future
|
||||
? ("future" as const)
|
||||
: day.due
|
||||
? ("due" as const)
|
||||
: ("not-due" as const),
|
||||
})) ?? [];
|
||||
const days = useMemo(() => calendar ? toHeatmapDays(calendar) : [], [calendar]);
|
||||
const steps = Math.max(
|
||||
1,
|
||||
calendar?.days.find((day) => day.date === date)?.shadeCount ??
|
||||
habit.target ??
|
||||
1,
|
||||
);
|
||||
const shades = calendar
|
||||
const shades = useMemo(() => calendar
|
||||
? [
|
||||
calendar.settings.emptyColor,
|
||||
...Array.from(
|
||||
@@ -101,7 +107,7 @@ export function HabitHistory({
|
||||
).color,
|
||||
),
|
||||
]
|
||||
: [];
|
||||
: [], [calendar, steps]);
|
||||
const chart = error ? (
|
||||
<div className="ds-form-feedback">
|
||||
<p role="alert">{error}</p>
|
||||
@@ -119,17 +125,20 @@ export function HabitHistory({
|
||||
) : (
|
||||
<>
|
||||
<CalendarHeatmap
|
||||
onSelectDate={setSelectedDate}
|
||||
onSelectDate={selectDate}
|
||||
selectedDate={selectedDate}
|
||||
compact
|
||||
initialMonths={3}
|
||||
availableRange={monthWindow(date, 12)}
|
||||
onRangeChange={requestRange}
|
||||
loading={historyLoading}
|
||||
historyLabel="Your recorded progress"
|
||||
label={`${habit.name} progress calendar`}
|
||||
unit={habit.unit}
|
||||
color={calendar.settings.mainColor}
|
||||
emptyColor={calendar.settings.emptyColor}
|
||||
days={days}
|
||||
selectedDayAction={selected => <Button variant="secondary" disabled={disabled} aria-label="Edit selected check-in" aria-haspopup="dialog" onClick={() => { setSelectedDate(selected); setEditingDay(true); }}><Pencil size={16} aria-hidden="true" />Edit check-in</Button>}
|
||||
selectedDayAction={selected => <Button variant="secondary" disabled={disabled} aria-label="Edit selected check-in" aria-haspopup="dialog" onClick={() => { selectDate(selected); setEditingDay(true); }}><Pencil size={16} aria-hidden="true" />Edit check-in</Button>}
|
||||
legend={
|
||||
<CalendarLegend
|
||||
label={`${habit.name} progress calendar`}
|
||||
@@ -141,7 +150,7 @@ export function HabitHistory({
|
||||
/>
|
||||
{editingDay && <Modal id={`check-in-${habit.habitId}`} title="Edit check-in" description={habit.name ?? "Habit"} closeLabel="Close check-in editor" onClose={() => setEditingDay(false)} busy={savingDay}>
|
||||
<div className="ds-history-editor">
|
||||
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" disabled={savingDay} max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) setSelectedDate(event.target.value); }} />}</Field></div>
|
||||
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" disabled={savingDay} max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) selectDate(event.target.value); }} />}</Field></div>
|
||||
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
|
||||
onBusyChange={setSavingDay} onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
|
||||
</div></Modal>}
|
||||
|
||||
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)");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
import { habitProgressColor, themedHabitShade } from "../../lib/habit-palette";
|
||||
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { useId, useMemo, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { CalendarLegend } from "./CalendarLegend";
|
||||
import { Field } from "./Field";
|
||||
import { Button } from "./primitives";
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
calendarTimeline,
|
||||
MONTH_VIEWS,
|
||||
visibleCalendarDays,
|
||||
monthWindow,
|
||||
type CalendarDay,
|
||||
type MonthView,
|
||||
} from "./calendar-model";
|
||||
@@ -26,7 +27,13 @@ export function CalendarHeatmap({
|
||||
historyLabel,
|
||||
legend,
|
||||
selectedDayAction,
|
||||
availableRange,
|
||||
onRangeChange,
|
||||
loading = false,
|
||||
}: {
|
||||
availableRange?: { from: string; to: string };
|
||||
onRangeChange?: (range: { from: string; to: string }, months: MonthView | "custom") => void;
|
||||
loading?: boolean;
|
||||
days: CalendarDay[];
|
||||
unit: string;
|
||||
label: string;
|
||||
@@ -45,7 +52,7 @@ export function CalendarHeatmap({
|
||||
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
|
||||
const [rangeError, setRangeError] = useState("");
|
||||
const latestDate =
|
||||
allDays.findLast((day) => day.state !== "future")?.date ??
|
||||
allDays.findLast((day) => !(day.future ?? day.state === "future"))?.date ??
|
||||
allDays.at(-1)?.date;
|
||||
// Inspecting a square changes selection, not the window. Only an external
|
||||
// date outside the visible preset needs a new anchor to bring it into view.
|
||||
@@ -65,15 +72,15 @@ export function CalendarHeatmap({
|
||||
if (onSelectDate) onSelectDate(date);
|
||||
else setLocalSelectedDate(date);
|
||||
}
|
||||
const days = months === "custom" && customRange
|
||||
const days = useMemo(() => months === "custom" && customRange
|
||||
? allDays.filter((day) => day.date >= customRange.from && day.date <= customRange.to)
|
||||
: anchor ? visibleCalendarDays(allDays, months === "custom" ? 12 : months, anchor) : [];
|
||||
: anchor ? visibleCalendarDays(allDays, months === "custom" ? 12 : months, anchor) : [], [allDays, months, customRange, anchor]);
|
||||
const selected =
|
||||
days.find((day) => day.date === selectedDate) ??
|
||||
days.find((day) => day.date === anchor) ??
|
||||
days[0];
|
||||
// Date keys and the shared timeline remain stable when the chart resizes.
|
||||
const timeline = calendarTimeline(days);
|
||||
const timeline = useMemo(() => calendarTimeline(days), [days]);
|
||||
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const inspectorId = useId();
|
||||
const instructionsId = useId();
|
||||
@@ -100,6 +107,7 @@ export function CalendarHeatmap({
|
||||
<div
|
||||
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
|
||||
data-months={months}
|
||||
aria-busy={loading}
|
||||
style={{ "--calendar-weeks": timeline.weeks, "--calendar-color": color } as CSSProperties}
|
||||
>
|
||||
<div className="ds-calendar-range-toolbar">
|
||||
@@ -130,7 +138,7 @@ export function CalendarHeatmap({
|
||||
type="button"
|
||||
aria-label={`${value} ${value === 1 ? "month" : "months"}`}
|
||||
aria-pressed={months === value}
|
||||
onClick={() => setMonths(value)}
|
||||
onClick={() => { setMonths(value); if (anchor) onRangeChange?.(monthWindow(anchor, value), value); }}
|
||||
>
|
||||
{value}m
|
||||
</button>
|
||||
@@ -142,6 +150,7 @@ export function CalendarHeatmap({
|
||||
if (!customRange) setCustomRange({ from: days[0]!.date, to: days.at(-1)!.date });
|
||||
setRangeError("");
|
||||
setMonths("custom");
|
||||
onRangeChange?.(customRange ?? { from: days[0]!.date, to: days.at(-1)!.date }, "custom");
|
||||
}}
|
||||
>
|
||||
Custom
|
||||
@@ -160,7 +169,7 @@ export function CalendarHeatmap({
|
||||
const end = form.elements.namedItem("to") as HTMLInputElement;
|
||||
const isAvailable = (input: HTMLInputElement) =>
|
||||
input.validity.valid && input.value !== "" &&
|
||||
input.value >= allDays[0]!.date && input.value <= allDays.at(-1)!.date;
|
||||
input.value >= (availableRange?.from ?? allDays[0]!.date) && input.value <= (availableRange?.to ?? allDays.at(-1)!.date);
|
||||
if (!isAvailable(start) || !isAvailable(end)) {
|
||||
setRangeError("Choose both dates within the available history.");
|
||||
(!isAvailable(start) ? start : end).focus();
|
||||
@@ -171,11 +180,12 @@ export function CalendarHeatmap({
|
||||
end.focus();
|
||||
return;
|
||||
}
|
||||
if (!allDays.some((day) => day.date >= start.value && day.date <= end.value)) {
|
||||
if (!availableRange && !allDays.some((day) => day.date >= start.value && day.date <= end.value)) {
|
||||
setRangeError("There are no calendar dates in this range.");
|
||||
return;
|
||||
}
|
||||
setCustomRange({ from: start.value, to: end.value });
|
||||
onRangeChange?.({ from: start.value, to: end.value }, "custom");
|
||||
setRangeError("");
|
||||
}}
|
||||
>
|
||||
@@ -187,8 +197,8 @@ export function CalendarHeatmap({
|
||||
name={name}
|
||||
type="date"
|
||||
required
|
||||
min={allDays[0]!.date}
|
||||
max={allDays.at(-1)!.date}
|
||||
min={availableRange?.from ?? allDays[0]!.date}
|
||||
max={availableRange?.to ?? allDays.at(-1)!.date}
|
||||
defaultValue={customRange[name]}
|
||||
aria-invalid={rangeError ? true : undefined}
|
||||
aria-describedby={rangeError ? rangeErrorId : undefined}
|
||||
@@ -201,6 +211,7 @@ export function CalendarHeatmap({
|
||||
{rangeError && <p id={rangeErrorId} className="ds-calendar-range-error" role="alert">{rangeError}</p>}
|
||||
</form>
|
||||
)}
|
||||
{loading && <p role="status" className="ds-supporting-copy">Loading history…</p>}
|
||||
<div
|
||||
className="ds-calendar-viewport"
|
||||
role="group"
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
|
||||
9
src/components/design-system/deferred.tsx
Normal file
9
src/components/design-system/deferred.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { lazy, Suspense, type ComponentProps, type ComponentType } from "react";
|
||||
|
||||
/** Keep the surrounding workspace usable while an optional surface loads. */
|
||||
export function deferred<Component extends ComponentType<any>>(load: () => Promise<Component>) {
|
||||
const Surface = lazy(async () => ({ default: await load() })) as ComponentType<ComponentProps<Component>>;
|
||||
return function Deferred(props: ComponentProps<Component>) {
|
||||
return <Suspense fallback={<p className="ds-supporting-copy" role="status">Loading…</p>}><Surface {...props} /></Suspense>;
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
@@ -33,6 +34,7 @@ export const discordDeliveries = sqliteTable("discord_deliveries", {
|
||||
// Revisions are append-only, including multiple edits on the same local date.
|
||||
export const habits = sqliteTable('habits', {
|
||||
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id),
|
||||
calendarRevision: integer('calendar_revision').notNull().default(0),
|
||||
createdDate: text('created_date').notNull(), materializedThrough: text('materialized_through'), createdAt: integer('created_at').notNull(),
|
||||
}, t => [index('habits_user_idx').on(t.userId)]);
|
||||
export const habitRevisions = sqliteTable('habit_revisions', {
|
||||
@@ -47,7 +49,7 @@ export const habitDays = sqliteTable('habit_days', {
|
||||
export const taskOccurrences = sqliteTable('task_occurrences', {
|
||||
id: text('id').primaryKey(), dayId: integer('day_id').notNull().references(() => habitDays.id), taskId: text('task_id').notNull(), name: text('name').notNull(),
|
||||
done: integer('done', { mode: 'boolean' }).notNull().default(false), expiredAt: integer('expired_at'), closedAt: integer('closed_at'), updatedAt: integer('updated_at').notNull(),
|
||||
}, t => [index('task_occurrences_day_idx').on(t.dayId)]);
|
||||
}, t => [index('task_occurrences_day_idx').on(t.dayId), index('task_occurrences_open_idx').on(t.dayId).where(sql`${t.closedAt} IS NULL`)]);
|
||||
export const progressEvents = sqliteTable('progress_events', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }), dayId: integer('day_id').notNull().references(() => habitDays.id), occurrenceId: text('occurrence_id'),
|
||||
before: text('before', { mode: 'json' }).$type<unknown>().notNull(), after: text('after', { mode: 'json' }).$type<unknown>().notNull(), createdAt: integer('created_at').notNull(),
|
||||
|
||||
@@ -36,8 +36,8 @@ no signed interaction endpoint configured. Global command registration and the
|
||||
Discord interaction endpoint must be configured separately before production use.
|
||||
## Preferences
|
||||
|
||||
`preferences.ts` implements global `/settings` and `/reminders`. Reminder menus
|
||||
paginate ten named schedules at a time; entries with matching name and schedule
|
||||
`preferences.ts` implements global `/settings` and `/reminders`. Reminder panels
|
||||
paginate six named schedules at a time; entries with matching name and schedule
|
||||
are edited together as multiple comma-separated 24-hour times. Forms accept
|
||||
weekday names (or `every day`) or one ISO calendar date. Delivery is toggled for
|
||||
all schedules. Removing the last schedule disables delivery and stores an empty
|
||||
@@ -60,14 +60,15 @@ the website and reminder worker. Direct route fixtures default it to false.
|
||||
No new environment variables, migrations, outbound requests, or remote command
|
||||
registration are needed by this module. Global command registration remains an
|
||||
integration step. Successful mutations invoke the supplied live-card dirty hook.
|
||||
`progress.ts` implements `/progress`: six-habit text pages, focused private
|
||||
`progress.ts` implements `/progress`: six-habit component pages, focused private
|
||||
previews, explicit destination confirmation for snapshots, and live-image
|
||||
start/status/pause/resume. API composition injects the same `sharing.send` and
|
||||
`live.start/pause/state` operations used by website routes. Keep these shared
|
||||
instances when reconciling dispatch; do not construct a second live worker.
|
||||
|
||||
Rendering and network actions return ephemeral type-5 acknowledgements and edit
|
||||
the original interaction response once. `DeferredDispatch.complete` and
|
||||
Progress rendering and network actions acknowledge existing message interactions
|
||||
with type 6 and edit the original message once; a flow without a source message
|
||||
uses type 5 (ephemeral in servers). `DeferredDispatch.complete` and
|
||||
`schedule` are injectable for tests. Interaction tokens live only in memory,
|
||||
never receipts or controls. A process interruption after acknowledgement can
|
||||
leave the private response unfinished; restarting never replays the public send.
|
||||
@@ -116,12 +117,11 @@ showing the selected date's requirements, task completion, carryover where
|
||||
applicable, and a 14-day progress strip ending on that date. `/reminders` includes
|
||||
**View schedule card**, showing each time slot, weekday/date schedule, quiet-hour
|
||||
conflicts, and delivery status. Schedule cards retain disabled/unavailable states.
|
||||
Management forms and `/help` remain text-based because their controls already
|
||||
communicate the relevant choices.
|
||||
Management uses native component panels and forms. `/help` remains text-based.
|
||||
|
||||
These are private snapshots: **Refresh card**, **Toggle theme**, and pagination
|
||||
request a fresh owned snapshot. Regular check-ins keep their existing immediate
|
||||
text responses. Graphic actions acknowledge before rendering and attach a PNG to
|
||||
request a fresh owned snapshot. Regular check-ins use immediate
|
||||
component updates. Graphic actions acknowledge before rendering and attach a PNG to
|
||||
a new private reply; they never publish to the sharing channel or write progress.
|
||||
Each image has a text equivalent and attachment description. A render failure
|
||||
retains the text and navigation. No new command registration or configuration is
|
||||
@@ -129,3 +129,55 @@ needed after deploying this change.
|
||||
|
||||
Generate local visual review artifacts with
|
||||
`COMMAND_IMAGE_REVIEW_DIR=/tmp/minabot-command-review bun test src/discord/command-graphics.test.ts`.
|
||||
|
||||
## Interactive daily dashboard
|
||||
|
||||
`/today` uses Discord Components V2 containers with existing habit accent colors,
|
||||
a daily completion summary, and unfinished habits first. Five habits fit on each
|
||||
page within Discord's 40-component limit. Manual habits have inline completion
|
||||
and undo actions; counts have inline +1; routines open a checklist with individual
|
||||
Complete/Undo buttons. Details retain exact amounts, carryover, and amount forms.
|
||||
Checklists paginate eight tasks at a time.
|
||||
|
||||
Navigation, saves, and recoverable daily validation errors update the originating
|
||||
message (including amount-modal submissions). Updates clear legacy content,
|
||||
embeds, and attachments when upgrading an older message to V2. Server command
|
||||
replies remain ephemeral; reminder buttons open a separate private flow. Optional
|
||||
PNG cards still open as separate snapshots. Historical editors reuse the same component check-in screens while retaining
|
||||
their dated requirements.
|
||||
|
||||
The shared HabitService, owner-bound controls, stale snapshot checks, replay
|
||||
receipts, and local-midnight protections remain authoritative. No schema change,
|
||||
new API, or command registration is required. Native Discord mobile/desktop layout
|
||||
and real modal return behavior should be checked manually after deployment.
|
||||
|
||||
## Component command screens
|
||||
|
||||
`/habits`, `/history`, `/reminders`, and `/progress` share the native `screen`,
|
||||
`panel`, and `section` builders in `ui.ts`. Six entries per page leave room for
|
||||
navigation and actions within Discord's 40-component limit. Habit panels use the
|
||||
existing saved habit colors; general screens use the same Mina accent as /today.
|
||||
Each entry has an inline Manage, Open history, Manage reminder, or Preview action.
|
||||
Settings and task-management screens also use native panels so navigation from
|
||||
these commands never attempts to downgrade a V2 message.
|
||||
|
||||
History opens the selected date directly, with Previous/Next date and Change date
|
||||
controls. Its checklists paginate six tasks to leave room for date navigation.
|
||||
Manual/count/task saves still use the shared daily handlers and dated requirements.
|
||||
|
||||
Normal navigation, forms, confirmations, and recoverable errors edit the source
|
||||
message. Only validated owner controls can update it; expired or cross-account
|
||||
controls receive a separate private error. Existing dropdown controls remain
|
||||
supported until they expire. No new command registration or migration is needed.
|
||||
|
||||
Progress previews include a V2 media gallery referencing the uploaded attachment.
|
||||
The deferred PATCH preserves the V2 flag, clears legacy content/embeds, and replaces
|
||||
or removes prior attachments. Theme changes, destination confirmation, live status,
|
||||
and public-send results all stay in the private source message. Public sends still
|
||||
require explicit destination confirmation and retain the shared quotas and replay
|
||||
guards. Optional day/check-in/schedule PNG cards remain separate private snapshots.
|
||||
|
||||
`components.test.ts` checks layout limits and same-message form/navigation flows,
|
||||
plus deferred attachment transport. `progress.test.ts` covers preview/theme/back
|
||||
navigation, failures, and the existing sharing protections. Live client appearance
|
||||
still requires a Discord desktop/mobile acceptance check.
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Context } from './types';
|
||||
import type { DeferredMessage } from './progress';
|
||||
import { commandCard } from './command-graphics';
|
||||
import { renderCommandCard, commandCardSize } from './command-image';
|
||||
import { discordFixture, control, submit } from './test-fixture';
|
||||
import { discordFixture, messageText, control, submit } from './test-fixture';
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
afterEach(() => f?.close());
|
||||
function setup() {
|
||||
@@ -29,17 +29,17 @@ for (const guild of [false, true]) test(`day graphics retain private context and
|
||||
const payload = f.payload(click(control(t, 'View day card')), guild);
|
||||
expect(await (await f.requestDiscord(payload)).json()).toEqual({ type: 5, data: guild ? { flags: 64 } : {} });
|
||||
await f.requestDiscord(payload); expect(e.jobs).toHaveLength(1);
|
||||
let card = await e.drain(); expect(card.data.content).toContain('0 of 1 habits complete');
|
||||
let card = await e.drain(); expect(messageText(card)).toContain('0 of 1 habits complete');
|
||||
expect(e.outputs[0]!.image!.slice(0, 4)).toEqual(new Uint8Array([137,80,78,71]));
|
||||
expect(e.outputs[0]!.imageDescription).toContain('Today, at a glance');
|
||||
expect(f.db.select().from(progressEvents).all()).toEqual([]);
|
||||
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 12 });
|
||||
await f.run(click(control(card, 'Refresh card')), guild); card = await e.drain();
|
||||
expect(card.data.content).toContain('12/10 pages'); expect(card.data.content).toContain('1 of 1');
|
||||
expect(messageText(card)).toContain('12/10 pages'); expect(messageText(card)).toContain('1 of 1');
|
||||
t = await f.run({ data: { name: 'today' } }, guild);
|
||||
const d = await f.run(click(control(t, 'Choose a habit'), [h.id]), guild);
|
||||
const d = await f.run(click(control(t, 'Details'), [h.id]), guild);
|
||||
await f.run(click(control(d, 'View check-in card')), guild); card = await e.drain();
|
||||
expect(card.data.content).toContain('12/10 pages');
|
||||
expect(messageText(card)).toContain('12/10 pages');
|
||||
expect(f.changed).toHaveLength(0);
|
||||
});
|
||||
test('graphics retain ownership, expiry, pagination and selected historical requirements', async () => {
|
||||
@@ -48,12 +48,13 @@ test('graphics retain ownership, expiry, pagination and selected historical requ
|
||||
f.setDiscordTime('2026-09-05T12:00:00Z');
|
||||
await f.json(`/habits/${h.id}`, 'PATCH', { name: 'New target', target: 10 });
|
||||
const history = await f.run({ data: { name: 'history' } }, true);
|
||||
const form = await f.run(click(control(history, 'Choose a habit'), [h.id]), true);
|
||||
const editor = await f.run(click(control(history, 'Open history')), true);
|
||||
const form = await f.run(click(control(editor, 'Change date')), true);
|
||||
const day = await f.run(submit(form.data.custom_id, { date: '2026-09-04' }), true);
|
||||
const id = control(day, 'View check-in card');
|
||||
expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another');
|
||||
await f.run(click(id), true); const card = await e.drain();
|
||||
expect(card.data.content).toContain('Old target'); expect(card.data.content).toContain('3/4');
|
||||
expect(messageText(card)).toContain('Old target'); expect(messageText(card)).toContain('3/4');
|
||||
f.setDiscordTime('2026-09-06T12:00:00Z');
|
||||
expect((await f.run(click(id), true)).data.content).toContain('expired');
|
||||
});
|
||||
@@ -63,14 +64,14 @@ test('six-row cards paginate complete habit and reminder collections', async ()
|
||||
const lastName = (await f.json('/habits')).habits.at(-1).name;
|
||||
const today = await f.run({ data: { name: 'today' } });
|
||||
await f.run(click(control(today, 'View day card'))); let card = await e.drain();
|
||||
expect(card.data.content).toContain('Page 1/2'); expect(card.data.content).not.toContain(lastName);
|
||||
await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(card.data.content).toContain(lastName);
|
||||
expect(messageText(card)).toContain('Page 1/2'); expect(messageText(card)).not.toContain(lastName);
|
||||
await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(messageText(card)).toContain(lastName);
|
||||
f.db.insert(reminderSettings).values({ userId: 'alice', enabled: false, quietStart: '22:00', quietEnd: '08:00', updatedAt: 1,
|
||||
reminders: Array.from({ length: 8 }, (_, i) => ({ type: 'weekly' as const, days: [1, 3, 5], name: `Reminder ${i}`, time: `${String(i + 1).padStart(2, '0')}:00` })) }).run();
|
||||
const panel = await f.run({ data: { name: 'reminders' } });
|
||||
await f.run(click(control(panel, 'View schedule card'))); card = await e.drain();
|
||||
expect(card.data.content).toContain('During quiet hours'); expect(card.data.content).toContain('Mon, Wed, Fri');
|
||||
await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(card.data.content).toContain('Reminder 7');
|
||||
expect(messageText(card)).toContain('During quiet hours'); expect(messageText(card)).toContain('Mon, Wed, Fri');
|
||||
await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(messageText(card)).toContain('Reminder 7');
|
||||
});
|
||||
test('rendered cards include task progress, future/off states, weekly/date reminders and bounded image dimensions', async () => {
|
||||
setup();
|
||||
|
||||
@@ -55,7 +55,7 @@ export function commandGraphicAction(c: Context, action: string, data: ControlDa
|
||||
];
|
||||
const reply = message(c, content, [row(...controls)], false);
|
||||
c.defer(async () => {
|
||||
try { return { message: reply.data as import('./types').Message, image: await renderCommandCard(model, theme), imageDescription: `${model.title}. ${model.summary}. ${model.subtitle}. Page ${model.page + 1} of ${model.pages}.` }; }
|
||||
try { return { message: reply.data as import('./types').Message, image: await renderCommandCard(model, theme, c.service.user.id), imageDescription: `${model.title}. ${model.summary}. ${model.subtitle}. Page ${model.page + 1} of ${model.pages}.` }; }
|
||||
catch { return { message: { ...(reply.data as import('./types').Message), content: `The graphic could not be rendered.\n${content}`.slice(0, 2000) } }; }
|
||||
});
|
||||
return { type: 5, data: c.interaction.guild_id ? { flags: 64 } : {} };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { renderImage } from '../rendering/queue';
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import '../sharing/live-card-image';
|
||||
import { themePalette, type Theme } from '../lib/theme';
|
||||
@@ -7,7 +8,10 @@ export type CardRow = { label: string; detail: string; value?: string; ratio?: n
|
||||
export type CommandCard = { title: string; owner: string; subtitle: string; summary: string; note: string; rows: CardRow[]; page: number; pages: number; history?: { date: string; ratio: number | null; future: boolean; color: string }[] };
|
||||
export const commandCardSize = (data: CommandCard) => ({ width: 1200, height: 350 + Math.max(1, data.rows.length) * 116 + (data.history ? 164 : 0) + 100 });
|
||||
/** Fixed-size rows and text keep every page readable, including very large accounts. */
|
||||
export async function renderCommandCard(data: CommandCard, theme: Theme = 'light') {
|
||||
export function renderCommandCard(data: CommandCard, theme: Theme = 'light', userId = 'unscoped') {
|
||||
return renderImage('command', userId, data, theme, () => draw(data, theme));
|
||||
}
|
||||
async function draw(data: CommandCard, theme: Theme) {
|
||||
if (data.rows.length > 6 || (data.history && data.history.length > 14)) throw new Error('Paginate command cards before rendering.');
|
||||
const { width, height } = commandCardSize(data), canvas = createCanvas(width, height), ctx = canvas.getContext('2d');
|
||||
const p = themePalette[theme];
|
||||
@@ -61,5 +65,6 @@ export async function renderCommandCard(data: CommandCard, theme: Theme = 'light
|
||||
rect(64, height - 92, 1072, 1, p.rule);
|
||||
text(data.note, 64, height - 48, 21, 860, p.muted);
|
||||
text(`${data.page + 1} / ${data.pages}`, 1035, height - 48, 21, 100, p.muted);
|
||||
return new Uint8Array(await canvas.encode('png'));
|
||||
try { return new Uint8Array(await canvas.encode('png')); }
|
||||
finally { canvas.width = 1; canvas.height = 1; }
|
||||
}
|
||||
|
||||
126
src/discord/components.test.ts
Normal file
126
src/discord/components.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { components, control, discordFixture, findHabitPage, habitControl, messageText, submit } from './test-fixture';
|
||||
import { completeDeferred } from './routes';
|
||||
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
afterEach(() => f?.close());
|
||||
const click = (reply: any, label: string) => ({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label) } });
|
||||
function native(reply: any, type = 7) {
|
||||
expect(reply.type).toBe(type);
|
||||
expect(reply.data.flags & 32768).toBe(32768);
|
||||
expect(reply.data.content ?? null).toBeNull();
|
||||
expect(reply.data.embeds ?? []).toEqual([]);
|
||||
const all = components(reply.data.components);
|
||||
expect(all.length).toBeLessThanOrEqual(40);
|
||||
expect(all.filter(c => c.custom_id).map(c => c.custom_id).length).toBe(new Set(all.filter(c => c.custom_id).map(c => c.custom_id)).size);
|
||||
expect(all.filter(c => c.type === 10).map(c => c.content).join('').length).toBeLessThanOrEqual(4000);
|
||||
for (const c of all) {
|
||||
if (c.type === 1) expect(c.components.length).toBeLessThanOrEqual(5);
|
||||
if (c.type === 9) {
|
||||
expect(c.components.every((child: any) => child.type === 10)).toBe(true);
|
||||
expect(c.accessory.type).toBe(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const guild of [false, true]) test(`command lists use bounded native layouts and update paginated messages (${guild ? 'server' : 'DM'})`, async () => {
|
||||
f = discordFixture(true);
|
||||
for (let i = 0; i < 14; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual', color: '#123456' }, 201);
|
||||
await f.json('/reminders', 'PUT', { enabled: false, reminders: Array.from({ length: 20 }, (_, i) => ({ name: `Reminder ${i}`, type: 'weekly', days: [1, 3, 5], time: '12:00' })), quietStart: '22:00', quietEnd: '08:00' });
|
||||
for (const command of ['habits', 'history', 'progress', 'reminders']) {
|
||||
let list = await f.run({ data: { name: command } }, guild);
|
||||
native(list, 4);
|
||||
expect(list.data.flags & 64).toBe(guild ? 64 : 0);
|
||||
const titles = new Set<string>();
|
||||
for (let page = 0; page < 4; page++) {
|
||||
native(list, page ? 7 : 4);
|
||||
for (const section of components(list.data.components).filter(c => c.type === 9)) titles.add(section.components[0].content.split('\n')[0]);
|
||||
if (!components(list.data.components).some(c => c.label === 'Next')) break;
|
||||
list = await f.run(click(list, 'Next'), guild);
|
||||
}
|
||||
expect(titles.size).toBe(command === 'reminders' ? 20 : 14);
|
||||
native(await f.run(click(list, 'Previous'), guild));
|
||||
if (command !== 'reminders') expect(list.data.components.filter((c: any) => c.type === 17).slice(1).every((c: any) => c.accent_color === 0x123456)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('habit forms, confirmations, stale errors and recovery retain the original message', async () => {
|
||||
f = discordFixture();
|
||||
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual', color: '#abcdef' }, 201);
|
||||
let list = await f.run({ data: { name: 'habits' } }, true);
|
||||
let detail = await f.run(click(list, 'Manage'), true);
|
||||
native(detail);
|
||||
expect(detail.data.components[0].accent_color).toBe(0xabcdef);
|
||||
let form = await f.run(click(detail, 'Edit details'), true);
|
||||
detail = await f.run(submit(form.data.custom_id, { name: 'Books', color: '#123456' }), true);
|
||||
native(detail);
|
||||
expect(messageText(detail)).toContain('Books');
|
||||
const confirmation = await f.run(click(detail, 'Archive habit'), true);
|
||||
native(confirmation);
|
||||
detail = await f.run(click(confirmation, 'Confirm'), true);
|
||||
native(detail);
|
||||
list = await f.run(click(detail, 'Back to habits'), true);
|
||||
expect(messageText(list)).toContain('Archived habits');
|
||||
detail = await f.run(click(list, 'Manage'), true);
|
||||
form = await f.run(click(detail, 'Edit details'), true);
|
||||
await f.json(`/habits/${h.id}`, 'PATCH', { name: 'Website edit' });
|
||||
const failed = await f.run(submit(form.data.custom_id, { name: 'Old edit', color: '#123456' }), true);
|
||||
native(failed);
|
||||
native(await f.run(click(failed, 'Back to habits'), true));
|
||||
});
|
||||
|
||||
test('history checklists retain date controls, safe component limits and same-message saves', async () => {
|
||||
f = discordFixture();
|
||||
const h = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: Array.from({ length: 20 }, (_, i) => ({ name: `Task ${i}` })) }, 201);
|
||||
const list = await f.run({ data: { name: 'history' } });
|
||||
let detail = await f.run(click(list, 'Open history'));
|
||||
native(detail);
|
||||
detail = await f.run(click(detail, 'Next'));
|
||||
native(detail);
|
||||
expect(messageText(detail)).toContain('Page 2 of 4');
|
||||
detail = await f.run(click(detail, 'Complete'));
|
||||
native(detail);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(1);
|
||||
const form = await f.run(click(detail, 'Change date'));
|
||||
detail = await f.run(submit(form.data.custom_id, { date: '2026-09-05' }));
|
||||
native(detail);
|
||||
expect(messageText(detail)).toContain('Future dates are read-only');
|
||||
expect(components(detail.data.components).some(c => c.label === 'Complete')).toBe(false);
|
||||
});
|
||||
|
||||
test('reminder edits, validation retries and removal stay in the original message', async () => {
|
||||
f = discordFixture(true);
|
||||
let list = await f.run({ data: { name: 'reminders' } }, true);
|
||||
let form = await f.run(click(list, 'Add reminder'), true);
|
||||
const draft = { name: 'Evening', times: '99:00', days: 'every day', date: '' };
|
||||
const error = await f.run(submit(form.data.custom_id, draft), true);
|
||||
native(error);
|
||||
form = await f.run(click(error, 'Try again'), true);
|
||||
list = await f.run(submit(form.data.custom_id, { ...draft, times: '18:00' }), true);
|
||||
native(list);
|
||||
const page = await findHabitPage(f, list, 'Evening', true);
|
||||
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: habitControl(page, 'Evening', 'Manage reminder') } }, true);
|
||||
native(detail);
|
||||
const confirmation = await f.run(click(detail, 'Remove reminder'), true);
|
||||
native(confirmation);
|
||||
native(await f.run(click(confirmation, 'Confirm removal'), true));
|
||||
});
|
||||
|
||||
test('deferred native previews PATCH the original with V2 flags, gallery and attachment; text screens clear old media', async () => {
|
||||
f = discordFixture();
|
||||
const interaction = f.payload({ type: 3, message: { id: '123' } }, true);
|
||||
const requests: { url: string; payload: any; image: FormDataEntryValue | null }[] = [];
|
||||
const request = async (url: string, init: RequestInit) => {
|
||||
expect(init.method).toBe('PATCH');
|
||||
const body = init.body as FormData;
|
||||
requests.push({ url, payload: JSON.parse(String(body.get('payload_json'))), image: body.get('files[0]') });
|
||||
return Response.json({});
|
||||
};
|
||||
const gallery = { type: 12, items: [{ media: { url: 'attachment://mina-progress.png' } }] };
|
||||
await completeDeferred(interaction, { message: { flags: 32768 | 64, components: [gallery] }, image: new Uint8Array([1, 2, 3]), imageDescription: 'Progress preview' }, request);
|
||||
expect(requests[0]!.url).toEndWith('/messages/@original');
|
||||
expect(requests[0]!.payload).toMatchObject({ flags: 32768, content: null, embeds: [], components: [gallery], attachments: [{ id: 0, filename: 'mina-progress.png', description: 'Progress preview' }] });
|
||||
expect(requests[0]!.image).toBeInstanceOf(Blob);
|
||||
await completeDeferred(interaction, { message: { flags: 32768, components: [{ type: 10, content: 'Back to progress' }] } }, request);
|
||||
expect(requests[1]!.payload.attachments).toEqual([]);
|
||||
expect(requests[1]!.image).toBeNull();
|
||||
});
|
||||
@@ -1,17 +1,23 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { discordFixture, control, submit } from './test-fixture';
|
||||
import { discordFixture, components, messageText, habitControl, control, submit } from './test-fixture';
|
||||
import { discordControls, reminderSettings } from '../db/schema';
|
||||
import { defaultReminder } from '../reminders/contracts';
|
||||
import { sendDueReminders } from '../reminders/worker';
|
||||
import { reminderCheckIn } from './daily';
|
||||
import { ControlStore } from './state';
|
||||
function habitTaskControl(reply: any, name: string): string {
|
||||
const panel = reply.data.components[0];
|
||||
const section = panel.components.find((item: any) => item.type === 9 && item.components[0].content.endsWith(name));
|
||||
return section.accessory.custom_id;
|
||||
}
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
afterEach(() => f?.close());
|
||||
const click = (id: string, values?: string[]) => ({ type: 3, message: { id: '123' }, data: { custom_id: id, ...(values ? { values } : {}) } });
|
||||
async function open(habitId: string, guild = false) {
|
||||
const t = await f.run({ data: { name: 'today' } }, guild);
|
||||
return f.run(click(control(t, 'Choose a habit'), [habitId]), guild);
|
||||
const h = await f.json(`/habits/${habitId}`);
|
||||
return f.run(click(habitControl(t, h.name)), guild);
|
||||
}
|
||||
for (const guild of [false, true]) {
|
||||
test(`daily count carry, above-target, reversal and recoverable modal saves (${guild ? 'server' : 'DM'})`, async () => {
|
||||
@@ -19,18 +25,18 @@ for (const guild of [false, true]) {
|
||||
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'count', target: 10, carryPartialProgress: true }, 201);
|
||||
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 });
|
||||
f.setDiscordTime('2026-09-05T12:00:00Z');
|
||||
let d = await open(h.id, guild); expect(d.data.content).toContain('Carried from 2026-09-04');
|
||||
let d = await open(h.id, guild); expect(messageText(d)).toContain('Carried from 2026-09-04');
|
||||
d = await f.run(click(control(d, '+1')), guild);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(8);
|
||||
let m = await f.run(click(control(d, 'Enter amount')), guild);
|
||||
const invalid = await f.run(submit(m.data.custom_id, { amount: '-1' }), guild);
|
||||
expect(invalid.data.content).toContain('whole number'); expect(invalid.data.flags).toBe(guild ? 64 : undefined);
|
||||
expect(messageText(invalid)).toContain('whole number'); expect(invalid.data.flags).toBe(32768);
|
||||
m = await f.run(click(control(invalid, 'Try again')), guild);
|
||||
d = await f.run(submit(m.data.custom_id, { amount: '12' }), guild);
|
||||
expect(d.data.content).toContain('12/10');
|
||||
expect(messageText(d)).toContain('12/10');
|
||||
m = await f.run(click(control(d, 'Enter amount')), guild);
|
||||
d = await f.run(submit(m.data.custom_id, { amount: '0' }), guild);
|
||||
expect(d.data.content).toContain('Incomplete');
|
||||
expect(messageText(d)).toContain('Not started');
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(0);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(7);
|
||||
expect(f.changed).toHaveLength(3);
|
||||
@@ -46,22 +52,22 @@ for (const guild of [false, true]) {
|
||||
const h = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: Array.from({ length: 11 }, (_, n) => ({ name: `Task ${n + 1}` })) }, 201);
|
||||
d = await open(h.id, guild);
|
||||
d = await f.run(click(control(d, 'Next')), guild);
|
||||
expect(d.data.content).toContain('Task 11'); expect(d.data.content).not.toContain('Task 10');
|
||||
expect(messageText(d)).toContain('Task 11'); expect(messageText(d)).not.toContain('Task 8');
|
||||
const day = await f.json(`/habits/${h.id}/days/2026-09-04`);
|
||||
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
|
||||
d = await f.run(click(habitTaskControl(d, 'Task 11'), [day.tasks[10].taskId]), guild);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(1);
|
||||
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
|
||||
d = await f.run(click(habitTaskControl(d, 'Task 11'), [day.tasks[10].taskId]), guild);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
|
||||
// Website task changes invalidate an already-open Discord selection.
|
||||
for (const task of day.tasks) await f.json(`/habits/${h.id}/days/2026-09-04/tasks/${task.taskId}`, 'PUT', { done: true });
|
||||
const stale = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
|
||||
expect(stale.data.content).toContain('Progress changed');
|
||||
const stale = await f.run(click(habitTaskControl(d, 'Task 11'), [day.tasks[10].taskId]), guild);
|
||||
expect(messageText(stale)).toContain('Progress changed');
|
||||
d = await f.run(click(control(stale, 'Refresh')), guild);
|
||||
expect(d.data.content).toContain('11/11 tasks · Complete');
|
||||
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
|
||||
expect(messageText(d)).toContain('11/11 tasks');
|
||||
d = await f.run(click(habitTaskControl(d, 'Task 11'), [day.tasks[10].taskId]), guild);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).complete).toBe(false);
|
||||
await f.json(`/habits/${manual.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
expect((await open(manual.id, guild)).data.content).toContain('Complete');
|
||||
expect(messageText(await open(manual.id, guild))).toContain('Complete');
|
||||
});
|
||||
}
|
||||
test('habit pagination clamps after website archives', async () => {
|
||||
@@ -71,7 +77,7 @@ test('habit pagination clamps after website archives', async () => {
|
||||
const next = control(t, 'Next');
|
||||
for (const h of habits.slice(1)) await f.json(`/habits/${h.id}`, 'PATCH', { archived: true });
|
||||
const refreshed = await f.run(click(next));
|
||||
expect(refreshed.data.content).toContain('Habit 0');
|
||||
expect(messageText(refreshed)).toContain('Habit 0');
|
||||
});
|
||||
test('daily controls reject midnight rollover even within their fifteen minute lifetime', async () => {
|
||||
f = discordFixture(); f.setDiscordTime('2026-09-04T21:59:00Z');
|
||||
@@ -79,9 +85,9 @@ test('daily controls reject midnight rollover even within their fifteen minute l
|
||||
const d = await open(h.id);
|
||||
f.setDiscordTime('2026-09-04T22:00:00Z');
|
||||
const expired = await f.run(click(control(d, 'Mark complete')));
|
||||
expect(expired.data.content).toContain('day changed');
|
||||
expect(messageText(expired)).toContain('day changed');
|
||||
const fresh = await f.run(click(control(expired, 'Today')));
|
||||
expect(fresh.data.content).toContain('2026-09-05');
|
||||
expect(messageText(fresh)).toContain('2026-09-05');
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
|
||||
});
|
||||
test('reminder buttons are owner bound, open a new flow, expire at local midnight, and preserve deduplication', async () => {
|
||||
@@ -98,8 +104,8 @@ test('reminder buttons are owner bound, open a new flow, expire at local midnigh
|
||||
expect(f.db.select().from(discordControls).where(eq(discordControls.id, id.slice(5))).get()?.expiresAt).toBe(Date.parse('2026-09-04T22:00:00Z'));
|
||||
expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another user');
|
||||
for (const guild of [false, true]) {
|
||||
const flow = await f.run(click(id), guild); expect(flow.type).toBe(4); expect(flow.data.flags).toBe(guild ? 64 : undefined);
|
||||
expect(flow.data.content).toContain('Walk');
|
||||
const flow = await f.run(click(id), guild); expect(flow.type).toBe(4); expect(flow.data.flags).toBe(32768 | (guild ? 64 : 0));
|
||||
expect(messageText(flow)).toContain('Walk');
|
||||
}
|
||||
f.setDiscordTime('2026-09-04T22:00:00Z'); expect((await f.run(click(id))).data.content).toContain('expired');
|
||||
});
|
||||
@@ -110,3 +116,58 @@ test('reminder expiry follows DST boundary', () => {
|
||||
const id = control({ data: { components } }, 'Check in');
|
||||
expect(new ControlStore(f.db, 'alice', now).read(id).expiresAt).toBe(Date.parse('2026-10-25T23:00:00Z'));
|
||||
});
|
||||
|
||||
for (const guild of [false, true]) test(`inline dashboard actions update the same message and finish the day (${guild ? 'server' : 'DM'})`, async () => {
|
||||
f = discordFixture();
|
||||
const walk = await f.json('/habits', 'POST', { name: 'Walk', method: 'manual' }, 201);
|
||||
const water = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 1 }, 201);
|
||||
let t = await f.run({ data: { name: 'today' } }, guild);
|
||||
expect(t.data.content).toBeUndefined();
|
||||
expect(t.data.flags).toBe(32768 | (guild ? 64 : 0));
|
||||
const old = habitControl(t, 'Water', '+1');
|
||||
t = await f.run(click(habitControl(t, 'Walk', 'Mark complete')), guild);
|
||||
expect(t.type).toBe(7);
|
||||
expect(t.data.content).toBeNull();
|
||||
expect(messageText(t)).toContain('1/2 complete');
|
||||
expect(messageText(t).indexOf('**Water**')).toBeLessThan(messageText(t).indexOf('**Walk**'));
|
||||
t = await f.run(click(habitControl(t, 'Water', '+1')), guild);
|
||||
expect(t.type).toBe(7);
|
||||
expect(messageText(t)).toContain('All done for today');
|
||||
expect((await f.json(`/habits/${water.id}/days/2026-09-04`)).value).toBe(1);
|
||||
const stale = await f.run(click(old), guild);
|
||||
expect(stale.type).toBe(7);
|
||||
expect(messageText(stale)).toContain('Progress changed');
|
||||
t = await f.run(click(habitControl(t, 'Walk', 'Mark incomplete')), guild);
|
||||
expect(messageText(t)).not.toContain('All done for today');
|
||||
expect((await f.json(`/habits/${walk.id}/days/2026-09-04`)).complete).toBe(false);
|
||||
});
|
||||
test('native layouts stay within Discord limits and use existing habit colors', async () => {
|
||||
f = discordFixture();
|
||||
for (let n = 0; n < 12; n++) await f.json('/habits', 'POST', { name: `Habit ${n}`, method: 'manual' }, 201);
|
||||
let t = await f.run({ data: { name: 'today' } });
|
||||
for (let page = 0; page < 3; page++) {
|
||||
expect(components(t.data.components).length).toBeLessThanOrEqual(40);
|
||||
expect(t.data.components.filter((c: any) => c.type === 17).slice(1).every((c: any) => c.accent_color === 0x196127)).toBe(true);
|
||||
expect(messageText(t)).toContain(`Page ${page + 1} of 3`);
|
||||
if (page < 2) t = await f.run(click(control(t, 'Next')));
|
||||
}
|
||||
await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: Array.from({ length: 20 }, (_, n) => ({ name: `Step ${n}` })) }, 201);
|
||||
const d = await f.run(click(control(t, 'Details')));
|
||||
expect(components(d.data.components).length).toBeLessThanOrEqual(40);
|
||||
// Equal creation timestamps are ordered by random IDs, not insertion order.
|
||||
let list = await f.run({ data: { name: 'today' } });
|
||||
while (!messageText(list).includes('**Routine**') && components(list.data.components).some(c => c.label === 'Next')) {
|
||||
list = await f.run(click(control(list, 'Next')));
|
||||
}
|
||||
expect(messageText(list)).toContain('**Routine**');
|
||||
const checklist = await f.run(click(habitControl(list, 'Routine', 'Open checklist')));
|
||||
expect(components(checklist.data.components).length).toBeLessThanOrEqual(40);
|
||||
expect(messageText(checklist)).toContain('Page 1 of 3');
|
||||
});
|
||||
test('empty dashboard has no mutation controls', async () => {
|
||||
f = discordFixture();
|
||||
const t = await f.run({ data: { name: 'today' } });
|
||||
expect(messageText(t)).toContain('Nothing scheduled today');
|
||||
expect(messageText(t)).not.toContain('All done');
|
||||
expect(components(t.data.components).filter(c => c.type === 2).map(c => c.label)).toEqual(['Refresh', 'View day card']);
|
||||
});
|
||||
|
||||
@@ -6,23 +6,60 @@ import type { AppDatabase } from '../auth';
|
||||
import { progressInput } from '../habits/contracts';
|
||||
import type { Context, ControlData, Reply } from './types';
|
||||
import { fields } from './types';
|
||||
import { button, message, modal, pages, row, select, text } from './ui';
|
||||
import { button, message, modal, pages, row, select, text, componentMessage, display, panel, section } from './ui';
|
||||
const pageFor = (page: number | undefined, total: number) => Math.min(Math.max(0, Math.trunc(page ?? 0)), Math.max(0, Math.ceil(total / 10) - 1));
|
||||
const snapshot = (d: ReturnType<Context['service']['day']>) => JSON.stringify([d.value, d.revisionId, d.loggedCount, d.carriedFrom, d.tasks.map(t => [t.taskId, t.done])]);
|
||||
export function reminderCheckIn(db: AppDatabase, userId: string, timezone: string, now: number) {
|
||||
const date = localDate(now, timezone);
|
||||
return [row({ type: 2, style: 1, label: 'Check in', custom_id: new ControlStore(db, userId, now).create('today', { date, mode: 'reminder' }, endOfDay(date, timezone) - now) })];
|
||||
}
|
||||
const dashboardSize = 5;
|
||||
const taskSize = 8;
|
||||
const clampPage = (page: number | undefined, total: number, size: number) => Math.min(Math.max(0, Math.trunc(page ?? 0)), Math.max(0, Math.ceil(total / size) - 1));
|
||||
const status = (d: ReturnType<Context['service']['day']>) => d.complete ? '✓ Complete' : d.value > 0 ? '◐ In progress' : '○ Not started';
|
||||
const meter = (ratio: number) => '▰'.repeat(Math.round(Math.min(1, Math.max(0, ratio)) * 8)) + '▱'.repeat(8 - Math.round(Math.min(1, Math.max(0, ratio)) * 8));
|
||||
export function today(c: Context, data: ControlData = {}): Reply {
|
||||
if (data.date && data.date !== c.service.today) throw new ApiError(409, 'That reminder is from an earlier day. Run /today for a fresh check-in.');
|
||||
const all = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due);
|
||||
const page = pageFor(data.page, all.length), visible = all.slice(page * 10, page * 10 + 10);
|
||||
return message(c, `**Today · ${c.service.today}** · ${text(c.service.user.timezone)}\n${all.filter(h => h.complete).length}/${all.length} complete\n\n${visible.map(h => `${h.complete ? '✓' : '○'} ${text(h.name ?? 'Habit')} — ${h.value}/${h.target} ${text(h.unit ?? '')}`).join('\n') || 'No habits due. Use /habits to create or manage your habits.'}`,
|
||||
[...(visible.length ? [select(c, 'Choose a habit', 'daily.select', { date: c.service.today, today: true }, visible.map(h => ({ id: h.habitId, name: h.name ?? 'Habit' })))] : []), ...pages(c, 'today', { date: c.service.today, page }, all.length), row(button(c, 'Refresh', 'today'), button(c, 'View day card', 'graphic.today', { date: c.service.today }))], c.interaction.data?.custom_id?.startsWith('mina:') && data.mode !== 'reminder');
|
||||
const all = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due).sort((a, b) => Number(a.complete) - Number(b.complete));
|
||||
const page = clampPage(data.page, all.length, dashboardSize), complete = all.filter(d => d.complete).length;
|
||||
const components = [panel([display(`## ${all.length && complete === all.length ? '✓ All done for today' : 'Today'}\n${c.service.today} · ${text(c.service.user.timezone)}\n${all.length ? `${meter(complete / all.length)} **${complete}/${all.length} complete**` : 'Nothing scheduled today. Enjoy your day off.'}`)])];
|
||||
for (const d of all.slice(page * dashboardSize, (page + 1) * dashboardSize)) {
|
||||
const state: ControlData = { habitId: d.habitId, date: c.service.today, today: true, dashboardPage: page, expected: snapshot(d), mode: 'dashboard' };
|
||||
const primary = d.method === 'manual'
|
||||
? button(c, d.complete ? 'Mark incomplete' : 'Mark complete', 'daily.save', { ...state, done: !d.complete }, d.complete ? 2 : 3)
|
||||
: d.method === 'count' && d.value < 1_000_000_000
|
||||
? button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1)
|
||||
: button(c, d.method === 'tasks' ? 'Open checklist' : 'Edit amount', 'daily.detail', { ...state, mode: undefined }, 1);
|
||||
components.push(panel([
|
||||
section(`**${text(d.name ?? 'Habit')}**\n${status(d)} · ${d.value}/${d.target} ${text(d.unit ?? '')}`, primary),
|
||||
row(button(c, 'Details', 'daily.detail', { ...state, mode: undefined })),
|
||||
], c.service.settings(d.habitId).mainColor));
|
||||
}
|
||||
components.push(display(all.length ? `Page ${page + 1} of ${Math.max(1, Math.ceil(all.length / dashboardSize))} · Unfinished habits first` : 'Use /habits to create or manage your habits.'));
|
||||
components.push(...pages(c, 'today', { date: c.service.today, page }, all.length, dashboardSize), row(button(c, 'Refresh', 'today', { page }), button(c, 'View day card', 'graphic.today', { date: c.service.today })));
|
||||
return componentMessage(c, components, data.mode !== 'reminder');
|
||||
}
|
||||
function checkInDetail(c: Context, data: ControlData): Reply {
|
||||
const d = c.service.day(data.habitId!, data.date!);
|
||||
const size = data.mode === 'history' ? 6 : taskSize;
|
||||
const page = clampPage(data.page, d.tasks.length, size);
|
||||
const state = { ...data, mode: data.mode === 'history' ? 'history' : undefined, page, expected: snapshot(d) };
|
||||
const components = [display(`## ${text(d.name ?? 'Habit')}\n${data.date} · ${text(d.timezone)}\n${d.due ? `${meter(d.ratio ?? 0)} **${d.value}/${d.target} ${text(d.unit ?? '')}** · ${status(d)}` : 'Not scheduled on this date'}${d.carriedFrom ? `\nCarried from ${d.carriedFrom}` : ''}${d.future ? '\nFuture dates are read-only' : ''}`)];
|
||||
if (d.due && !d.future) {
|
||||
if (d.method === 'manual') components.push(row(button(c, d.complete ? 'Mark incomplete' : 'Mark complete', 'daily.save', { ...state, done: !d.complete }, d.complete ? 2 : 3)));
|
||||
if (d.method === 'count') components.push(row(...(d.value > 0 ? [button(c, '−1', 'daily.save', { ...state, value: d.value - 1 })] : []), ...(d.value < 1_000_000_000 ? [button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1)] : []), button(c, 'Enter amount', 'daily.amount', state)));
|
||||
if (d.method === 'tasks') {
|
||||
for (const task of d.tasks.slice(page * size, (page + 1) * size)) components.push(section(`${task.done ? '✓' : '○'} ${text(task.name)}`, button(c, task.done ? 'Undo' : 'Complete', 'daily.task', { ...state, taskId: task.taskId }, task.done ? 2 : 3)));
|
||||
if (d.tasks.length > size) components.push(display(`Page ${page + 1} of ${Math.ceil(d.tasks.length / size)}`));
|
||||
components.push(...pages(c, 'daily.detail', state, d.tasks.length, size));
|
||||
}
|
||||
}
|
||||
return componentMessage(c, [panel(components, c.service.settings(d.habitId).mainColor), row(button(c, 'Today', 'today', { page: data.dashboardPage }), button(c, 'Refresh', 'daily.detail', state), button(c, 'View check-in card', 'graphic.day', { ...state, page: 0 }))]);
|
||||
}
|
||||
export function detail(c: Context, data: ControlData): Reply {
|
||||
const date = data.date ?? c.service.today;
|
||||
if (data.today && date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today to check in.');
|
||||
if (data.today || data.mode === 'history') return checkInDetail(c, { ...data, date });
|
||||
const d = c.service.day(data.habitId!, date);
|
||||
data = { ...data, date, page: pageFor(data.page, d.tasks.length) };
|
||||
const state: ControlData = { ...data, expected: snapshot(d) };
|
||||
@@ -50,14 +87,14 @@ function action(c: Context, action: string, data: ControlData): Reply | undefine
|
||||
const d = c.service.day(data.habitId!, data.date!);
|
||||
if (data.expected !== snapshot(d)) throw new ApiError(409, 'Progress changed since this message was opened. Refresh it before saving.');
|
||||
if (action === 'daily.task') {
|
||||
const task = d.tasks.find(t => t.taskId === c.interaction.data?.values?.[0]);
|
||||
const task = d.tasks.find(t => t.taskId === (data.taskId ?? c.interaction.data?.values?.[0]));
|
||||
if (!task) throw new ApiError(404, 'Task not found.');
|
||||
c.service.completeTask(data.habitId!, data.date!, task.taskId, !task.done);
|
||||
} else {
|
||||
const amount = fields(c.interaction).amount;
|
||||
c.service.progress(data.habitId!, data.date!, progressInput.parse(data.done !== undefined ? { done: data.done } : { count: amount === undefined ? data.value : /^\d+$/.test(amount) ? Number(amount) : NaN }));
|
||||
}
|
||||
c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return detail(c, data);
|
||||
c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return data.mode === 'dashboard' ? today(c, { page: data.dashboardPage }) : detail(c, data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +104,7 @@ export function dailyAction(c: Context, name: string, data: ControlData): Reply
|
||||
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;
|
||||
const invalid = error instanceof ZodError;
|
||||
const recover = invalid ? button(c, 'Try again', 'daily.amount', data) : button(c, 'Refresh', data.habitId ? 'daily.detail' : 'today', data);
|
||||
if (data.today || data.mode === 'history') return componentMessage(c, [panel([display(invalid ? 'Enter a whole number from 0 to 1,000,000,000. Nothing was saved.' : error.message)]), row(recover, button(c, 'Today', 'today'))]);
|
||||
return message(c, invalid ? 'Enter a whole number from 0 to 1,000,000,000. This is the total for the date, including any carried progress. Nothing was saved.' : error.message,
|
||||
[row(recover, button(c, 'Today', 'today'))], false);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function help(guild: boolean, origin: string): Reply {
|
||||
'',
|
||||
...commands.map(command => `**/${command.name}** — ${command.description}`),
|
||||
'',
|
||||
'Start with /today, then choose a habit and use its buttons to record progress. Use the forms for amounts and other details.',
|
||||
'Start with /today and use the buttons beside each habit. Open Details to adjust an amount or undo a check-in; Open checklist lets you complete individual tasks.',
|
||||
'If a control expires or progress changes elsewhere, run the command again to get fresh controls.',
|
||||
'',
|
||||
'Personal command replies in servers are visible only to you. Sharing progress requires an explicit sharing action.',
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { control, discordFixture, submit } from './test-fixture';
|
||||
import { control, discordFixture, submit, messageText, components, habitControl, findHabitPage, taskControl } from './test-fixture';
|
||||
import { commands } from './commands';
|
||||
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
afterEach(() => f?.close());
|
||||
const items = (reply: any): any[] => reply.data.components.flatMap((r: any) => r.components ?? []);
|
||||
const items = (reply: any): any[] => components(reply.data.components);
|
||||
async function click(reply: any, label: string, guild = false, values?: string[], user?: string) {
|
||||
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), ...(values ? { values } : {}) } }, guild, user);
|
||||
}
|
||||
async function open(habitId: string, date: string, guild = false) {
|
||||
const list = await f.run({ data: { name: 'history' } }, guild);
|
||||
const form = await click(list, 'Choose a habit', guild, [habitId]);
|
||||
const h = await f.json(`/habits/${habitId}`);
|
||||
const list = await findHabitPage(f, await f.run({ data: { name: 'history' } }, guild), h.name, guild);
|
||||
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: habitControl(list, h.name, 'Open history') } }, guild);
|
||||
const form = await click(detail, 'Change date', guild);
|
||||
expect(form.type).toBe(9);
|
||||
return f.run(submit(form.data.custom_id, { date }), guild);
|
||||
}
|
||||
@@ -19,7 +21,7 @@ async function changeDate(reply: any, date: string, guild = false) {
|
||||
return f.run(submit(form.data.custom_id, { date }), guild);
|
||||
}
|
||||
function readOnly(reply: any) {
|
||||
expect(reply.data.content).toContain('Read-only');
|
||||
expect(messageText(reply)).toContain('Read-only');
|
||||
expect(items(reply).some(i => ['+1', 'Enter amount', 'Mark complete', 'Mark incomplete'].includes(i.label) || i.type === 3)).toBe(false);
|
||||
}
|
||||
|
||||
@@ -44,33 +46,33 @@ describe('/history', () => {
|
||||
await f.json(`/habits/${h.id}`, 'PATCH', { archived: true });
|
||||
|
||||
const list = await f.run({ data: { name: 'history' } }, guild);
|
||||
expect(list.data.flags).toBe(guild ? 64 : undefined);
|
||||
expect(items(list)[0].options[0].description).toContain('Archived');
|
||||
expect(list.data.flags).toBe(32768 | (guild ? 64 : 0));
|
||||
expect(messageText(list)).toContain('Archived');
|
||||
let d = await open(h.id, '2026-09-04', guild);
|
||||
expect(d.data.content).toContain('0/5 pages');
|
||||
expect(messageText(d)).toContain('0/5 pages');
|
||||
const amount = await click(d, 'Enter amount', guild);
|
||||
d = await f.run(submit(amount.data.custom_id, { amount: '4' }), guild);
|
||||
expect(d.data.content).toContain('4/5 pages');
|
||||
expect(messageText(d)).toContain('4/5 pages');
|
||||
expect(control(d, 'Change date')).toBeTruthy();
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(4);
|
||||
d = await click(d, 'Next date', guild);
|
||||
expect(d.data.content).toContain('0/20 chapters');
|
||||
expect(messageText(d)).toContain('0/20 chapters');
|
||||
d = await click(d, 'Next date', guild);
|
||||
expect(d.data.content).toContain('Checkbox');
|
||||
expect(messageText(d)).toContain('Checkbox');
|
||||
d = await click(d, 'Mark complete', guild);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-06`)).complete).toBe(true);
|
||||
d = await click(d, 'Mark incomplete', guild);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-06`)).complete).toBe(false);
|
||||
d = await click(d, 'Next date', guild);
|
||||
expect(d.data.content).toContain('Original task');
|
||||
expect(d.data.content).not.toContain('New task');
|
||||
d = await click(d, 'Choose a task to change its completion', guild, [task.id]);
|
||||
expect(messageText(d)).toContain('Original task');
|
||||
expect(messageText(d)).not.toContain('New task');
|
||||
d = await click(d, 'Complete', guild);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-07`)).tasks[0].done).toBe(true);
|
||||
d = await click(d, 'Next date', guild);
|
||||
readOnly(d);
|
||||
d = await click(d, 'Next date', guild);
|
||||
readOnly(d);
|
||||
expect(d.data.content).toContain('archived on this date');
|
||||
expect(messageText(d)).toContain('archived on this date');
|
||||
expect(f.changed).toEqual(['alice', 'alice', 'alice', 'alice']);
|
||||
expect((await f.json(`/habits/${h.id}`)).archived).toBe(true);
|
||||
});
|
||||
@@ -79,21 +81,21 @@ describe('/history', () => {
|
||||
f = discordFixture();
|
||||
const h = await f.json('/habits', 'POST', { name: 'Friday', method: 'manual', schedule: { type: 'weekdays', days: [5] } }, 201);
|
||||
let d = await open(h.id, '2026-02-30');
|
||||
expect(d.data.content).toContain('real calendar date');
|
||||
expect(messageText(d)).toContain('real calendar date');
|
||||
for (const date of ['2025-02-29', '09/04/2026', '1969-12-31', '9999-01-01']) {
|
||||
d = await changeDate(d, date);
|
||||
expect(d.data.content).toContain('real calendar date');
|
||||
expect(messageText(d)).toContain('real calendar date');
|
||||
}
|
||||
d = await changeDate(d, '2024-02-29');
|
||||
readOnly(d);
|
||||
expect(d.data.content).toContain('not tracked yet');
|
||||
expect(messageText(d)).toContain('not tracked yet');
|
||||
d = await changeDate(d, '2026-09-05');
|
||||
expect(d.data.content).toContain('Future dates are read-only');
|
||||
expect(messageText(d)).toContain('Future dates are read-only');
|
||||
expect(items(d).some(i => i.label === 'Mark complete')).toBe(false);
|
||||
f.setDiscordTime('2026-09-06T12:00:00Z');
|
||||
d = await open(h.id, '2026-09-05');
|
||||
readOnly(d);
|
||||
expect(d.data.content).toContain('Friday');
|
||||
expect(messageText(d)).toContain('Friday');
|
||||
d = await changeDate(d, '1970-01-01');
|
||||
expect(items(d).some(i => i.label === 'Previous date')).toBe(false);
|
||||
d = await changeDate(d, '9998-12-31');
|
||||
@@ -106,19 +108,18 @@ describe('/history', () => {
|
||||
for (let i = 0; i < 12; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201);
|
||||
const h = await f.json('/habits', 'POST', { name: 'Checklist', method: 'tasks', tasks: Array.from({ length: 23 }, (_, i) => ({ name: `Task ${i}` })) }, 201);
|
||||
let list = await f.run({ data: { name: 'history' } });
|
||||
expect(items(list)[0].options).toHaveLength(10);
|
||||
expect(items(list).filter(c => c.label === 'Open history')).toHaveLength(6);
|
||||
list = await click(list, 'Next');
|
||||
expect(items(list)[0].options).toHaveLength(3);
|
||||
expect((await click(list, 'Previous')).data.content).toContain('Page 1 of 2');
|
||||
const form = await click(list, 'Choose a habit', false, [h.id]);
|
||||
let d = await f.run(submit(form.data.custom_id, { date: '2026-09-04' }));
|
||||
expect(items(list).filter(c => c.label === 'Open history')).toHaveLength(6);
|
||||
expect(messageText((await click(list, 'Previous')))).toContain('Page 1 of 3');
|
||||
let d = await open(h.id, '2026-09-04');
|
||||
d = await click(d, 'Next');
|
||||
expect(items(d)[0].options[0].label).toBe('○ Task 10');
|
||||
expect(d.data.components.length).toBeLessThanOrEqual(5);
|
||||
d = await click(d, 'Choose a task to change its completion', false, [h.tasks[10].id]);
|
||||
expect(d.data.content).toContain('✓ Task 10');
|
||||
expect(messageText(d)).toContain('○ Task 6');
|
||||
expect(items(d).length).toBeLessThanOrEqual(40);
|
||||
d = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: taskControl(d, 'Task 10') } });
|
||||
expect(messageText(d)).toContain('✓ Task 10');
|
||||
d = await click(d, 'Refresh');
|
||||
expect(d.data.content).toContain('✓ Task 10');
|
||||
expect(messageText(d)).toContain('✓ Task 10');
|
||||
expect(control(d, 'Change date')).toBeTruthy();
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).tasks[10].done).toBe(true);
|
||||
});
|
||||
@@ -128,14 +129,15 @@ describe('/history', () => {
|
||||
const h = await f.json('/habits', 'POST', { name: 'Secret', method: 'manual' }, 201);
|
||||
await f.json('/habits', 'POST', { name: 'Bobs habit', method: 'manual' }, 201, 'b');
|
||||
const a = await f.run({ data: { name: 'history' } }, guild);
|
||||
const rejected = await click(a, 'Choose a habit', guild, [h.id], f.bob);
|
||||
expect(rejected.data.content).toContain('belongs to another user');
|
||||
const rejected = await click(a, 'Open history', guild, [h.id], f.bob);
|
||||
expect(messageText(rejected)).toContain('belongs to another user');
|
||||
expect(rejected.data.flags).toBe(guild ? 64 : undefined);
|
||||
const b = await f.run({ data: { name: 'history' } }, guild, f.bob);
|
||||
expect(JSON.stringify(b)).not.toContain('Secret');
|
||||
expect((await click(b, 'Choose a habit', guild, [h.id], f.bob)).data.content).toBe('Not found');
|
||||
// Inline controls bind the habit ID; injected select values cannot redirect them.
|
||||
expect(messageText(await click(b, 'Open history', guild, [h.id], f.bob))).toContain('Bobs habit');
|
||||
const d = await open(h.id, '2026-09-04', guild);
|
||||
expect((await click(d, 'Mark complete', guild, undefined, f.bob)).data.content).toContain('belongs to another user');
|
||||
expect(messageText((await click(d, 'Mark complete', guild, undefined, f.bob)))).toContain('belongs to another user');
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
|
||||
expect(f.changed).toEqual([]);
|
||||
});
|
||||
@@ -147,20 +149,20 @@ describe('/history', () => {
|
||||
let d = await open(h.id, '2026-09-04');
|
||||
const oldAmount = await click(d, 'Enter amount');
|
||||
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 8 });
|
||||
expect((await f.run(submit(oldAmount.data.custom_id, { amount: '2' }))).data.content).toContain('Progress changed');
|
||||
expect(messageText((await f.run(submit(oldAmount.data.custom_id, { amount: '2' }))))).toContain('Progress changed');
|
||||
d = await click(d, 'Refresh');
|
||||
expect(d.data.content).toContain('8/10');
|
||||
expect(messageText(d)).toContain('8/10');
|
||||
const interaction = f.payload({ type: 3, message: { id: '123' }, data: { custom_id: control(d, '+1') } });
|
||||
const saved = await (await f.requestDiscord(interaction)).json();
|
||||
f.restart();
|
||||
expect(await (await f.requestDiscord(interaction)).json()).toEqual(saved);
|
||||
expect((await click(d, '+1')).data.content).toContain('expired');
|
||||
expect(messageText((await click(d, '+1')))).toContain('expired');
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(9);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04/audit`)).records[0].events).toHaveLength(2);
|
||||
expect(f.changed).toEqual(['alice']);
|
||||
d = await open(h.id, '2026-09-04');
|
||||
f.setDiscordTime('2026-09-05T12:16:00Z');
|
||||
expect((await click(d, '+1')).data.content).toContain('expired');
|
||||
expect(messageText((await click(d, '+1')))).toContain('expired');
|
||||
});
|
||||
|
||||
test('uses account-local dates at midnight and retains historical record timezone', async () => {
|
||||
@@ -168,31 +170,31 @@ describe('/history', () => {
|
||||
const h = await f.json('/habits', 'POST', { name: 'Local day', method: 'manual' }, 201);
|
||||
f.setDiscordTime('2026-09-04T22:05:00Z');
|
||||
const list = await f.run({ data: { name: 'history' } });
|
||||
const form = await click(list, 'Choose a habit', false, [h.id]);
|
||||
const form = await click(await click(list, 'Open history'), 'Change date');
|
||||
expect(form.data.components[0].components[0].value).toBe('2026-09-05');
|
||||
await f.json('/me', 'PATCH', { timezone: 'America/New_York' });
|
||||
const d = await open(h.id, '2026-09-04');
|
||||
expect(d.data.content).toContain('Europe/Belgrade');
|
||||
expect(messageText(d)).toContain('Europe/Belgrade');
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).timezone).toBe('Europe/Belgrade');
|
||||
});
|
||||
|
||||
test('keeps earlier schedule requirements editable after a schedule change', async () => {
|
||||
f = discordFixture();
|
||||
const empty = await f.run({ data: { name: 'history' } });
|
||||
expect(empty.data.content).toContain('No habits yet');
|
||||
expect(items(empty)).toEqual([]);
|
||||
expect(messageText(empty)).toContain('No habits yet');
|
||||
expect(items(empty).filter(c => c.type === 2)).toEqual([]);
|
||||
const h = await f.json('/habits', 'POST', { name: 'Practice', method: 'manual' }, 201);
|
||||
f.setDiscordTime('2026-09-05T12:00:00Z');
|
||||
await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
|
||||
let d = await open(h.id, '2026-09-04');
|
||||
expect(d.data.content).toContain('Every day');
|
||||
expect(messageText(d)).toContain('Every day');
|
||||
d = await click(d, 'Mark complete');
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).complete).toBe(true);
|
||||
d = await click(d, 'Next date');
|
||||
readOnly(d);
|
||||
expect(d.data.content).toContain('Monday');
|
||||
expect(messageText(d)).toContain('Monday');
|
||||
d = await changeDate(d, '2026-09-07');
|
||||
expect(d.data.content).toContain('Future dates are read-only');
|
||||
expect(messageText(d)).toContain('Future dates are read-only');
|
||||
expect(items(d).some(i => i.label === 'Mark complete')).toBe(false);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).due).toBe(false);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { addDays } from '../habits/calendar';
|
||||
import { dateSchema, type Schedule } from '../habits/contracts';
|
||||
import { dailyAction, detail } from './daily';
|
||||
import { fields, type Context, type ControlData, type Message, type Reply } from './types';
|
||||
import { button, message, modal, pages, row, select, text } from './ui';
|
||||
import { button, screen, modal, pages, row, text, componentMessage, display, panel, section, listSize, listPage, pageLabel } from './ui';
|
||||
|
||||
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
function scheduleLabel(schedule: Schedule): string {
|
||||
@@ -14,11 +14,15 @@ function scheduleLabel(schedule: Schedule): string {
|
||||
|
||||
export function history(c: Context, data: ControlData = {}): Reply {
|
||||
const all = c.service.list(true);
|
||||
const page = Math.max(0, Math.min(data.page ?? 0, Math.ceil(all.length / 10) - 1));
|
||||
const state: ControlData = { date: data.date ?? c.service.today, page, mode: 'history', today: false };
|
||||
const visible = all.slice(page * 10, page * 10 + 10);
|
||||
return message(c, `**History** · ${text(c.service.user.timezone)}\nChoose a habit, then enter a calendar date. Archived habits are included.${all.length ? `\nPage ${page + 1} of ${Math.ceil(all.length / 10)}` : '\nNo habits yet. Use /habits to create one.'}`,
|
||||
[...(visible.length ? [select(c, 'Choose a habit', 'history.select', state, visible.map(h => ({ id: h.id, name: h.name, description: `${h.archived ? 'Archived · ' : ''}Tracked since ${h.createdDate}` })))] : []), ...pages(c, 'history', state, all.length)]);
|
||||
const page = listPage(data.page, all.length);
|
||||
const state: ControlData = { date: data.date ?? c.service.today, mode: 'history', today: false };
|
||||
return componentMessage(c, [
|
||||
panel([display(`## History\n${text(c.service.user.timezone)} · ${pageLabel(page, all.length)}\n${all.length ? 'Open a habit to inspect or correct a date. Archived habits are included.' : 'No habits yet. Use /habits to create one.'}`)]),
|
||||
...all.slice(page * listSize, (page + 1) * listSize).map(h => panel([
|
||||
section(`**${text(h.name)}**\n${h.archived ? 'Archived · ' : ''}Tracked since ${h.createdDate}`, button(c, 'Open history', 'history.detail', { ...state, habitId: h.id })),
|
||||
], c.service.settings(h.id).mainColor)),
|
||||
...pages(c, 'history', { ...state, page }, all.length, listSize),
|
||||
]);
|
||||
}
|
||||
|
||||
function dateForm(c: Context, data: ControlData): Reply {
|
||||
@@ -43,7 +47,13 @@ function withNavigation(c: Context, data: ControlData, reply: Reply): Reply {
|
||||
: !d.due ? 'Read-only · Day off; nothing is scheduled for this date.' : '';
|
||||
const body = reply.data as Message;
|
||||
const requirements = d.requirements ? `${d.method === 'manual' ? 'Checkbox' : d.method === 'count' ? 'Count' : 'Tasks'} · ${scheduleLabel(d.requirements.schedule)}\n` : '';
|
||||
return { ...reply, data: { ...body, content: `${requirements}${text(d.timezone)}\n${explanation ? explanation + '\n' : ''}${body.content ?? ''}`.slice(0, 2000), components: [...(body.components ?? []), row(...navigation)] } };
|
||||
const [card, ...rest] = body.components ?? [];
|
||||
if (card?.type === 17) {
|
||||
const children = card.components as import('./types').DiscordComponent[];
|
||||
const metadata = `${requirements}${explanation}`.trim();
|
||||
return { ...reply, data: { ...body, components: [{ ...card, components: [children[0]!, ...(metadata ? [display(metadata)] : []), ...children.slice(1)] }, ...rest, row(...navigation)] } };
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
|
||||
export function historyAction(c: Context, action: string, data: ControlData): Reply | undefined {
|
||||
@@ -52,7 +62,7 @@ export function historyAction(c: Context, action: string, data: ControlData): Re
|
||||
if (action === 'history.form') return dateForm(c, data);
|
||||
if (action === 'history.date') {
|
||||
const parsed = dateSchema.safeParse(fields(c.interaction).date?.trim());
|
||||
if (!parsed.success) return message(c, 'Enter a real calendar date as YYYY-MM-DD, from 1970 through 9998.', [row(button(c, 'Change date', 'history.form', data))]);
|
||||
if (!parsed.success) return screen(c, 'Enter a real calendar date as YYYY-MM-DD, from 1970 through 9998.', [row(button(c, 'Change date', 'history.form', data))]);
|
||||
data = { ...data, date: parsed.data, page: 0 };
|
||||
action = 'history.detail';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { discordFixture, control, submit } from './test-fixture';
|
||||
import { discordFixture, control, submit, messageText, components, habitControl } from './test-fixture';
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
afterEach(() => f?.close());
|
||||
async function click(reply: any, label: string, guild = false, values?: string[]) {
|
||||
@@ -7,7 +7,7 @@ async function click(reply: any, label: string, guild = false, values?: string[]
|
||||
}
|
||||
async function list(id: string, guild = false) {
|
||||
const home = await f.run({ data: { name: 'tasks' } }, guild);
|
||||
expect(home.data.flags).toBe(guild ? 64 : undefined);
|
||||
expect(home.data.flags).toBe(32768 | (guild ? 64 : 0));
|
||||
return click(home, 'Choose a task habit', guild, [id]);
|
||||
}
|
||||
async function task(id: string, taskId: string, guild = false) { return click(await list(id, guild), 'Choose a task', guild, [taskId]); }
|
||||
@@ -30,7 +30,7 @@ for (const guild of [false, true]) test(`task add, rename, schedule and delete m
|
||||
detail = await f.run(submit(form.data.custom_id, { every: '2', anchor: '2026-09-04', weekday: 'Friday' }), guild);
|
||||
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'weekly', every: 2, anchor: '2026-09-04', weekday: 5 });
|
||||
const confirm = await click(detail, 'Delete task', guild);
|
||||
expect(confirm.data.content).toContain('completion total may change');
|
||||
expect(messageText(confirm)).toContain('completion total may change');
|
||||
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks).toHaveLength(1);
|
||||
await click(confirm, 'Confirm task deletion', guild);
|
||||
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks).toHaveLength(0);
|
||||
@@ -46,12 +46,12 @@ test('task scheduling supports daily, selected weekdays, day intervals and overl
|
||||
expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'interval', every: 3, anchor: '2026-09-01' });
|
||||
let menu = await click(await click(detail, 'Change task schedule'), 'Recurrence', false, ['weekdays']);
|
||||
detail = await click(menu, 'Select weekdays', false, ['2']);
|
||||
expect(detail.data.content).toContain('never coincides');
|
||||
expect(messageText(detail)).toContain('never coincides');
|
||||
menu = await click(detail, 'Change task schedule');
|
||||
detail = await click(menu, 'Recurrence', false, ['daily']);
|
||||
expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'daily' });
|
||||
form = await click(detail, 'Rename task');
|
||||
expect((await f.run(submit(form.data.custom_id, { name: ' ' }))).data.content).toContain('Check the entered');
|
||||
expect(messageText((await f.run(submit(form.data.custom_id, { name: ' ' }))))).toContain('Check the entered');
|
||||
expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].name).toBe('Plan');
|
||||
});
|
||||
test('task changes preserve prior dates; stale controls and cross-owner task IDs are rejected', async () => {
|
||||
@@ -59,18 +59,18 @@ test('task changes preserve prior dates; stale controls and cross-owner task IDs
|
||||
const id = h.tasks[0].id;
|
||||
const rename = await click(await task(h.id, id), 'Rename task');
|
||||
await f.json(`/habits/${h.id}/tasks/${id}`, 'PATCH', { name: 'Website rename' });
|
||||
expect((await f.run(submit(rename.data.custom_id, { name: 'Stale' }))).data.content).toContain('habit changed');
|
||||
expect(messageText((await f.run(submit(rename.data.custom_id, { name: 'Stale' }))))).toContain('habit changed');
|
||||
f.setDiscordTime('2026-09-05T12:00:00Z');
|
||||
await click(await click(await task(h.id, id), 'Delete task'), 'Confirm task deletion');
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).tasks[0].name).toBe('Website rename');
|
||||
expect((await f.json(`/habits/${h.id}/tasks`)).tasks).toHaveLength(0);
|
||||
const bob = await f.json('/habits', 'POST', { name: 'Private', method: 'tasks', tasks: [{ name: 'Secret' }] }, 201, 'b');
|
||||
const add = await click(await list(h.id), 'Add task');
|
||||
expect((await f.run(submit(add.data.custom_id, { name: 'Attack' }), true, f.bob)).data.content).toContain('belongs to another');
|
||||
expect(messageText((await f.run(submit(add.data.custom_id, { name: 'Attack' }), true, f.bob)))).toContain('belongs to another');
|
||||
const bobMenu = await f.run({ data: { name: 'tasks' } }, false, f.bob);
|
||||
expect((await click(bobMenu, 'Choose a task habit', false, [bob.id])).data.content).toContain('belongs to another');
|
||||
expect(messageText((await click(bobMenu, 'Choose a task habit', false, [bob.id])))).toContain('belongs to another');
|
||||
const aliceMenu = await f.run({ data: { name: 'tasks' } });
|
||||
expect((await click(aliceMenu, 'Choose a task habit', false, [bob.id])).data.content).toContain('available options');
|
||||
expect(messageText((await click(aliceMenu, 'Choose a task habit', false, [bob.id])))).toContain('available options');
|
||||
});
|
||||
test('add forms are replay safe after restart and task pagination stays within limits', async () => {
|
||||
f = discordFixture(); const h = await setup();
|
||||
@@ -81,9 +81,9 @@ test('add forms are replay safe after restart and task pagination stays within l
|
||||
expect(f.changed).toHaveLength(1);
|
||||
for (let i = 0; i < 11; i++) await f.json(`/habits/${h.id}/tasks`, 'POST', { name: `Task ${i}` }, 201);
|
||||
const first = await list(h.id);
|
||||
expect(first.data.components[0].components[0].options).toHaveLength(10);
|
||||
expect(components(first.data.components).find(c => c.type === 3).options).toHaveLength(10);
|
||||
const next = await click(first, 'Next');
|
||||
expect(next.data.components[0].components[0].options).toHaveLength(2);
|
||||
expect(components(next.data.components).find(c => c.type === 3).options).toHaveLength(2);
|
||||
expect(control(next, 'Previous')).toBeTruthy();
|
||||
});
|
||||
test('midnight, color-only website edits, and count progress invalidate management snapshots', async () => {
|
||||
@@ -91,16 +91,16 @@ test('midnight, color-only website edits, and count progress invalidate manageme
|
||||
const h = await setup();
|
||||
let add = await click(await list(h.id), 'Add task');
|
||||
f.setDiscordTime('2026-09-04T22:01:00Z');
|
||||
expect((await f.run(submit(add.data.custom_id, { name: 'Wrong day' }))).data.content).toContain('habit changed');
|
||||
expect(messageText((await f.run(submit(add.data.custom_id, { name: 'Wrong day' }))))).toContain('habit changed');
|
||||
add = await click(await list(h.id), 'Add task');
|
||||
await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#abcdef' });
|
||||
expect((await f.run(submit(add.data.custom_id, { name: 'Old color' }))).data.content).toContain('habit changed');
|
||||
expect(messageText((await f.run(submit(add.data.custom_id, { name: 'Old color' }))))).toContain('habit changed');
|
||||
const c = await f.json('/habits', 'POST', { name: 'Count', method: 'count', target: 10 }, 201);
|
||||
const home = await f.run({ data: { name: 'habits' } });
|
||||
const detail = await click(home, 'Choose a habit to manage', false, [c.id]);
|
||||
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: habitControl(home, 'Count', 'Manage') } });
|
||||
const form = await click(detail, 'Edit details');
|
||||
await f.json(`/habits/${c.id}/days/2026-09-05/progress`, 'PUT', { count: 3 });
|
||||
expect((await f.run(submit(form.data.custom_id, { name: 'Lost progress', target: '20', unit: 'steps', color: '#123456' }))).data.content).toContain('habit changed');
|
||||
expect(messageText((await f.run(submit(form.data.custom_id, { name: 'Lost progress', target: '20', unit: 'steps', color: '#123456' }))))).toContain('habit changed');
|
||||
});
|
||||
test('a complete checklist routine can be created entirely in Discord', async () => {
|
||||
f = discordFixture();
|
||||
@@ -111,7 +111,7 @@ test('a complete checklist routine can be created entirely in Discord', async ()
|
||||
const tasks = await click(habit, 'Manage tasks', true);
|
||||
const add = await click(tasks, 'Add task', true);
|
||||
const task = await f.run(submit(add.data.custom_id, { name: 'Prepare tomorrow' }), true);
|
||||
expect(task.data.content).toContain('Prepare tomorrow');
|
||||
expect(messageText(task)).toContain('Prepare tomorrow');
|
||||
const website = (await f.json('/habits')).habits[0];
|
||||
expect(website.method).toBe('tasks'); expect(website.tasks[0].name).toBe('Prepare tomorrow');
|
||||
const settings = await f.json(`/habits/${website.id}/calendar-settings`);
|
||||
@@ -119,5 +119,5 @@ test('a complete checklist routine can be created entirely in Discord', async ()
|
||||
const other = await f.json('/habits', 'POST', { name: 'Other', method: 'tasks', tasks: [{ name: 'Private task' }] }, 201, 'b');
|
||||
const taskMenu = await list(website.id);
|
||||
const blocked = await click(taskMenu, 'Choose a task', false, [other.tasks[0].id]);
|
||||
expect(blocked.data.content).toContain('available options');
|
||||
expect(messageText(blocked)).toContain('available options');
|
||||
});
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { discordFixture, control, submit } from './test-fixture';
|
||||
import { discordFixture, control, submit, messageText, components, habitControl, findHabitPage } from './test-fixture';
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
afterEach(() => f?.close());
|
||||
async function click(reply: any, label: string, guild = false, values?: string[]) {
|
||||
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), ...(values ? { values } : {}) } }, guild);
|
||||
}
|
||||
async function open(id: string, guild = false) { return click(await f.run({ data: { name: 'habits' } }, guild), 'Choose a habit to manage', guild, [id]); }
|
||||
async function open(id: string, guild = false) {
|
||||
const h = await f.json(`/habits/${id}`);
|
||||
const list = await findHabitPage(f, await f.run({ data: { name: 'habits' } }, guild), h.name, guild);
|
||||
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: habitControl(list, h.name, 'Manage') } }, guild);
|
||||
}
|
||||
async function create(method: string, guild = false) {
|
||||
const list = await f.run({ data: { name: 'habits' } }, guild);
|
||||
expect(list.data.flags).toBe(guild ? 64 : undefined);
|
||||
expect(list.data.flags).toBe(32768 | (guild ? 64 : 0));
|
||||
const methodMenu = await click(list, 'Create habit', guild);
|
||||
const form = await click(methodMenu, 'Tracking method', guild, [method]);
|
||||
const result = await f.run(submit(form.data.custom_id, { name: 'Read', color: '#123456', ...(method === 'count' ? { target: '10', unit: 'pages' } : {}) }), guild);
|
||||
expect(result.data.content).toContain('Read');
|
||||
expect(messageText(result)).toContain('Read');
|
||||
return { result, habit: (await f.json('/habits')).habits[0] };
|
||||
}
|
||||
for (const guild of [false, true]) test(`habit lifecycle and shared website state in ${guild ? 'server' : 'DM'}`, async () => {
|
||||
@@ -30,9 +34,9 @@ for (const guild of [false, true]) test(`habit lifecycle and shared website stat
|
||||
expect((await f.json('/habits')).habits[0].id).toBe(habit.id);
|
||||
detail = await click(await click(detail, 'Archive habit', guild), 'Confirm', guild);
|
||||
const archived = await click(await f.run({ data: { name: 'habits' } }, guild), 'Browse archived', guild);
|
||||
expect(control(archived, 'Choose a habit to manage')).toBeTruthy();
|
||||
expect(control(archived, 'Manage')).toBeTruthy();
|
||||
detail = await click(await click(detail, 'Permanently delete', guild), 'Confirm', guild);
|
||||
expect(detail.data.content).toContain('No habits');
|
||||
expect(messageText(detail)).toContain('No habits');
|
||||
expect((await f.request(`/habits/${habit.id}/days/2026-09-04`)).status).toBe(404);
|
||||
expect(f.changed.length).toBe(7);
|
||||
});
|
||||
@@ -54,7 +58,7 @@ test('all recurrences use validated friendly controls', async () => {
|
||||
await click(menu, 'Recurrence', false, ['daily']);
|
||||
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'daily' });
|
||||
const invalid = await click(await click(await open(habit.id), 'Change schedule'), 'Recurrence', false, ['interval']);
|
||||
expect((await f.run(submit(invalid.data.custom_id, { every: '0', anchor: '2026-02-30' }))).data.content).toContain('Check the entered');
|
||||
expect(messageText((await f.run(submit(invalid.data.custom_id, { every: '0', anchor: '2026-02-30' }))))).toContain('Check the entered');
|
||||
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'daily' });
|
||||
});
|
||||
test('method conversion warns, resets current progress and preserves past dated requirements', async () => {
|
||||
@@ -63,7 +67,7 @@ test('method conversion warns, resets current progress and preserves past dated
|
||||
f.setDiscordTime('2026-09-05T12:00:00Z');
|
||||
const menu = await click(await open(habit.id), 'Change method');
|
||||
const confirmation = await click(menu, 'New tracking method', false, ['count']);
|
||||
expect(confirmation.data.content).toContain('resets today');
|
||||
expect(messageText(confirmation)).toContain('resets today');
|
||||
const form = await click(confirmation, 'Continue conversion');
|
||||
await f.run(submit(form.data.custom_id, { name: 'Read', color: '#123456', target: '5', unit: 'pages' }));
|
||||
const past = await f.json(`/habits/${habit.id}/days/2026-09-04`);
|
||||
@@ -78,21 +82,23 @@ test('stale, replayed, and cross-account management controls cannot write', asyn
|
||||
f = discordFixture(); const { result, habit } = await create('manual');
|
||||
const form = await click(result, 'Edit details');
|
||||
await f.json(`/habits/${habit.id}`, 'PATCH', { name: 'Website edit' });
|
||||
expect((await f.run(submit(form.data.custom_id, { name: 'Stale', color: '#123456' }))).data.content).toContain('habit changed');
|
||||
expect(messageText((await f.run(submit(form.data.custom_id, { name: 'Stale', color: '#123456' }))))).toContain('habit changed');
|
||||
const fresh = await click(await open(habit.id), 'Edit details');
|
||||
const payload = f.payload(submit(fresh.data.custom_id, { name: 'Final', color: '#123456' }));
|
||||
await f.requestDiscord(payload); const changes = f.changed.length;
|
||||
f.restart(); await f.requestDiscord(payload);
|
||||
expect(f.changed.length).toBe(changes);
|
||||
expect((await f.run(submit(fresh.data.custom_id, { name: 'Replay', color: '#123456' }))).data.content).toContain('expired');
|
||||
expect((await f.run(submit(fresh.data.custom_id, { name: 'Attack', color: '#123456' }), true, f.bob)).data.content).toContain('belongs to another');
|
||||
expect(messageText((await f.run(submit(fresh.data.custom_id, { name: 'Replay', color: '#123456' }))))).toContain('expired');
|
||||
expect(messageText((await f.run(submit(fresh.data.custom_id, { name: 'Attack', color: '#123456' }), true, f.bob)))).toContain('belongs to another');
|
||||
expect((await f.json('/habits')).habits[0].name).toBe('Final');
|
||||
});
|
||||
test('pagination keeps Discord component and option limits', async () => {
|
||||
f = discordFixture(); for (let i = 0; i < 12; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201);
|
||||
const page = await f.run({ data: { name: 'habits' } });
|
||||
expect(page.data.components[0].components[0].options.length).toBe(10);
|
||||
expect(components(page.data.components).filter(c => c.label === 'Manage')).toHaveLength(6);
|
||||
expect(components(page.data.components).length).toBeLessThanOrEqual(40);
|
||||
const next = await click(page, 'Next');
|
||||
expect(next.data.components[0].components[0].options.length).toBe(2);
|
||||
expect(components(next.data.components).filter(c => c.label === 'Manage')).toHaveLength(6);
|
||||
expect(components(next.data.components).length).toBeLessThanOrEqual(40);
|
||||
expect(control(next, 'Previous')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { habitInput, habitPatch, taskInput, taskPatch, scheduleSchema, type Schedule } from '../habits/contracts';
|
||||
import { ApiError } from '../habits/service';
|
||||
import { fields, type Context, type ControlData, type Reply } from './types';
|
||||
import { button, message, modal, pages, row, select, text } from './ui';
|
||||
import { button, screen, modal, pages, row, select, text, componentMessage, display, panel, section, listSize, listPage, pageLabel } from './ui';
|
||||
|
||||
const prefix = 'manage:';
|
||||
const methods = [{ id: 'manual', name: 'Manual check-in' }, { id: 'count', name: 'Count toward a target' }, { id: 'tasks', name: 'Checklist of tasks' }];
|
||||
@@ -31,22 +31,25 @@ export function scheduleLabel(s: Schedule): string {
|
||||
export function manageHabits(c: Context, d: ControlData = {}): Reply {
|
||||
const archived = d.mode === 'archived';
|
||||
const habits = c.service.list(true).filter(h => h.archived === archived);
|
||||
const page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(habits.length / 10) - 1)));
|
||||
const page = listPage(d.page, habits.length);
|
||||
const state = { mode: archived ? 'archived' : 'active', page };
|
||||
return message(c, `**${archived ? 'Archived' : 'Active'} habits** · Page ${page + 1}\n${habits.length ? 'Choose a habit to manage.' : 'No habits here yet.'}`, [
|
||||
...(habits.length ? [select(c, 'Choose a habit to manage', prefix + 'open', state, habits.slice(page * 10, page * 10 + 10).map(h => ({ id: h.id, name: h.name, description: scheduleLabel(h.schedule) })))] : []),
|
||||
row(button(c, 'Create habit', prefix + 'create'), button(c, archived ? 'Browse active' : 'Browse archived', prefix + 'list', { mode: archived ? 'active' : 'archived' })),
|
||||
...pages(c, prefix + 'list', state, habits.length),
|
||||
return componentMessage(c, [
|
||||
panel([display(`## ${archived ? 'Archived' : 'Active'} habits\n${pageLabel(page, habits.length)} · ${habits.length} habits\n${habits.length ? 'Edit details, schedules, and tracking methods.' : 'No habits here yet.'}`)]),
|
||||
...habits.slice(page * listSize, (page + 1) * listSize).map(h => panel([
|
||||
section(`**${text(h.name)}**\n${scheduleLabel(h.schedule)} · ${h.method === 'count' ? `${h.target} ${text(h.unit)}` : h.method === 'tasks' ? 'Checklist' : 'Manual check-in'}`, button(c, 'Manage', prefix + 'detail', { habitId: h.id })),
|
||||
], c.service.settings(h.id).mainColor)),
|
||||
...pages(c, prefix + 'list', state, habits.length, listSize),
|
||||
row(button(c, 'Create habit', prefix + 'create', {}, 1), button(c, archived ? 'Browse active' : 'Browse archived', prefix + 'list', { mode: archived ? 'active' : 'archived' })),
|
||||
]);
|
||||
}
|
||||
function detail(c: Context, id: string): Reply {
|
||||
const h = c.service.current(id), d = snapshot(c, id);
|
||||
return message(c, `**${text(h.name)}**${h.archived ? ' · Archived' : ''}\n${h.method === 'count' ? `${h.target} ${text(h.unit)} · Carry partial progress: ${h.carryPartialProgress ? 'on' : 'off'}` : h.method === 'tasks' ? `${h.tasks.length} tasks` : 'Manual check-in'}\n${scheduleLabel(h.schedule)}\nColor: ${c.service.settings(id).mainColor}\nChanges apply from ${c.service.today} (${text(c.service.user.timezone)}); earlier requirements remain unchanged.`, [
|
||||
return screen(c, `**${text(h.name)}**${h.archived ? ' · Archived' : ''}\n${h.method === 'count' ? `${h.target} ${text(h.unit)} · Carry partial progress: ${h.carryPartialProgress ? 'on' : 'off'}` : h.method === 'tasks' ? `${h.tasks.length} tasks` : 'Manual check-in'}\n${scheduleLabel(h.schedule)}\nColor: ${c.service.settings(id).mainColor}\nChanges apply from ${c.service.today} (${text(c.service.user.timezone)}); earlier requirements remain unchanged.`, [
|
||||
row(button(c, 'Edit details', prefix + 'edit', d), button(c, 'Change schedule', prefix + 'schedule', d), button(c, 'Change method', prefix + 'method', d)),
|
||||
...(h.method === 'tasks' && !h.archived ? [row(button(c, 'Manage tasks', prefix + 'tasks-list', d))] : []),
|
||||
...(h.method === 'count' ? [row(button(c, h.carryPartialProgress ? 'Disable carryover' : 'Enable carryover', prefix + 'carry', d))] : []),
|
||||
row(button(c, h.archived ? 'Restore habit' : 'Archive habit', prefix + 'confirm', { ...d, mode: h.archived ? 'restore' : 'archive' }), ...(h.archived ? [button(c, 'Permanently delete', prefix + 'confirm', { ...d, mode: 'delete' }, 4)] : []), button(c, 'Back to habits', prefix + 'list')),
|
||||
]);
|
||||
row(button(c, h.archived ? 'Restore habit' : 'Archive habit', prefix + 'confirm', { ...d, mode: h.archived ? 'restore' : 'archive' }), ...(h.archived ? [button(c, 'Permanently delete', prefix + 'confirm', { ...d, mode: 'delete' }, 4)] : []), button(c, 'Back to habits', prefix + 'list', { mode: h.archived ? 'archived' : 'active' })),
|
||||
], true, c.service.settings(id).mainColor);
|
||||
}
|
||||
function detailsForm(c: Context, d: ControlData, creating = false): Reply {
|
||||
const h = creating ? undefined : check(c, d), method = d.method ?? h!.method;
|
||||
@@ -58,7 +61,7 @@ function detailsForm(c: Context, d: ControlData, creating = false): Reply {
|
||||
}
|
||||
function schedulePicker(c: Context, d: ControlData): Reply {
|
||||
check(c, d);
|
||||
return message(c, 'Choose a recurrence. Dates use your account timezone.', [select(c, 'Recurrence', prefix + 'schedule-kind', d, recurrence), row(button(c, 'Cancel', prefix + (d.taskId ? 'task-detail' : 'detail'), d))]);
|
||||
return screen(c, 'Choose a recurrence. Dates use your account timezone.', [select(c, 'Recurrence', prefix + 'schedule-kind', d, recurrence), row(button(c, 'Cancel', prefix + (d.taskId ? 'task-detail' : 'detail'), d))]);
|
||||
}
|
||||
function saveSchedule(c: Context, d: ControlData, schedule: Schedule): Reply {
|
||||
check(c, d);
|
||||
@@ -74,7 +77,7 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
|
||||
if (a === 'list') return manageHabits(c, d);
|
||||
if (a === 'open') return detail(c, chosen(c, c.service.list(true).filter(h => h.archived === (d.mode === 'archived')).map(h => h.id)));
|
||||
if (a === 'detail') return detail(c, d.habitId!);
|
||||
if (a === 'create') return message(c, 'Choose how to track your habit. New habits start with a daily schedule; you can change it after creating.', [select(c, 'Tracking method', prefix + 'create-method', {}, methods)]);
|
||||
if (a === 'create') return screen(c, 'Choose how to track your habit. New habits start with a daily schedule; you can change it after creating.', [select(c, 'Tracking method', prefix + 'create-method', {}, methods), row(button(c, 'Back to habits', prefix + 'list'))]);
|
||||
if (a === 'create-method') return detailsForm(c, { method: chosen(c, methods.map(m => m.id)) }, true);
|
||||
if (a === 'save-create') {
|
||||
const f = fields(c.interaction);
|
||||
@@ -88,10 +91,10 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
|
||||
consume(c); return detail(c, d.habitId!);
|
||||
}
|
||||
if (a === 'carry') { const h = check(c, d); if (h.method !== 'count') throw new ApiError(409, 'This habit no longer uses counts.'); c.service.edit(h.id, { carryPartialProgress: !h.carryPartialProgress }); consume(c); return detail(c, h.id); }
|
||||
if (a === 'method') { const h = check(c, d); return message(c, 'Choose a new tracking method.', [select(c, 'New tracking method', prefix + 'method-confirm', d, methods.filter(m => m.id !== h.method))]); }
|
||||
if (a === 'method') { const h = check(c, d); return screen(c, 'Choose a new tracking method.', [select(c, 'New tracking method', prefix + 'method-confirm', d, methods.filter(m => m.id !== h.method)), row(button(c, 'Cancel', prefix + 'detail', d))]); }
|
||||
if (a === 'method-confirm') {
|
||||
const h = check(c, d), method = chosen(c, methods.filter(m => m.id !== h.method).map(m => m.id));
|
||||
return message(c, 'Changing method resets today’s progress. Switching away from tasks removes the current checklist; switching to tasks starts an empty checklist. Earlier dated history stays available. Count carryover starts off when switching to count. Continue?', [row(button(c, 'Continue conversion', prefix + 'convert', { ...d, method }, 4), button(c, 'Cancel', prefix + 'detail', d))]);
|
||||
return screen(c, 'Changing method resets today’s progress. Switching away from tasks removes the current checklist; switching to tasks starts an empty checklist. Earlier dated history stays available. Count carryover starts off when switching to count. Continue?', [row(button(c, 'Continue conversion', prefix + 'convert', { ...d, method }, 4), button(c, 'Cancel', prefix + 'detail', d))]);
|
||||
}
|
||||
if (a === 'convert') {
|
||||
check(c, d);
|
||||
@@ -100,7 +103,7 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
|
||||
}
|
||||
if (a === 'confirm') {
|
||||
const h = check(c, d);
|
||||
return message(c, d.mode === 'delete' ? `Permanently delete ${text(h.name)} and all its history? This cannot be undone and removes it from combined charts.` : `${d.mode === 'restore' ? 'Restore' : 'Archive'} ${text(h.name)} from today? Earlier history is preserved.`, [row(button(c, 'Confirm', prefix + 'apply-status', d, d.mode === 'delete' ? 4 : 1), button(c, 'Cancel', prefix + 'detail', d))]);
|
||||
return screen(c, d.mode === 'delete' ? `Permanently delete ${text(h.name)} and all its history? This cannot be undone and removes it from combined charts.` : `${d.mode === 'restore' ? 'Restore' : 'Archive'} ${text(h.name)} from today? Earlier history is preserved.`, [row(button(c, 'Confirm', prefix + 'apply-status', d, d.mode === 'delete' ? 4 : 1), button(c, 'Cancel', prefix + 'detail', d))]);
|
||||
}
|
||||
if (a === 'apply-status') { check(c, d); if (d.mode === 'delete') c.service.permanentlyDelete(d.habitId!); else c.service.edit(d.habitId!, { archived: d.mode === 'archive' }); consume(c); return d.mode === 'delete' ? manageHabits(c, { mode: 'archived' }) : detail(c, d.habitId!); }
|
||||
if (a === 'schedule') return schedulePicker(c, d);
|
||||
@@ -110,7 +113,7 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
|
||||
if (kind === 'weekdays') {
|
||||
const menu = select(c, 'Select weekdays', prefix + 'save-weekdays', d, weekdays.map((name, i) => ({ id: String(i), name })));
|
||||
Object.assign((menu.components as Record<string, unknown>[])[0]!, { min_values: 1, max_values: 7 });
|
||||
return message(c, 'Select all days on which this repeats.', [menu]);
|
||||
return screen(c, 'Select all days on which this repeats.', [menu, row(button(c, 'Cancel', prefix + (d.taskId ? 'task-detail' : 'detail'), d))]);
|
||||
}
|
||||
const s = d.taskId ? c.service.tasks(h.id).find(t => t.id === d.taskId)?.schedule : h.schedule;
|
||||
if (!s) throw new ApiError(404, 'Task not found. Run /tasks again.');
|
||||
@@ -132,7 +135,7 @@ export function managementAction(c: Context, action: string, d: ControlData): Re
|
||||
export function manageTasks(c: Context, d: ControlData = {}): Reply {
|
||||
const habits = c.service.list().filter(h => h.method === 'tasks');
|
||||
const page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(habits.length / 10) - 1)));
|
||||
return message(c, `**Task habits** · Page ${page + 1}\n${habits.length ? 'Choose a habit to manage its checklist.' : 'Create a habit with the checklist method to add tasks.'}`, [
|
||||
return screen(c, `**Task habits** · Page ${page + 1}\n${habits.length ? 'Choose a habit to manage its checklist.' : 'Create a habit with the checklist method to add tasks.'}`, [
|
||||
...(habits.length ? [select(c, 'Choose a task habit', prefix + 'tasks-open', {}, habits.slice(page * 10, page * 10 + 10).map(h => ({ id: h.id, name: h.name })))] : []),
|
||||
row(button(c, 'Create habit', prefix + 'create'), button(c, 'All habits', prefix + 'list')),
|
||||
...pages(c, prefix + 'tasks-home', { page }, habits.length),
|
||||
@@ -143,7 +146,7 @@ function taskList(c: Context, d: ControlData): Reply {
|
||||
if (h.archived) throw new ApiError(409, 'Restore the habit in /habits before editing tasks.');
|
||||
const tasks = c.service.tasks(h.id), page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(tasks.length / 10) - 1)));
|
||||
const state = { ...snapshot(c, h.id), page };
|
||||
return message(c, `**${text(h.name)} · Tasks** · Page ${page + 1}\nHabit recurrence: ${scheduleLabel(h.schedule)}\nTasks are due only when their own and the habit’s schedules coincide. ${tasks.length ? '' : 'Add your first task.'}`, [
|
||||
return screen(c, `**${text(h.name)} · Tasks** · Page ${page + 1}\nHabit recurrence: ${scheduleLabel(h.schedule)}\nTasks are due only when their own and the habit’s schedules coincide. ${tasks.length ? '' : 'Add your first task.'}`, [
|
||||
...(tasks.length ? [select(c, 'Choose a task', prefix + 'task-open', state, tasks.slice(page * 10, page * 10 + 10).map(t => ({ id: t.id, name: t.name, description: scheduleLabel(t.schedule) })))] : []),
|
||||
row(button(c, 'Add task', prefix + 'task-add', state), button(c, 'Habit details', prefix + 'detail', state), button(c, 'Other task habits', prefix + 'tasks-home')),
|
||||
...pages(c, prefix + 'tasks-list', state, tasks.length),
|
||||
@@ -155,7 +158,7 @@ function taskDetail(c: Context, d: ControlData): Reply {
|
||||
if (h.archived) throw new ApiError(409, 'Restore the habit in /habits before editing tasks.');
|
||||
const state = { ...snapshot(c, h.id), taskId: task.id };
|
||||
const warning = h.warnings.find(w => w.taskId === task.id);
|
||||
return message(c, `**${text(task.name)}** · ${text(h.name)}\n${scheduleLabel(task.schedule)}\n${warning ? text(warning.message) : 'Due when the task and habit schedules coincide.'}`, [
|
||||
return screen(c, `**${text(task.name)}** · ${text(h.name)}\n${scheduleLabel(task.schedule)}\n${warning ? text(warning.message) : 'Due when the task and habit schedules coincide.'}`, [
|
||||
row(button(c, 'Rename task', prefix + 'task-rename', state), button(c, 'Change task schedule', prefix + 'schedule', state), button(c, 'Delete task', prefix + 'task-delete', state, 4)),
|
||||
row(button(c, 'Back to tasks', prefix + 'tasks-list', state)),
|
||||
]);
|
||||
@@ -181,7 +184,7 @@ function taskAction(c: Context, a: string, d: ControlData): Reply | undefined {
|
||||
if (a === 'task-delete') {
|
||||
check(c, d); const task = c.service.tasks(d.habitId!).find(t => t.id === d.taskId);
|
||||
if (!task) throw new ApiError(404, 'Task not found.');
|
||||
return message(c, `Delete ${text(task.name)} from this checklist starting today? Today’s completion total may change. Earlier dated history remains. Adding it again creates a new task without its old completion state.`, [row(button(c, 'Confirm task deletion', prefix + 'task-delete-confirm', d, 4), button(c, 'Cancel', prefix + 'task-detail', d))]);
|
||||
return screen(c, `Delete ${text(task.name)} from this checklist starting today? Today’s completion total may change. Earlier dated history remains. Adding it again creates a new task without its old completion state.`, [row(button(c, 'Confirm task deletion', prefix + 'task-delete-confirm', d, 4), button(c, 'Cancel', prefix + 'task-detail', d))]);
|
||||
}
|
||||
if (a === 'task-delete-confirm') { check(c, d); c.service.changeTask(d.habitId!, d.taskId, null); consume(c); return taskList(c, { habitId: d.habitId }); }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { reminderDeliveries, users } from '../db/schema';
|
||||
import { discordFixture, control, submit } from './test-fixture';
|
||||
import { discordFixture, control, submit, messageText, components } from './test-fixture';
|
||||
import { commands } from './commands';
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
afterEach(() => f?.close());
|
||||
const click = (reply: any, label: string) => ({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label) } });
|
||||
const draft = { name: 'Take a breath', times: '09:00, 18:30', days: 'Mon, Wed, Fri', date: '' };
|
||||
async function choose(list: any, guild = false, index = '0') {
|
||||
return f.run({ ...click(list, 'Choose a reminder'), data: { custom_id: control(list, 'Choose a reminder'), values: [index] } }, guild);
|
||||
return f.run(click(list, 'Manage reminder'), guild);
|
||||
}
|
||||
test('settings and reminders are global commands in both contexts', () => {
|
||||
for (const name of ['settings', 'reminders']) expect(commands.find(c => c.name === name)?.contexts).toEqual([0, 1]);
|
||||
@@ -17,7 +17,7 @@ for (const guild of [false, true]) test(`full named reminder management shares w
|
||||
f = discordFixture(true);
|
||||
await f.json('/reminders', 'PUT', { enabled: false, reminders: [], quietStart: '22:00', quietEnd: '08:00' });
|
||||
let list = await f.run({ data: { name: 'reminders' } }, guild);
|
||||
expect(list.data.flags).toBe(guild ? 64 : undefined);
|
||||
expect(list.data.flags).toBe(32768 | (guild ? 64 : 0));
|
||||
const form = await f.run(click(list, 'Add reminder'), guild);
|
||||
const interaction = f.payload(submit(form.data.custom_id, draft), guild);
|
||||
list = await (await f.requestDiscord(interaction)).json();
|
||||
@@ -40,7 +40,7 @@ for (const guild of [false, true]) test(`full named reminder management shares w
|
||||
list = await f.run(click(confirmation, 'Confirm removal'), guild);
|
||||
expect((await f.json('/reminders')).reminders).toEqual([]);
|
||||
expect((await f.json('/reminders')).enabled).toBe(false);
|
||||
expect(list.data.content).toContain('No reminders');
|
||||
expect(messageText(list)).toContain('No reminders');
|
||||
const settings = await f.run({ data: { name: 'settings' } }, guild);
|
||||
const tz = await f.run(click(settings, 'Change timezone'), guild);
|
||||
await f.run(submit(tz.data.custom_id, { timezone: 'America/New_York' }), guild);
|
||||
@@ -55,20 +55,20 @@ test('bad input can reopen a prefilled form without consuming the valid control'
|
||||
let form = await f.run(click(list, 'Add reminder'));
|
||||
for (const invalid of [{ ...draft, times: '25:00' }, { ...draft, days: 'Funday' }, { ...draft, days: '', date: '2026-02-30' }, { ...draft, date: '2026-09-30' }]) {
|
||||
const failed = await f.run(submit(form.data.custom_id, invalid));
|
||||
expect(failed.type).toBe(4);
|
||||
expect(failed.type).toBe(7);
|
||||
form = await f.run(click(failed, 'Try again'));
|
||||
expect(form.type).toBe(9);
|
||||
expect(form.data.components[0].components[0].value).toBe(draft.name);
|
||||
}
|
||||
const saved = await f.run(submit(form.data.custom_id, draft));
|
||||
expect(saved.data.content).toContain(draft.name);
|
||||
expect(messageText(saved)).toContain(draft.name);
|
||||
const enabled = await f.run(click(saved, 'Enable delivery'));
|
||||
expect(enabled.data.content).toContain('not configured');
|
||||
expect(messageText(enabled)).toContain('not configured');
|
||||
expect((await f.json('/reminders')).enabled).toBe(false);
|
||||
const settings = await f.run({ data: { name: 'settings' } });
|
||||
const timezone = await f.run(click(settings, 'Change timezone'));
|
||||
const invalid = await f.run(submit(timezone.data.custom_id, { timezone: 'Invalid/Zone' }));
|
||||
expect(invalid.data.content).toContain('Use a timezone');
|
||||
expect(messageText(invalid)).toContain('Use a timezone');
|
||||
const retry = await f.run(click(invalid, 'Try again'));
|
||||
await f.run(submit(retry.data.custom_id, { timezone: 'UTC' }));
|
||||
expect((await f.json('/me')).timezone).toBe('UTC');
|
||||
@@ -82,17 +82,17 @@ test('website edits and timezone changes invalidate old controls even within one
|
||||
const prefs = { enabled: false, reminders: before.reminders, quietStart: '22:00', quietEnd: '08:00' };
|
||||
await f.json('/reminders', 'PUT', prefs);
|
||||
const blocked = await f.run(submit(form.data.custom_id, draft));
|
||||
expect(blocked.data.content).toContain('Settings changed');
|
||||
expect(messageText(blocked)).toContain('Settings changed');
|
||||
const newer = await f.run({ data: { name: 'reminders' } });
|
||||
const newerForm = await f.run(click(newer, 'Add reminder'));
|
||||
await f.json('/reminders', 'PUT', { ...prefs, quietStart: '21:00' });
|
||||
await f.json('/reminders', 'PUT', prefs);
|
||||
expect((await f.run(submit(newerForm.data.custom_id, draft))).data.content).toContain('Settings changed');
|
||||
expect(messageText((await f.run(submit(newerForm.data.custom_id, draft))))).toContain('Settings changed');
|
||||
const settings = await f.run({ data: { name: 'settings' } });
|
||||
const tz = await f.run(click(settings, 'Change timezone'));
|
||||
await f.json('/me', 'PATCH', { timezone: 'UTC' });
|
||||
await f.json('/me', 'PATCH', { timezone: 'Europe/Belgrade' });
|
||||
expect((await f.run(submit(tz.data.custom_id, { timezone: 'Asia/Tokyo' }))).data.content).toContain('Settings changed');
|
||||
expect(messageText((await f.run(submit(tz.data.custom_id, { timezone: 'Asia/Tokyo' }))))).toContain('Settings changed');
|
||||
expect((await f.json('/me')).timezone).toBe('Europe/Belgrade');
|
||||
});
|
||||
|
||||
@@ -102,11 +102,11 @@ test('old forms cannot cross accounts; new Discord accounts discover UTC setup',
|
||||
const form = await f.run(click(list, 'Add reminder'));
|
||||
const blocked = await f.run(submit(form.data.custom_id, draft), true, f.bob);
|
||||
expect(blocked.data.flags).toBe(64);
|
||||
expect(blocked.data.content).toContain('belongs to another user');
|
||||
expect(messageText(blocked)).toContain('belongs to another user');
|
||||
expect((await f.json('/reminders', 'GET', undefined, 200, 'b')).reminders).toHaveLength(1);
|
||||
const id = '555555555555555555';
|
||||
const settings = await f.run({ data: { name: 'settings' } }, false, id);
|
||||
expect(settings.data.content).toContain('New accounts start in UTC');
|
||||
expect(messageText(settings)).toContain('New accounts start in UTC');
|
||||
expect(f.db.select().from(users).where(eq(users.discordId, id)).get()?.timezone).toBe('UTC');
|
||||
});
|
||||
|
||||
@@ -116,19 +116,19 @@ test('paginated list, 20-slot limit, delivery status and quiet-hour validation',
|
||||
await f.json('/reminders', 'PUT', { enabled: false, reminders, quietStart: '22:00', quietEnd: '08:00' });
|
||||
f.db.insert(reminderDeliveries).values({ id: 'delivery', userId: 'alice', date: '2026-09-04', status: 'pending', updatedAt: 1 }).run();
|
||||
const list = await f.run({ data: { name: 'reminders' } });
|
||||
expect(list.data.content).toContain('Delivery unconfirmed');
|
||||
expect(messageText(list)).toContain('Delivery unconfirmed');
|
||||
const next = await f.run(click(list, 'Next'));
|
||||
expect(next.data.content).toContain('Reminder 20');
|
||||
expect(messageText(next)).toContain('Reminder 12');
|
||||
const form = await f.run(click(next, 'Add reminder'));
|
||||
const fail = await f.run(submit(form.data.custom_id, draft));
|
||||
expect(fail.data.content).toContain('up to 20');
|
||||
expect(messageText(fail)).toContain('up to 20');
|
||||
expect((await f.json('/reminders')).reminders).toHaveLength(20);
|
||||
const quiet = await f.run(click(list, 'Quiet hours'));
|
||||
const bad = await f.run(submit(quiet.data.custom_id, { start: '25:00', end: '00:00' }));
|
||||
const retry = await f.run(click(bad, 'Try again'));
|
||||
const done = await f.run(submit(retry.data.custom_id, { start: '00:00', end: '00:00' }));
|
||||
expect(done.data.content).toContain('(off)');
|
||||
expect(messageText(done)).toContain('(off)');
|
||||
const expired = await f.run({ data: { name: 'settings' } });
|
||||
f.setDiscordTime('2026-09-04T12:16:00Z');
|
||||
expect((await f.run(click(expired, 'Change timezone'))).data.content).toContain('expired');
|
||||
expect(messageText((await f.run(click(expired, 'Change timezone'))))).toContain('expired');
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ReminderService } from '../reminders/service';
|
||||
import type { ReminderEntry } from '../reminders/contracts';
|
||||
import type { Context, ControlData, Reply } from './types';
|
||||
import { fields } from './types';
|
||||
import { button, message, modal, pages, row, select, text } from './ui';
|
||||
import { button, screen, modal, pages, row, text, componentMessage, display, panel, section, listSize, listPage, pageLabel } from './ui';
|
||||
|
||||
const deliveryLabels: Record<string, string> = {
|
||||
sent: 'Delivered', deferred: 'Waiting to retry', uncertain: 'Delivery unconfirmed; check Discord. This attempt will not be retried.',
|
||||
@@ -40,12 +40,13 @@ const schedule = (e: ReminderEntry) => e.type === 'date' ? e.date : e.days.lengt
|
||||
export function preferences(c: Context, command: string, data: ControlData = {}): Reply {
|
||||
const prefs = service(c).read(), tz = user(c).timezone, state = snapshot(c, data);
|
||||
const status = `Delivery: ${prefs.available ? prefs.enabled ? 'enabled' : 'disabled' : 'not configured on this server'}\nLast delivery: ${prefs.lastDelivery ? `${prefs.lastDelivery.date} · ${deliveryLabels[prefs.lastDelivery.status] ?? 'Unknown status'}` : 'none yet'}\nQuiet hours: ${prefs.quietStart}–${prefs.quietEnd}${prefs.quietStart === prefs.quietEnd ? ' (off)' : ''}`;
|
||||
if (command === 'settings') return message(c, `**Settings**\nTimezone: ${text(tz)}${tz === 'UTC' ? '\nNew accounts start in UTC. Set your timezone for local habit days and reminder times.' : ''}\n${status}`, [row(button(c, 'Change timezone', 'prefs.timezone', state), button(c, 'Manage reminders', 'prefs.list'))]);
|
||||
const all = groups(c), page = Math.max(0, Math.min(data.page ?? 0, Math.ceil(all.length / 10) - 1)), visible = all.slice(page * 10, page * 10 + 10);
|
||||
return message(c, `**Reminders · ${text(tz)}**\n${status}\nReminders arrive by DM only when habits remain unfinished. Times use your timezone.\n${tz === 'UTC' ? 'Use Settings to choose your local timezone.\n' : ''}\n${visible.map(g => `• ${text(g.name)}: ${g.times.join(', ')} · ${schedule(g.entry)}`).join('\n') || 'No reminders. Add one to get started.'}\n${prefs.reminders.length}/20 time slots`, [
|
||||
...(visible.length ? [select(c, 'Choose a reminder', 'prefs.select', state, visible.map((g, i) => ({ id: String(page * 10 + i), name: g.name, description: `${g.times.join(', ')} · ${schedule(g.entry)}` })))] : []),
|
||||
...pages(c, 'prefs.list', { page }, all.length),
|
||||
row(button(c, 'Add reminder', 'prefs.edit', { ...state, index: undefined }), button(c, prefs.enabled ? 'Disable delivery' : 'Enable delivery', 'prefs.toggle', { ...state, done: !prefs.enabled }), button(c, 'Quiet hours', 'prefs.quiet', state)),
|
||||
if (command === 'settings') return screen(c, `**Settings**\nTimezone: ${text(tz)}${tz === 'UTC' ? '\nNew accounts start in UTC. Set your timezone for local habit days and reminder times.' : ''}\n${status}`, [row(button(c, 'Change timezone', 'prefs.timezone', state), button(c, 'Manage reminders', 'prefs.list'))]);
|
||||
const all = groups(c), page = listPage(data.page, all.length), visible = all.slice(page * listSize, (page + 1) * listSize);
|
||||
return componentMessage(c, [
|
||||
panel([display(`## Reminders · ${text(tz)}\n${status}\nReminders arrive by DM only when habits remain unfinished.\n${tz === 'UTC' ? 'Use Settings to choose your local timezone.\n' : ''}${prefs.reminders.length}/20 time slots · ${pageLabel(page, all.length)}${all.length ? '' : '\nNo reminders. Add one to get started.'}`)]),
|
||||
...visible.map((g, i) => panel([section(`**${text(g.name)}**\n${g.times.join(', ')} · ${schedule(g.entry)}`, button(c, 'Manage reminder', 'prefs.select', { ...state, index: page * listSize + i }))])),
|
||||
...pages(c, 'prefs.list', { page }, all.length, listSize),
|
||||
row(button(c, 'Add reminder', 'prefs.edit', { ...state, index: undefined }, 1), button(c, prefs.enabled ? 'Disable delivery' : 'Enable delivery', 'prefs.toggle', { ...state, done: !prefs.enabled }), button(c, 'Quiet hours', 'prefs.quiet', state)),
|
||||
row(button(c, 'Settings', 'prefs.settings'), button(c, 'Refresh', 'prefs.list', { page }), button(c, 'View schedule card', 'graphic.reminders')),
|
||||
]);
|
||||
}
|
||||
@@ -84,12 +85,12 @@ export function preferencesAction(c: Context, action: string, data: ControlData)
|
||||
if (action === 'prefs.quiet') { const p = service(c).read(); return modal(c, 'Quiet hours (same times = off)', 'prefs.quietSave', data, [{ id: 'start', label: 'Start (24-hour HH:MM)', value: data.draft?.start ?? p.quietStart, max: 5 }, { id: 'end', label: 'End (24-hour HH:MM)', value: data.draft?.end ?? p.quietEnd, max: 5 }]); }
|
||||
if (action === 'prefs.edit') return edit(c, data);
|
||||
if (action === 'prefs.select') {
|
||||
const selected = c.interaction.data?.values?.[0] ?? '';
|
||||
const selected = data.index === undefined ? c.interaction.data?.values?.[0] ?? '' : String(data.index);
|
||||
if (!/^\d+$/.test(selected)) throw new ApiError(404, 'Reminder not found.');
|
||||
const state = { ...data, index: Number(selected) }, g = group(c, state);
|
||||
return message(c, `**${text(g.name)}**\n${g.times.join(', ')} · ${schedule(g.entry)}\nTimezone: ${text(user(c).timezone)}`, [row(button(c, 'Edit reminder', 'prefs.edit', state), button(c, 'Remove reminder', 'prefs.remove', state, 4), button(c, 'Back', 'prefs.list'))]);
|
||||
return screen(c, `**${text(g.name)}**\n${g.times.join(', ')} · ${schedule(g.entry)}\nTimezone: ${text(user(c).timezone)}`, [row(button(c, 'Edit reminder', 'prefs.edit', state), button(c, 'Remove reminder', 'prefs.remove', state, 4), button(c, 'Back', 'prefs.list'))]);
|
||||
}
|
||||
if (action === 'prefs.remove') return message(c, `Remove **${text(group(c, data).name)}** and all its times?`, [row(button(c, 'Confirm removal', 'prefs.removeSave', data, 4), button(c, 'Cancel', 'prefs.list'))]);
|
||||
if (action === 'prefs.remove') return screen(c, `Remove **${text(group(c, data).name)}** and all its times?`, [row(button(c, 'Confirm removal', 'prefs.removeSave', data, 4), button(c, 'Cancel', 'prefs.list'))]);
|
||||
try {
|
||||
if (action === 'prefs.timezoneSave') {
|
||||
const parsed = timezoneInput.safeParse({ timezone: fields(c.interaction).timezone?.trim() });
|
||||
@@ -108,6 +109,6 @@ export function preferencesAction(c: Context, action: string, data: ControlData)
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError) || error.status !== 422) throw error;
|
||||
const retry = { 'prefs.save': 'prefs.edit', 'prefs.quietSave': 'prefs.quiet', 'prefs.timezoneSave': 'prefs.timezone' }[action];
|
||||
return message(c, error.message, [row(...(retry ? [button(c, 'Try again', retry, { ...data, draft: fields(c.interaction) })] : []), button(c, 'Reminders', 'prefs.list'), button(c, 'Settings', 'prefs.settings'))], false);
|
||||
return screen(c, error.message, [row(...(retry ? [button(c, 'Try again', retry, { ...data, draft: fields(c.interaction) })] : []), button(c, 'Reminders', 'prefs.list'), button(c, 'Settings', 'prefs.settings'))]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { renderImage } from '../rendering/queue';
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
// Import the existing renderer's bundled font registration and data contract.
|
||||
import { type LiveImageData } from '../sharing/live-card-image';
|
||||
@@ -8,7 +9,10 @@ import { cardDate } from '../lib/progress-card';
|
||||
|
||||
export const progressImageSize = { width: 1200, height: 860 };
|
||||
/** Focused, chronological 30-day view; constant font/cell sizes at any habit count. */
|
||||
export async function renderProgressImage(data: LiveImageData, theme: Theme = 'light') {
|
||||
export function renderProgressImage(data: LiveImageData, theme: Theme = 'light', userId = 'unscoped') {
|
||||
return renderImage('progress', userId, data, theme, () => draw(data, theme));
|
||||
}
|
||||
async function draw(data: LiveImageData, theme: Theme) {
|
||||
const habit = data.habits[0];
|
||||
if (!habit || data.habits.length !== 1 || habit.days.length !== 30) throw new Error('Choose one habit and a 30-day range.');
|
||||
const canvas = createCanvas(progressImageSize.width, progressImageSize.height);
|
||||
@@ -62,5 +66,6 @@ export async function renderProgressImage(data: LiveImageData, theme: Theme = 'l
|
||||
text(item.label, x + 36, 772, 21, 230, palette.muted);
|
||||
});
|
||||
text('A little, every day.', 64, 822, 27, 1072, palette.muted, true);
|
||||
return new Uint8Array(await canvas.encode('png'));
|
||||
try { return new Uint8Array(await canvas.encode('png')); }
|
||||
finally { canvas.width = 1; canvas.height = 1; }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, expect, test } from 'bun:test';
|
||||
import { createAuth } from '../auth';
|
||||
import { createSharingRoutes } from '../sharing/routes';
|
||||
import { createLiveDiscord } from '../sharing/live-discord';
|
||||
import { discordFixture, control } from './test-fixture';
|
||||
import { discordFixture, control, messageText, habitControl, findHabitPage } from './test-fixture';
|
||||
import type { DeferredMessage } from './progress';
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
let stop = () => {};
|
||||
@@ -23,24 +23,25 @@ function setup(settings: { allowed?: string[]; fail?: boolean; writes?: boolean
|
||||
return { jobs, replies, sends, drain: async () => { while (jobs.length) await jobs.shift()!(); return { data: replies.at(-1)!.message }; } };
|
||||
}
|
||||
async function click(reply: any, label: string, guild = false, values?: string[]) {
|
||||
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), values } }, guild);
|
||||
const id = label === 'Preview' && values?.[0] ? habitControl(reply, (await f.json(`/habits/${values[0]}`)).name, label) : control(reply, label);
|
||||
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: id, values } }, guild);
|
||||
}
|
||||
for (const guild of [false, true]) test(`progress preview, explicit snapshot and live lifecycle in ${guild ? 'server' : 'DM'}`, async () => {
|
||||
const env = setup();
|
||||
const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201);
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
const panel = await f.run({ data: { name: 'progress' } }, guild);
|
||||
expect(panel.data.content).toContain('Reading — 1/1');
|
||||
expect(panel.data.flags).toBe(guild ? 64 : undefined);
|
||||
expect(await click(panel, 'Preview a habit', guild, [habit.id])).toEqual({ type: 5, data: { flags: 64 } });
|
||||
expect(messageText(panel)).toContain('1/1 scheduled days complete');
|
||||
expect(panel.data.flags).toBe(32768 | (guild ? 64 : 0));
|
||||
expect(await click(panel, 'Preview', guild, [habit.id])).toEqual({ type: 6 });
|
||||
expect(env.sends).toHaveLength(0);
|
||||
const preview = await env.drain();
|
||||
expect(env.replies.at(-1)!.image!.slice(0, 8)).toEqual(new Uint8Array([137,80,78,71,13,10,26,10]));
|
||||
expect(preview.data.content).toContain('1/1');
|
||||
expect(messageText(preview)).toContain('1/1');
|
||||
await click(preview, 'Choose public destination', guild);
|
||||
const confirmation = await env.drain();
|
||||
expect(confirmation.data.content).toContain('habit-sharing');
|
||||
expect(confirmation.data.content).toContain('https://discord.com/channels/666666666666666666/555555555555555555');
|
||||
expect(messageText(confirmation)).toContain('habit-sharing');
|
||||
expect(messageText(confirmation)).toContain('https://discord.com/channels/666666666666666666/555555555555555555');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
const i = f.payload({ type: 3, data: { custom_id: control(confirmation, 'Share snapshot publicly') } }, guild);
|
||||
await f.requestDiscord(i); await env.drain();
|
||||
@@ -62,28 +63,30 @@ test('pagination, cross-account controls, stale snapshot and uncertain delivery
|
||||
const env = setup({ fail: true });
|
||||
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
|
||||
for (let n = 0; n < 6; n++) await f.json('/habits', 'POST', { name: `Habit ${n}`, method: 'manual' }, 201);
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
let panel = await f.run({ data: { name: 'progress' } });
|
||||
expect(control(panel, 'Next')).toBeString();
|
||||
const stolen = await f.run({ type: 3, data: { custom_id: control(panel, 'Preview a habit'), values: [h.id] } }, true, f.bob);
|
||||
expect(stolen.data.content).toContain('belongs to another user');
|
||||
await click(panel, 'Preview a habit', false, [h.id]); const preview = await env.drain();
|
||||
panel = await findHabitPage(f, panel, 'Read');
|
||||
const previewId = habitControl(panel, 'Read', 'Preview');
|
||||
const stolen = await f.run({ type: 3, data: { custom_id: previewId, values: [h.id] } }, true, f.bob);
|
||||
expect(messageText(stolen)).toContain('belongs to another user');
|
||||
await click(panel, 'Preview', false, [h.id]); const preview = await env.drain();
|
||||
await click(preview, 'Choose public destination'); const confirm = await env.drain();
|
||||
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
expect((await click(confirm, 'Share snapshot publicly')).data.content).toContain('changed');
|
||||
expect(messageText((await click(confirm, 'Share snapshot publicly')))).toContain('changed');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
expect((await click(preview, 'Choose public destination')).data.content).toContain('changed');
|
||||
await click(panel, 'Preview a habit', false, [h.id]); const newPreview = await env.drain();
|
||||
expect(messageText((await click(preview, 'Choose public destination')))).toContain('changed');
|
||||
await click(panel, 'Preview', false, [h.id]); const newPreview = await env.drain();
|
||||
await click(newPreview, 'Choose public destination'); const fresh = await env.drain();
|
||||
await click(fresh, 'Share snapshot publicly'); const result = await env.drain();
|
||||
expect(result.data.content).toContain('could not confirm');
|
||||
expect((await click(fresh, 'Share snapshot publicly')).data.content).toContain('expired');
|
||||
expect(messageText(result)).toContain('could not confirm');
|
||||
expect(messageText((await click(fresh, 'Share snapshot publicly')))).toContain('expired');
|
||||
expect(env.sends).toHaveLength(1);
|
||||
});
|
||||
test('allowlist and disabled writes apply to command shares', async () => {
|
||||
const env = setup({ allowed: ['222222222222222222'] });
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
await click(panel, 'Live image controls'); const result = await env.drain();
|
||||
expect(result.data.content).toContain('approved community');
|
||||
expect(messageText(result)).toContain('approved community');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
});
|
||||
test('disabled writes, shared snapshot quotas, and stale live confirmations', async () => {
|
||||
@@ -91,24 +94,65 @@ test('disabled writes, shared snapshot quotas, and stale live confirmations', as
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
await click(panel, 'Live image controls'); const live = await env.drain();
|
||||
await click(live, 'Start live image publicly'); const denied = await env.drain();
|
||||
expect(denied.data.content).toContain('disabled in development');
|
||||
expect(messageText(denied)).toContain('disabled in development');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
});
|
||||
test('snapshot quotas are shared with website operations and new controls cannot bypass them', async () => {
|
||||
const env = setup();
|
||||
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
await click(panel, 'Preview a habit', false, [h.id]); const preview = await env.drain();
|
||||
await click(panel, 'Preview', false, [h.id]); const preview = await env.drain();
|
||||
await click(preview, 'Choose public destination'); const first = await env.drain();
|
||||
await click(first, 'Share snapshot publicly'); await env.drain();
|
||||
await click(preview, 'Choose public destination'); const next = await env.drain();
|
||||
await click(next, 'Share snapshot publicly'); const limited = await env.drain();
|
||||
expect(limited.data.content).toContain('Sharing limit reached');
|
||||
expect(messageText(limited)).toContain('Sharing limit reached');
|
||||
expect(env.sends).toHaveLength(1);
|
||||
await click(panel, 'Live image controls'); const live = await env.drain();
|
||||
await f.json('/habits', 'POST', { name: 'New private habit', method: 'manual' }, 201);
|
||||
expect((await click(live, 'Start live image publicly')).data.content).toContain('changed');
|
||||
expect(messageText((await click(live, 'Start live image publicly')))).toContain('changed');
|
||||
expect(env.sends).toHaveLength(1);
|
||||
f.setDiscordTime('2026-09-04T12:16:00Z');
|
||||
expect((await click(preview, 'Choose public destination')).data.content).toContain('expired');
|
||||
expect(messageText((await click(preview, 'Choose public destination')))).toContain('expired');
|
||||
});
|
||||
|
||||
test('a website edit during a deferred public share invalidates the prepared snapshot', async () => {
|
||||
const env = setup();
|
||||
const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201);
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
await click(panel, 'Preview', false, [habit.id]); const preview = await env.drain();
|
||||
await click(preview, 'Choose public destination'); const confirmation = await env.drain();
|
||||
await click(confirmation, 'Share snapshot publicly');
|
||||
const delivery = env.drain();
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
const result = await delivery;
|
||||
expect(messageText(result)).toContain('changed');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
});
|
||||
|
||||
for (const guild of [false, true]) test(`progress previews, theme changes, errors and back navigation edit the original (${guild ? 'server' : 'DM'})`, async () => {
|
||||
const env = setup();
|
||||
await f.json('/habits', 'POST', { name: 'Read', method: 'manual', color: '#123456' }, 201);
|
||||
const list = await f.run({ data: { name: 'progress' } }, guild);
|
||||
expect(await click(list, 'Preview', guild)).toEqual({ type: 6 });
|
||||
let preview = await env.drain();
|
||||
expect(preview.data.flags).toBe(32768);
|
||||
expect(preview.data.content).toBeNull();
|
||||
expect(preview.data.components![0]!.accent_color).toBe(0x123456);
|
||||
expect(preview.data.components!.at(-1)).toMatchObject({ type: 12, items: [{ media: { url: 'attachment://mina-progress.png' } }] });
|
||||
expect(await click(preview, 'Toggle theme', guild)).toEqual({ type: 6 });
|
||||
preview = await env.drain();
|
||||
const back = await click(preview, 'Back to private progress', guild);
|
||||
expect(back.type).toBe(7);
|
||||
expect(back.data.attachments).toEqual([]);
|
||||
expect(back.data.components.some((c: any) => c.type === 12)).toBe(false);
|
||||
expect(env.sends).toHaveLength(0);
|
||||
// Change the website after previewing; the error must still be a usable V2 update.
|
||||
const h = (await f.json('/habits')).habits[0];
|
||||
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
const failed = await click(preview, 'Choose public destination', guild);
|
||||
expect(failed.type).toBe(7);
|
||||
expect(failed.data.flags).toBe(32768);
|
||||
expect(messageText(failed)).toContain('Progress changed');
|
||||
expect((await click(failed, 'Back to private progress', guild)).type).toBe(7);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { renderProgressImage } from './progress-image';
|
||||
import { ApiError } from '../habits/service';
|
||||
import { ApiError, HabitService } from '../habits/service';
|
||||
import { liveCardData } from '../sharing/live-card-image';
|
||||
import type { createSharingRoutes } from '../sharing/routes';
|
||||
import type { createLiveDiscord } from '../sharing/live-discord';
|
||||
import type { Context, ControlData, Message, Reply } from './types';
|
||||
import { button, message, pages, row, select, text } from './ui';
|
||||
import { button, screen, pages, row, text, componentMessage, display, panel, section, listSize, listPage, pageLabel } from './ui';
|
||||
|
||||
export type ProgressOperations = {
|
||||
sharing: Pick<ReturnType<typeof createSharingRoutes>, 'send' | 'destination'>;
|
||||
@@ -14,9 +14,11 @@ export type ProgressOperations = {
|
||||
export type DeferredMessage = { message: Message; image?: Uint8Array; imageDescription?: string };
|
||||
const fingerprint = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
function imageData(c: Context, data: ControlData) {
|
||||
const result = liveCardData(c.db, c.service.user.id, c.now);
|
||||
// Preparation happened before dispatch. Fresh read caches detect edits across deferred awaits.
|
||||
const snapshot = new HabitService(c.db, c.service.user, c.now);
|
||||
const result = liveCardData(c.db, c.service.user.id, c.now, snapshot);
|
||||
if (data.habitId) {
|
||||
const habits = c.service.list();
|
||||
const habits = snapshot.list();
|
||||
const index = habits.findIndex(h => h.id === data.habitId);
|
||||
if (index < 0) throw new ApiError(409, 'This habit changed. Run /progress again.');
|
||||
result.habits = [result.habits[index]!];
|
||||
@@ -24,27 +26,36 @@ function imageData(c: Context, data: ControlData) {
|
||||
return { ...result, avatarUrl: null };
|
||||
}
|
||||
async function render(c: Context, data: ControlData, source = imageData(c, data)) {
|
||||
return renderProgressImage(source, data.theme ?? 'light');
|
||||
return renderProgressImage(source, data.theme ?? 'light', c.service.user.id);
|
||||
}
|
||||
function output(reply: Reply, image?: Uint8Array): DeferredMessage {
|
||||
const message = reply.data as Message;
|
||||
if (image) message.components = [...(message.components ?? []), { type: 12, items: [{ media: { url: 'attachment://mina-progress.png' }, description: 'Habit progress for the selected period. Exact totals are shown above.' }] }];
|
||||
return { message, image, imageDescription: image ? 'Habit progress for the selected period. Exact totals are shown in the message.' : undefined };
|
||||
}
|
||||
function output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; }
|
||||
function defer(c: Context, job: () => Promise<DeferredMessage>): Reply {
|
||||
if (!c.defer) throw new ApiError(503, 'Progress rendering is unavailable. Try again later.');
|
||||
c.defer(job);
|
||||
return { type: 5, data: { flags: 64 } };
|
||||
c.defer(async () => {
|
||||
try { return await job(); }
|
||||
catch (error) {
|
||||
return output(screen(c, error instanceof ApiError ? error.message : 'Could not complete this action. Check the destination before trying another share.', [row(button(c, 'Back to private progress', 'progress'))]));
|
||||
}
|
||||
});
|
||||
return c.interaction.message && [3, 5].includes(c.interaction.type) ? { type: 6 } : { type: 5, data: { flags: c.interaction.guild_id ? 64 : 0 } };
|
||||
}
|
||||
export function progress(c: Context, data: ControlData = {}): Reply {
|
||||
const habits = c.service.list(), page = data.page ?? 0;
|
||||
const visible = habits.slice(page * 6, page * 6 + 6);
|
||||
const habits = c.service.list(), page = listPage(data.page, habits.length);
|
||||
const result = imageData(c, {});
|
||||
const state = c.progress?.live.state(c.service.user.id);
|
||||
return message(c, `**Progress · ${result.from} to ${result.to}**\n${text(c.service.user.timezone)} · Live image: ${state?.status ?? 'unavailable'}\n\n${visible.map(h => {
|
||||
const stats = result.habits[habits.findIndex(v => v.id === h.id)]!;
|
||||
return `${text(h.name)} — ${stats.completed}/${stats.scheduled} scheduled days complete`;
|
||||
}).join('\n') || 'No active habits. Create a habit on the website or with /habits.'}\n\nPreviews are private. A public share requires confirmation of the configured destination.`, [
|
||||
...(visible.length ? [select(c, 'Preview a habit', 'progress.preview', {}, visible.map(h => ({ id: h.id, name: h.name })))] : []),
|
||||
...pages(c, 'progress', data, habits.length, 6),
|
||||
row(button(c, 'Live image controls', 'progress.live'), button(c, 'Refresh progress', 'progress')),
|
||||
], false);
|
||||
return componentMessage(c, [
|
||||
panel([display(`## Progress\n${result.from} to ${result.to} · ${text(c.service.user.timezone)}\nLive image: ${state?.status ?? 'unavailable'} · ${pageLabel(page, habits.length)}\nPreviews are private. Public sharing requires confirmation.${habits.length ? '' : '\nNo active habits. Create one with /habits.'}`)]),
|
||||
...habits.slice(page * listSize, (page + 1) * listSize).map(h => {
|
||||
const stats = result.habits[habits.findIndex(v => v.id === h.id)]!;
|
||||
return panel([section(`**${text(h.name)}**\n${stats.completed}/${stats.scheduled} scheduled days complete`, button(c, 'Preview', 'progress.preview', { habitId: h.id, page }))], c.service.settings(h.id).mainColor);
|
||||
}),
|
||||
...pages(c, 'progress', { page }, habits.length, listSize),
|
||||
row(button(c, 'Live image controls', 'progress.live'), button(c, 'Refresh progress', 'progress', { page })),
|
||||
]);
|
||||
}
|
||||
export function progressAction(c: Context, action: string, data: ControlData): Reply | undefined {
|
||||
if (action === 'progress') return progress(c, data);
|
||||
@@ -52,15 +63,15 @@ export function progressAction(c: Context, action: string, data: ControlData): R
|
||||
const ops = c.progress;
|
||||
if (!ops) throw new ApiError(503, 'Progress sharing is unavailable.');
|
||||
if (action === 'progress.preview') {
|
||||
const habitId = c.interaction.data?.values?.[0] ?? data.habitId;
|
||||
const habitId = data.habitId ?? c.interaction.data?.values?.[0];
|
||||
c.service.current(habitId!);
|
||||
const selection = { habitId, theme: data.theme ?? 'light' as const };
|
||||
const selection = { habitId, page: data.page, theme: data.theme ?? 'light' as const };
|
||||
return defer(c, async () => {
|
||||
const content = imageData(c, selection);
|
||||
const image = await render(c, selection, content);
|
||||
const confirmed = { ...selection, expected: fingerprint(content) };
|
||||
const stats = content.habits[0]!;
|
||||
return output(message(c, `**Private preview · ${text(stats.name)}**\n${content.from} to ${content.to} · ${text(c.service.user.timezone)}\n${stats.completed}/${stats.scheduled} scheduled days complete.\nThe snapshot includes your display name and this habit's name and progress.`, [row(button(c, 'Choose public destination', 'progress.confirm', confirmed), button(c, 'Toggle theme', 'progress.preview', { ...selection, theme: selection.theme === 'light' ? 'dark' : 'light' }))], false), image);
|
||||
return output(screen(c, `**Private preview · ${text(stats.name)}**\n${content.from} to ${content.to} · ${text(c.service.user.timezone)}\n${stats.completed}/${stats.scheduled} scheduled days complete.\nThe snapshot includes your display name and this habit's name and progress.`, [row(button(c, 'Choose public destination', 'progress.confirm', confirmed), button(c, 'Toggle theme', 'progress.preview', { ...selection, theme: selection.theme === 'light' ? 'dark' : 'light' }), button(c, 'Back to private progress', 'progress', { page: data.page }))], true, c.service.settings(habitId!).mainColor), image);
|
||||
});
|
||||
}
|
||||
if (action === 'progress.confirm' && data.expected !== fingerprint(imageData(c, data))) throw new ApiError(409, 'Progress changed. Open a fresh preview before sharing.');
|
||||
@@ -69,7 +80,7 @@ export function progressAction(c: Context, action: string, data: ControlData): R
|
||||
const destination = await ops.sharing.destination(c.service.user);
|
||||
const state = ops.live.state(c.service.user.id);
|
||||
const selection = { ...data, expected: action === 'progress.confirm' ? data.expected : fingerprint(liveSnapshot()), deliveryId: crypto.randomUUID(), mode: destination.channelUrl };
|
||||
return output(message(c, `${action === 'progress.confirm' ? '**Share snapshot?**' : `**Live image · ${state.status}**`}\nPublic destination: [${text(destination.name)}](${destination.channelUrl})\n${action === 'progress.confirm' ? 'Shares your display name and the selected habit’s progress.' : 'The live image includes your display name and all active habit names and progress. It updates in the same message as progress changes.'}${state.messageUrl ? `\n[Open live image](${state.messageUrl})` : ''}${state.error ? `\n${text(state.error)}` : ''}`, [row(...(action === 'progress.confirm' ? [button(c, 'Share snapshot publicly', 'progress.send', selection, 1)] : [button(c, state.status === 'off' ? 'Start live image publicly' : 'Resume live image publicly', 'progress.start', selection, 1), button(c, 'Pause live image', 'progress.pause', selection)])), row(button(c, 'Back to private progress', 'progress'))], false));
|
||||
return output(screen(c, `${action === 'progress.confirm' ? '**Share snapshot?**' : `**Live image · ${state.status}**`}\nPublic destination: [${text(destination.name)}](${destination.channelUrl})\n${action === 'progress.confirm' ? 'Shares your display name and the selected habit’s progress.' : 'The live image includes your display name and all active habit names and progress. It updates in the same message as progress changes.'}${state.messageUrl ? `\n[Open live image](${state.messageUrl})` : ''}${state.error ? `\n${text(state.error)}` : ''}`, [row(...(action === 'progress.confirm' ? [button(c, 'Share snapshot publicly', 'progress.send', selection, 1)] : [button(c, state.status === 'off' ? 'Start live image publicly' : 'Resume live image publicly', 'progress.start', selection, 1), button(c, 'Pause live image', 'progress.pause', selection)])), row(button(c, 'Back to private progress', 'progress'))]));
|
||||
});
|
||||
if (['progress.send', 'progress.start', 'progress.pause'].includes(action)) {
|
||||
const current = action === 'progress.send' ? imageData(c, data) : liveSnapshot();
|
||||
@@ -87,11 +98,11 @@ export function progressAction(c: Context, action: string, data: ControlData): R
|
||||
const source = imageData(c, data);
|
||||
const image = await render(c, data, source);
|
||||
if (fingerprint(imageData(c, data)) !== data.expected) throw new ApiError(409, 'Progress changed during rendering. Open a fresh preview.');
|
||||
const delivery = await ops.sharing.send(c.service.user, data.deliveryId!, new File([image], 'mina-habits-progress.png', { type: 'image/png' }));
|
||||
return output(message(c, delivery.status === 'sent' ? `Snapshot shared.${delivery.messageUrl ? ` [Open snapshot](${delivery.messageUrl})` : ''}` : 'Discord could not confirm delivery. Check the destination before creating another snapshot; this action will not be retried.', [], false));
|
||||
const delivery = await ops.sharing.send(c.service.user, data.deliveryId!, new File([new Uint8Array(image)], 'mina-habits-progress.png', { type: 'image/png' }));
|
||||
return output(screen(c, delivery.status === 'sent' ? `Snapshot shared.${delivery.messageUrl ? ` [Open snapshot](${delivery.messageUrl})` : ''}` : 'Discord could not confirm delivery. Check the destination before creating another snapshot; this action will not be retried.', [row(button(c, 'Back to private progress', 'progress'))]));
|
||||
}
|
||||
const state = action === 'progress.pause' ? await ops.live.pause(c.service.user.id) : await ops.live.start(c.service.user, data.theme ?? 'light');
|
||||
return output(message(c, `Live image: **${state.status}**.${state.messageUrl ? ` [Open live image](${state.messageUrl})` : ''}${state.error ? `\n${text(state.error)}` : ''}`, [row(button(c, 'Live image status', 'progress.live'), button(c, 'Private progress', 'progress'))], false));
|
||||
return output(screen(c, `Live image: **${state.status}**.${state.messageUrl ? ` [Open live image](${state.messageUrl})` : ''}${state.error ? `\n${text(state.error)}` : ''}`, [row(button(c, 'Live image status', 'progress.live'), button(c, 'Private progress', 'progress'))]));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { discordFixture, control, submit } from './test-fixture';
|
||||
import { discordFixture, messageText, control, submit } from './test-fixture';
|
||||
import { commands } from './commands';
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
afterEach(() => f?.close());
|
||||
@@ -17,8 +17,8 @@ describe('signed Discord interactions', () => {
|
||||
for (const guild of [false, true]) test(`records progress from ${guild ? 'server' : 'DM'} and shares website state`, async () => {
|
||||
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Reading', method: 'count', target: 10, unit: 'pages' }, 201);
|
||||
const today = await f.run({ data: { name: 'today' } }, guild);
|
||||
expect(today.data.flags).toBe(guild ? 64 : undefined);
|
||||
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(today, 'Choose a habit'), values: [h.id] } }, guild);
|
||||
expect(today.data.flags).toBe(32768 | (guild ? 64 : 0));
|
||||
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(today, 'Details'), values: [h.id] } }, guild);
|
||||
const click = f.payload({ type: 3, message: { id: '123' }, data: { custom_id: control(detail, '+1') } }, guild);
|
||||
expect((await (await f.requestDiscord(click)).json()).type).toBe(7);
|
||||
f.restart(); await f.requestDiscord(click);
|
||||
@@ -26,20 +26,20 @@ describe('signed Discord interactions', () => {
|
||||
expect(f.changed).toEqual(['alice']);
|
||||
const stale = await f.run({ type: 3, data: { custom_id: control(detail, 'Enter amount') } }, guild);
|
||||
const failed = await f.run(submit(stale.data.custom_id, { amount: '7' }), guild);
|
||||
expect(failed.data.content).toContain('Progress changed');
|
||||
expect(messageText(failed)).toContain('Progress changed');
|
||||
});
|
||||
test('owner-bound buttons and habit IDs prevent cross-account access', async () => {
|
||||
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Private', method: 'manual' }, 201);
|
||||
const a = await f.run({ data: { name: 'today' } });
|
||||
const blocked = await f.run({ type: 3, data: { custom_id: control(a, 'Choose a habit'), values: [h.id] } }, true, f.bob);
|
||||
expect(blocked.data.content).toContain('belongs to another user'); expect(blocked.data.flags).toBe(64);
|
||||
const blocked = await f.run({ type: 3, data: { custom_id: control(a, 'Details'), values: [h.id] } }, true, f.bob);
|
||||
expect(messageText(blocked)).toContain('belongs to another user'); expect(blocked.data.flags).toBe(64);
|
||||
const b = await f.run({ data: { name: 'today' } }, false, f.bob);
|
||||
expect(b.data.content).not.toContain('Private');
|
||||
expect(messageText(b)).not.toContain('Private');
|
||||
});
|
||||
test('old daily buttons cannot correct history accidentally', async () => {
|
||||
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
|
||||
const t = await f.run({ data: { name: 'today' } });
|
||||
const d = await f.run({ type: 3, data: { custom_id: control(t, 'Choose a habit'), values: [h.id] } });
|
||||
const d = await f.run({ type: 3, data: { custom_id: control(t, 'Details'), values: [h.id] } });
|
||||
f.setDiscordTime('2026-09-05T12:00:00Z');
|
||||
expect((await f.run({ type: 3, data: { custom_id: control(d, 'Mark complete') } })).data.content).toContain('expired');
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { manageHabits, manageTasks, managementAction } from './management';
|
||||
import { history, historyAction } from './history';
|
||||
import { preferences, preferencesAction } from './preferences';
|
||||
import { progress, progressAction, type DeferredMessage, type ProgressOperations } from './progress';
|
||||
import { message } from './ui';
|
||||
import { message, screen, button, row } from './ui';
|
||||
import { help } from './help';
|
||||
import { commandGraphicAction } from './command-graphics';
|
||||
|
||||
@@ -24,12 +24,13 @@ export type DeferredDispatch = {
|
||||
schedule?: (job: () => Promise<void>) => void;
|
||||
};
|
||||
/** Edit the deferred original exactly once. Never retry an uncertain network result. */
|
||||
export async function completeDeferred(i: import('./types').Interaction, result: DeferredMessage) {
|
||||
export async function completeDeferred(i: import('./types').Interaction, result: DeferredMessage, request: (url: string, init: RequestInit) => Promise<Response> = fetch) {
|
||||
const body = new FormData();
|
||||
const payload = { ...result.message, flags: undefined, allowed_mentions: { parse: [] }, attachments: result.image ? [{ id: 0, filename: 'mina-progress.png', description: (result.imageDescription ?? result.message.content)?.slice(0, 1024) }] : [] };
|
||||
const native = !!(result.message.flags && (result.message.flags & 32768));
|
||||
const payload = { ...result.message, flags: native ? 32768 : undefined, ...(native ? { content: null, embeds: [] } : {}), allowed_mentions: { parse: [] }, attachments: result.image ? [{ id: 0, filename: 'mina-progress.png', description: (result.imageDescription ?? result.message.content)?.slice(0, 1024) }] : [] };
|
||||
body.set('payload_json', JSON.stringify(payload));
|
||||
if (result.image) body.set('files[0]', new Blob([new Uint8Array(result.image)], { type: 'image/png' }), 'mina-progress.png');
|
||||
const response = await fetch(`https://discord.com/api/v10/webhooks/${i.application_id}/${encodeURIComponent(i.token!)}/messages/@original`, { method: 'PATCH', body, redirect: 'error', signal: AbortSignal.timeout(15000) });
|
||||
const response = await request(`https://discord.com/api/v10/webhooks/${i.application_id}/${encodeURIComponent(i.token!)}/messages/@original`, { method: 'PATCH', body, redirect: 'error', signal: AbortSignal.timeout(15000) });
|
||||
if (!response.ok) throw new Error('Deferred Discord response was rejected.');
|
||||
}
|
||||
export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now = Date.now, changed: (userId: string) => void = () => {}, dispatch: DeferredDispatch = {}) {
|
||||
@@ -54,17 +55,20 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
|
||||
// Help needs no account creation, habit synchronization, or saved UI state.
|
||||
if (i.type === 2 && i.data?.name === 'help') return c.json(help(!!i.guild_id, config.origin));
|
||||
let pending: (() => Promise<DeferredMessage>) | undefined;
|
||||
const reply = db.transaction((): Reply => {
|
||||
const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get();
|
||||
const userId = existing?.id ?? crypto.randomUUID();
|
||||
if (!existing) db.insert(users).values({ id: userId, discordId: profile.id, username: profile.username, globalName: profile.global_name, avatarHash: profile.avatar, createdAt: now(), updatedAt: now() }).run();
|
||||
// Resolve the account before queueing catch-up; no transaction is held across yields.
|
||||
const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get();
|
||||
const userId = existing?.id ?? crypto.randomUUID();
|
||||
if (!existing) db.insert(users).values({ id: userId, discordId: profile.id, username: profile.username, globalName: profile.global_name, avatarHash: profile.avatar, createdAt: now(), updatedAt: now() }).run();
|
||||
const receipt = db.select().from(discordReceipts).where(eq(discordReceipts.id, i.id)).get();
|
||||
if (receipt && receipt.expiresAt > now()) return c.json(receipt.response);
|
||||
const owner = publicUser(db.select().from(users).where(eq(users.id, userId)).get()!);
|
||||
const reply = await HabitService.withPrepared(db, owner, now, service => db.transaction((): Reply => {
|
||||
const receipt = db.select().from(discordReceipts).where(eq(discordReceipts.id, i.id)).get();
|
||||
if (receipt && receipt.expiresAt > now()) return receipt.response;
|
||||
const user = publicUser(db.select().from(users).where(eq(users.id, userId)).get()!);
|
||||
const controls = new ControlStore(db, userId, now()); controls.prune();
|
||||
const service = new HabitService(db, user, now()); service.sync();
|
||||
const ctx: Context = { db, service, controls, interaction: i, now: now(), origin: config.origin, remindersAvailable: config.remindersAvailable ?? false, changed: () => changed(userId), progress: dispatch.progress, defer: i.token ? job => { pending = job; } : undefined };
|
||||
let result: Reply;
|
||||
let ownedAction: string | undefined;
|
||||
try {
|
||||
if (i.type === 4) result = { type: 8, data: { choices: [] } };
|
||||
else if (i.type === 2 && i.data?.name === 'tasks') result = manageTasks(ctx);
|
||||
@@ -75,15 +79,18 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
|
||||
else if (i.type === 2 && i.data?.name === 'history') result = history(ctx);
|
||||
else if ([3, 5].includes(i.type)) {
|
||||
const state = controls.read(i.data?.custom_id ?? '');
|
||||
ownedAction = state.action;
|
||||
result = commandGraphicAction(ctx, state.action, state.payload) ?? progressAction(ctx, state.action, state.payload) ?? preferencesAction(ctx, state.action, state.payload) ?? historyAction(ctx, state.action, state.payload) ?? dailyAction(ctx, state.action, state.payload) ?? managementAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false);
|
||||
} else result = message(ctx, 'Run /help to see available commands.', [], false);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;
|
||||
result = message(ctx, error instanceof ApiError ? error.message : 'Check the entered values and try again.', [], false);
|
||||
const reason = error instanceof ApiError ? error.message : 'Check the entered values and try again.';
|
||||
const home = ownedAction?.startsWith('manage:') ? ['Back to habits', 'manage:list'] : ownedAction?.startsWith('prefs.') ? ['Reminders', 'prefs.list'] : ownedAction?.startsWith('progress') ? ['Back to private progress', 'progress'] : ownedAction?.startsWith('history') ? ['History', 'history'] : undefined;
|
||||
result = home ? screen(ctx, reason, [row(button(ctx, home[0]!, home[1]!))]) : message(ctx, reason, [], false);
|
||||
}
|
||||
db.insert(discordReceipts).values({ id: i.id, userId, response: result, expiresAt: now() + 86400000 }).run();
|
||||
return result;
|
||||
});
|
||||
}));
|
||||
if (pending) {
|
||||
const work = pending;
|
||||
const job = async () => {
|
||||
|
||||
@@ -28,10 +28,36 @@ export function discordFixture(setup?: boolean | ((f: ReturnType<typeof fixture>
|
||||
return { ...f, alice, bob, changed, payload, requestDiscord: request, run, restart: () => { app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin, remindersAvailable }, () => time, id => changed.push(id), dispatch); }, setDiscordTime: (value: string) => { time = Date.parse(value); f.setTime(value); } };
|
||||
}
|
||||
export function control(reply: any, label: string): string {
|
||||
const item = reply.data.components.flatMap((r: any) => r.components ?? []).find((c: any) => c.label === label || c.placeholder === label);
|
||||
const item = components(reply.data.components).find((c: any) => c.label === label || c.placeholder === label);
|
||||
if (!item) throw new Error(`Missing control ${label}: ${JSON.stringify(reply)}`);
|
||||
return item.custom_id;
|
||||
}
|
||||
export function submit(custom_id: string, values: Record<string, string>) {
|
||||
return { type: 5, message: { id: '123' }, data: { custom_id, components: Object.entries(values).map(([custom_id, value]) => ({ type: 1, components: [{ type: 4, custom_id, value }] })) } };
|
||||
}
|
||||
|
||||
export function components(items: any[] = []): any[] {
|
||||
return items.flatMap(item => [item, ...components(item.components), ...(item.accessory ? components([item.accessory]) : [])]);
|
||||
}
|
||||
export function messageText(reply: any): string {
|
||||
return [reply.data.content, ...components(reply.data.components).map(item => item.content)].filter(Boolean).join('\n');
|
||||
}
|
||||
export function habitControl(reply: any, name: string, label = 'Details'): string {
|
||||
const item = reply.data.components.find((item: any) => item.type === 17 && messageText({ data: { components: [item] } }).includes(`**${name}**`));
|
||||
if (!item) throw new Error(`Missing habit ${name}: ${JSON.stringify(reply)}`);
|
||||
return control({ data: { components: [item] } }, label);
|
||||
}
|
||||
|
||||
export async function findHabitPage(f: ReturnType<typeof discordFixture>, reply: any, name: string, guild = false) {
|
||||
for (let page = 0; page < 100; page++) {
|
||||
if (messageText(reply).includes(`**${name}**`)) return reply;
|
||||
if (!components(reply.data.components).some(c => c.label === 'Next')) break;
|
||||
reply = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, 'Next') } }, guild);
|
||||
}
|
||||
throw new Error(`Missing habit ${name}: ${JSON.stringify(reply)}`);
|
||||
}
|
||||
export function taskControl(reply: any, name: string): string {
|
||||
const item = components(reply.data.components).find(c => c.type === 9 && [`○ ${name}`, `✓ ${name}`].includes(c.components[0].content));
|
||||
if (!item) throw new Error(`Missing task ${name}: ${JSON.stringify(reply)}`);
|
||||
return item.accessory.custom_id;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ type ComponentInput = { type: number; custom_id?: string; value?: string; values
|
||||
export const interactionSchema = z.object({ id: z.string().regex(/^\d{17,20}$/), application_id: z.string(), type: z.number(), token: z.string().max(2048).optional(), guild_id: z.string().optional(), user: profile.optional(), member: z.object({ user: profile }).optional(), message: z.object({ id: z.string() }).optional(), data: z.object({ name: z.string().optional(), custom_id: z.string().max(100).optional(), values: z.array(z.string()).optional(), options: z.array(option).optional(), components: z.array(component).optional() }).optional() });
|
||||
export type Interaction = z.infer<typeof interactionSchema>;
|
||||
export type DiscordComponent = { type: number; [key: string]: unknown };
|
||||
export type Message = { content?: string; components?: DiscordComponent[]; flags?: number; allowed_mentions?: { parse: string[] }; attachments?: unknown[]; embeds?: unknown[] };
|
||||
export type Message = { content?: string | null; components?: DiscordComponent[]; flags?: number; allowed_mentions?: { parse: string[] }; attachments?: unknown[]; embeds?: unknown[] };
|
||||
export type Reply = { type: number; data?: Message | { custom_id: string; title: string; components: DiscordComponent[] } | { choices: { name: string; value: string }[] } };
|
||||
export type ControlData = { habitId?: string; taskId?: string; date?: string; today?: boolean; page?: number; mode?: string; method?: string; expected?: string; value?: number; done?: boolean; revision?: number; index?: number; theme?: 'light' | 'dark'; deliveryId?: string; preferenceVersion?: string; timezoneVersion?: string; draft?: Record<string, string> };
|
||||
export type ControlData = { dashboardPage?: number; habitId?: string; taskId?: string; date?: string; today?: boolean; page?: number; mode?: string; method?: string; expected?: string; value?: number; done?: boolean; revision?: number; index?: number; theme?: 'light' | 'dark'; deliveryId?: string; preferenceVersion?: string; timezoneVersion?: string; draft?: Record<string, string> };
|
||||
export type Context = { db: AppDatabase; service: HabitService; controls: ControlStore; interaction: Interaction; now: number; origin: string; remindersAvailable?: boolean; changed: () => void; progress?: ProgressOperations; defer?: (job: () => Promise<DeferredMessage>) => void };
|
||||
export function fields(i: Interaction) {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
@@ -19,3 +19,20 @@ export function pages(c: Context, action: string, data: ControlData, total: numb
|
||||
if ((page + 1) * size < total) buttons.push(button(c, 'Next', action, { ...data, page: page + 1 }));
|
||||
return buttons.length ? [row(...buttons)] : [];
|
||||
}
|
||||
|
||||
/** Native Discord layouts; colors come from the existing habit settings. */
|
||||
export const display = (content: string): DiscordComponent => ({ type: 10, content });
|
||||
export const section = (content: string, accessory: DiscordComponent): DiscordComponent => ({ type: 9, components: [display(content)], accessory });
|
||||
export const panel = (components: DiscordComponent[], color = '#285d49'): DiscordComponent => ({ type: 17, accent_color: parseInt(color.replace('#', ''), 16), components });
|
||||
export function componentMessage(c: Context, components: DiscordComponent[], update = true): Reply {
|
||||
const edit = update && !!c.interaction.message && [3, 5].includes(c.interaction.type);
|
||||
return { type: edit ? 7 : 4, data: { components, flags: 32768 | (!edit && c.interaction.guild_id ? 64 : 0), allowed_mentions: { parse: [] }, ...(edit ? { content: null, embeds: [], attachments: [] } : {}) } };
|
||||
}
|
||||
|
||||
/** Shared command screen using the same native panels as /today. */
|
||||
export function screen(c: Context, content: string, controls: DiscordComponent[] = [], update = true, color?: string): Reply {
|
||||
return componentMessage(c, [panel([display(content), ...controls], color)], update);
|
||||
}
|
||||
export const listSize = 6;
|
||||
export const listPage = (page: number | undefined, total: number, size = listSize) => Math.max(0, Math.min(Math.trunc(page ?? 0), Math.max(0, Math.ceil(total / size) - 1)));
|
||||
export const pageLabel = (page: number, total: number, size = listSize) => `Page ${page + 1} of ${Math.max(1, Math.ceil(total / size))}`;
|
||||
|
||||
@@ -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
|
||||
|
||||
85
src/habits/performance.test.ts
Normal file
85
src/habits/performance.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { fixture } from './test-fixture';
|
||||
import { HabitService } from './service';
|
||||
import { publicUser } from '../auth';
|
||||
import { users } from '../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
test('single-day reads load only the selected latest snapshot and its tasks', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const habit = await f.json('/habits', 'POST', { name: 'Tasks', method: 'tasks', tasks: [{name:'Read'}] }, 201);
|
||||
f.setTime('2027-09-04T12:00:00Z');
|
||||
await f.json('/today');
|
||||
const logs: string[] = [];
|
||||
(f.db as any).session.logger = { logQuery(query: string) { logs.push(query); } };
|
||||
const service = new HabitService(f.db, publicUser(f.db.select().from(users).where(eq(users.id,'alice')).get()!), Date.parse('2027-09-04T12:00:00Z'));
|
||||
const day = service.day(habit.id, '2027-09-04');
|
||||
expect(day.tasks).toHaveLength(1);
|
||||
expect((service as any).recordsCache.get(habit.id).size).toBe(1);
|
||||
expect((service as any).occurrencesCache.get(habit.id).size).toBe(1);
|
||||
expect(logs.find(query => query.includes('task_occurrences'))).toContain('"habit_days"."date" >=');
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('calendar summaries omit editor payloads and mutation versions cover historical changes', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const habit = await f.json('/habits', 'POST', { name: 'Tasks', method: 'tasks', tasks: [{name:'Read'}] }, 201);
|
||||
const first = (await f.json('/today')).habits[0];
|
||||
expect((await f.json('/today')).habits[0].calendarRevision).toBe(first.calendarRevision);
|
||||
f.setTime('2026-09-05T12:00:00Z');
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${first.tasks[0].taskId}`, 'PUT', {done:true});
|
||||
const changed = (await f.json('/today')).habits[0].calendarRevision;
|
||||
expect(changed).toBeGreaterThan(first.calendarRevision);
|
||||
await f.json(`/habits/${habit.id}/calendar-settings`, 'PUT', {mainColor:'#123456'});
|
||||
expect((await f.json('/today')).habits[0].calendarRevision).toBeGreaterThan(changed);
|
||||
const url = `/habits/${habit.id}/calendar?from=2026-09-04&to=2026-09-05`;
|
||||
const full = await f.json(url);
|
||||
const compact = await f.json(url+'&compact=true');
|
||||
const detail = full.days[0].habits[0];
|
||||
const {tasks, requirements, ...summary} = detail;
|
||||
expect(compact.days[0].habits[0]).toEqual(summary);
|
||||
expect(compact.data).toEqual(full.data);
|
||||
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).tasks[0].done).toBe(true);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('unchanged dashboard and calendar reads write no rows after synchronization', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const habit = await f.json('/habits', 'POST', {name:'Tasks', method:'tasks', tasks:[{name:'Read'}]}, 201);
|
||||
f.setTime('2027-09-04T12:00:00Z');
|
||||
await f.json('/today');
|
||||
const before = (f.sqlite.query('SELECT total_changes() AS n').get() as {n:number}).n;
|
||||
await f.json('/today');
|
||||
await f.json(`/habits/${habit.id}/calendar?from=2027-09-01&to=2027-09-04&compact=true`);
|
||||
expect((f.sqlite.query('SELECT total_changes() AS n').get() as {n:number}).n).toBe(before);
|
||||
} finally {f.close();}
|
||||
});
|
||||
|
||||
test('catch-up yields to other users and serializes same-user edits until fully ready', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const habit = await f.json('/habits', 'POST', {name:'Many tasks', method:'tasks', tasks:Array.from({length:50}, (_,i)=>({name:`Task ${i}`}))}, 201);
|
||||
f.setTime('2027-09-04T12:00:00Z');
|
||||
let complete = false;
|
||||
const dashboard = f.json('/today').then(value => {complete=true; return value;});
|
||||
await new Promise(resolve=>setTimeout(resolve,0));
|
||||
expect(complete).toBe(false);
|
||||
// Another user's request can complete while Alice is still catching up.
|
||||
expect((await f.request('/today','GET',undefined,'b')).status).toBe(200);
|
||||
expect(complete).toBe(false);
|
||||
// The actual date can advance while catch-up yields; readiness revalidates it.
|
||||
f.setTime('2027-09-05T12:00:00Z');
|
||||
const edit = f.json(`/habits/${habit.id}`, 'PATCH', {name:'Renamed'});
|
||||
const [today, changed] = await Promise.all([dashboard, edit]);
|
||||
expect(today.date).toBe('2027-09-05');
|
||||
expect(today.habits[0].tasks).toHaveLength(50);
|
||||
expect(changed.name).toBe('Renamed');
|
||||
const through = f.sqlite.query('SELECT materialized_through AS date FROM habits WHERE id=?').get(habit.id) as {date:string};
|
||||
expect(through.date).toBe('2027-09-05');
|
||||
const day = await f.json(`/habits/${habit.id}/days/2026-09-05`);
|
||||
expect(day.tasks.every((task:any)=>task.expiredAt!==null && task.closedAt!==null)).toBe(true);
|
||||
} finally {f.close();}
|
||||
});
|
||||
@@ -20,7 +20,7 @@ function date(c: Context<Env>) {
|
||||
return result.data;
|
||||
}
|
||||
function range(c: Context<Env>) {
|
||||
const result = z.object({ from: dateSchema, to: dateSchema }).strict().safeParse(c.req.query());
|
||||
const result = z.object({ from: dateSchema, to: dateSchema, compact: z.enum(['true', 'false']).optional() }).strict().safeParse(c.req.query());
|
||||
if (!result.success || result.data.from > result.data.to || dayNumber(result.data.to) - dayNumber(result.data.from) > 1829) throw new ApiError(422, 'Supply from and to dates in order, spanning at most 1830 days');
|
||||
return result.data;
|
||||
}
|
||||
@@ -41,8 +41,9 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
|
||||
try { payload.value = await c.req.json(); } catch { payload.malformed = true; }
|
||||
}
|
||||
c.set('payload', payload);
|
||||
const service = new HabitService(db, c.get('user'), now());
|
||||
service.sync(); c.set('service', service); await next();
|
||||
await HabitService.withPrepared(db, c.get('user'), now, async service => {
|
||||
c.set('service', service); await next();
|
||||
});
|
||||
}); if (response) c.res = response; };
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) {
|
||||
const response = await origin(c, proceed); if (response) c.res = response;
|
||||
@@ -80,7 +81,7 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
|
||||
app.get('/habits/:id/calendar-settings', c => c.json(service(c).settings(id(c))));
|
||||
app.put('/habits/:id/calendar-settings', c => c.json(service(c).saveSettings(id(c), body(c, calendarSettingsSchema))));
|
||||
app.get('/habits/:id/calendar', c => {
|
||||
const { from, to } = range(c); return c.json(service(c).calendar([id(c)], from, to, service(c).settings(id(c)), false));
|
||||
const { from, to, compact } = range(c); return c.json(service(c).calendar([id(c)], from, to, service(c).settings(id(c)), false, compact === 'true'));
|
||||
});
|
||||
app.get('/today', c => {
|
||||
const s = service(c); const habits = s.list().map(h => s.day(h.id, s.today));
|
||||
@@ -100,7 +101,7 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
|
||||
});
|
||||
app.delete('/charts/:id', c => { service(c).deleteChart(id(c)); return c.body(null, 204); });
|
||||
app.get('/charts/:id/calendar', c => {
|
||||
const chart = service(c).chart(id(c)); const { from, to } = range(c); return c.json(service(c).calendar(chart.habitIds, from, to, chart.settings, true));
|
||||
const chart = service(c).chart(id(c)); const { from, to, compact } = range(c); return c.json(service(c).calendar(chart.habitIds, from, to, chart.settings, true, compact === 'true'));
|
||||
});
|
||||
app.get('/charts/:id/days/:date', c => {
|
||||
const chart = service(c).chart(id(c)); const day = date(c); return c.json(service(c).calendar(chart.habitIds, day, day, chart.settings, true).days[0]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, inArray, lte, sql } from 'drizzle-orm';
|
||||
import { and, asc, desc, eq, inArray, lte, gte, gt, lt, or, isNull, sql } from 'drizzle-orm';
|
||||
import type { AppDatabase } from '../auth';
|
||||
import type { PublicUser } from '../shared/user';
|
||||
import { habits, habitRevisions, habitDays, taskOccurrences, progressEvents, habitCalendarSettings, combinedCharts, users } from '../db/schema';
|
||||
@@ -13,17 +13,59 @@ export type Habit = typeof habits.$inferSelect;
|
||||
export type Revision = typeof habitRevisions.$inferSelect;
|
||||
export type HabitDay = typeof habitDays.$inferSelect;
|
||||
|
||||
// Each database has its own per-user readiness queue. No transaction spans a yield.
|
||||
const preparationQueues = new WeakMap<AppDatabase, Map<string, Promise<void>>>();
|
||||
export class HabitService {
|
||||
static async withPrepared<T>(db: AppDatabase, user: PublicUser, now: () => number, run: (service: HabitService) => T | Promise<T>): Promise<T> {
|
||||
let queues = preparationQueues.get(db);
|
||||
if (!queues) { queues = new Map(); preparationQueues.set(db, queues); }
|
||||
const previous = queues.get(user.id);
|
||||
let release!: () => void;
|
||||
const ticket = new Promise<void>(resolve => { release = resolve; });
|
||||
queues.set(user.id, ticket);
|
||||
if (previous) await previous;
|
||||
try {
|
||||
let sliceStarted = performance.now();
|
||||
while (true) {
|
||||
// Re-read timezone, date and owned records after every scheduling yield.
|
||||
const service = new HabitService(db, user, now());
|
||||
const habit = service.pending()[0];
|
||||
if (!habit) {
|
||||
service.sync();
|
||||
return await run(service);
|
||||
}
|
||||
const start = habit.materializedThrough ? addDays(habit.materializedThrough, 1) : habit.createdDate;
|
||||
const through = [addDays(start, 7), service.today].sort()[0]!;
|
||||
db.transaction(() => service.materialize(habit, through));
|
||||
if (performance.now() - sliceStarted >= 8) {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
sliceStarted = performance.now();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
if (queues.get(user.id) === ticket) queues.delete(user.id);
|
||||
}
|
||||
}
|
||||
/** Ready read snapshot. Use withPrepared for read-modify-write handlers. */
|
||||
static prepare(db: AppDatabase, user: PublicUser, now: () => number) {
|
||||
return HabitService.withPrepared(db, user, now, service => service);
|
||||
}
|
||||
|
||||
readonly today: string;
|
||||
private ownedCache = new Map<string, Habit>();
|
||||
private revisionCache = new Map<string, Revision[]>();
|
||||
private deadlineCache = new Map<string, number>();
|
||||
private ranges = new Map<string, { from: string; to: string }>();
|
||||
private revisionRanges = new Map<string, { from: string; to: string }>();
|
||||
private recordsCache = new Map<string, Map<string, HabitDay>>();
|
||||
private occurrencesCache = new Map<string, Map<number, (typeof taskOccurrences.$inferSelect)[]>>();
|
||||
private countCache = new Map<string, Map<string, { value: number; carriedFrom: string | null }>>();
|
||||
constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) {
|
||||
// A different request may update the timezone while this request's body streams in.
|
||||
const timezone = db.select({ timezone: users.timezone }).from(users).where(eq(users.id, user.id)).get()!.timezone;
|
||||
const currentUser = db.select({ timezone: users.timezone }).from(users).where(eq(users.id, user.id)).get();
|
||||
if (!currentUser) throw missing();
|
||||
const timezone = currentUser.timezone;
|
||||
this.user = { ...user, timezone };
|
||||
// Travel west must never reopen or rewrite earlier requirements.
|
||||
const latest = db.select({ date: habits.materializedThrough }).from(habits).where(eq(habits.userId, user.id)).orderBy(desc(habits.materializedThrough)).get()?.date;
|
||||
@@ -35,10 +77,27 @@ export class HabitService {
|
||||
if (!habit) throw missing();
|
||||
this.ownedCache.set(id, habit); return habit;
|
||||
}
|
||||
private loadRevisions(id: string, from: string, to: string) {
|
||||
const first = this.db.select().from(habitRevisions).where(and(eq(habitRevisions.habitId, id), lte(habitRevisions.effectiveDate, from))).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).get();
|
||||
const later = from === to ? [] : this.db.select().from(habitRevisions).where(and(eq(habitRevisions.habitId, id), gt(habitRevisions.effectiveDate, from), lte(habitRevisions.effectiveDate, to))).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).all();
|
||||
this.revisionCache.set(id, first ? [...later, first] : later);
|
||||
this.revisionRanges.set(id, { from, to });
|
||||
}
|
||||
revision(id: string, date = this.today): Revision | undefined {
|
||||
let revisions = this.revisionCache.get(id);
|
||||
if (!revisions) { revisions = this.db.select().from(habitRevisions).where(eq(habitRevisions.habitId, id)).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).all(); this.revisionCache.set(id, revisions); }
|
||||
return revisions.find(r => r.effectiveDate <= date);
|
||||
const range = this.revisionRanges.get(id);
|
||||
if (!range || date < range.from || date > range.to) this.loadRevisions(id, date, date);
|
||||
return this.revisionCache.get(id)!.find(r => r.effectiveDate <= date);
|
||||
}
|
||||
private range(id: string, from: string, to = from) {
|
||||
const old = this.ranges.get(id);
|
||||
if (old && from >= old.from && to <= old.to) return;
|
||||
this.ranges.set(id, { from, to });
|
||||
this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.countCache.delete(id);
|
||||
this.loadRevisions(id, from, to);
|
||||
}
|
||||
private changed(id: string) {
|
||||
this.db.update(habits).set({ calendarRevision: sql`${habits.calendarRevision} + 1` }).where(eq(habits.id, id)).run();
|
||||
this.ownedCache.delete(id);
|
||||
}
|
||||
current(id: string) {
|
||||
const habit = this.owned(id); const revision = this.revision(id)!;
|
||||
@@ -47,11 +106,11 @@ export class HabitService {
|
||||
? [{ code: 'no_tasks', taskId: null, message: 'Add a task to make this habit due.' }]
|
||||
: config.tasks.filter(task => !schedulesOverlap(config.schedule, task.schedule))
|
||||
.map(task => ({ code: 'task_never_due', taskId: task.id, message: `Task \"${task.name}\" never coincides with the habit schedule.` }));
|
||||
return { id: habit.id, ...config, warnings, createdDate: habit.createdDate, createdAt: habit.createdAt, revisionId: revision.id, effectiveDate: revision.effectiveDate };
|
||||
return { id: habit.id, calendarRevision: habit.calendarRevision, ...config, warnings, createdDate: habit.createdDate, createdAt: habit.createdAt, revisionId: revision.id, effectiveDate: revision.effectiveDate };
|
||||
}
|
||||
list(includeArchived = false) {
|
||||
return this.db.select().from(habits).where(eq(habits.userId, this.user.id)).orderBy(asc(habits.createdAt), asc(habits.id)).all()
|
||||
.map(h => this.current(h.id)).filter(h => includeArchived || !h.archived);
|
||||
.map(h => { this.ownedCache.set(h.id, h); return this.current(h.id); }).filter(h => includeArchived || !h.archived);
|
||||
}
|
||||
create(input: ReturnType<typeof habitInput.parse>) {
|
||||
const id = crypto.randomUUID();
|
||||
@@ -114,7 +173,8 @@ export class HabitService {
|
||||
this.owned(id);
|
||||
this.db.transaction(() => {
|
||||
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
|
||||
this.materialize(this.owned(id));
|
||||
this.materialize(this.owned(id), this.today, true);
|
||||
this.changed(id);
|
||||
});
|
||||
return this.current(id);
|
||||
}
|
||||
@@ -137,19 +197,25 @@ export class HabitService {
|
||||
}
|
||||
sync() {
|
||||
this.db.transaction(() => {
|
||||
for (const habit of this.db.select().from(habits).where(eq(habits.userId, this.user.id)).all()) this.materialize(habit);
|
||||
// Record the original deadline even after downtime. Only active day snapshots expire.
|
||||
for (const habit of this.pending()) this.materialize(habit);
|
||||
// Start with the small partial index of open occurrences rather than
|
||||
// revisiting every historical snapshot on every unchanged request.
|
||||
this.db.run(sql`UPDATE task_occurrences SET
|
||||
expired_at = CASE WHEN done = 0 THEN COALESCE(expired_at, (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)) ELSE expired_at END,
|
||||
closed_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)
|
||||
WHERE closed_at IS NULL AND day_id IN (
|
||||
SELECT d.id FROM habit_days d JOIN habits h ON h.id = d.habit_id
|
||||
WHERE h.user_id = ${this.user.id} AND d.ends_at <= ${this.now}
|
||||
WHERE id IN (
|
||||
SELECT o.id FROM task_occurrences o INDEXED BY task_occurrences_open_idx
|
||||
CROSS JOIN habit_days d ON d.id = o.day_id
|
||||
CROSS JOIN habits h ON h.id = d.habit_id
|
||||
WHERE o.closed_at IS NULL AND h.user_id = ${this.user.id} AND d.ends_at <= ${this.now}
|
||||
AND d.id = (SELECT MAX(d2.id) FROM habit_days d2 WHERE d2.habit_id = d.habit_id AND d2.date = d.date))`);
|
||||
});
|
||||
}
|
||||
private pending() {
|
||||
return this.db.select().from(habits).where(and(eq(habits.userId, this.user.id), or(isNull(habits.materializedThrough), lt(habits.materializedThrough, this.today)))).all();
|
||||
}
|
||||
private invalidate(id: string) {
|
||||
this.countCache.delete(id); this.revisionCache.delete(id); this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.ownedCache.delete(id);
|
||||
this.ranges.delete(id); this.revisionRanges.delete(id); this.countCache.delete(id); this.revisionCache.delete(id); this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.ownedCache.delete(id);
|
||||
}
|
||||
private deadline(date: string) {
|
||||
let value = this.deadlineCache.get(date);
|
||||
@@ -160,7 +226,7 @@ export class HabitService {
|
||||
let records = this.recordsCache.get(id);
|
||||
if (!records) {
|
||||
records = new Map();
|
||||
for (const day of this.db.select().from(habitDays).where(eq(habitDays.habitId, id)).orderBy(asc(habitDays.id)).all()) records.set(day.date, day);
|
||||
for (const day of this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), gte(habitDays.date, this.ranges.get(id)!.from), lte(habitDays.date, this.ranges.get(id)!.to), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`)).all()) records.set(day.date, day);
|
||||
this.recordsCache.set(id, records);
|
||||
}
|
||||
return records;
|
||||
@@ -169,15 +235,17 @@ export class HabitService {
|
||||
let occurrences = this.occurrencesCache.get(id);
|
||||
if (!occurrences) {
|
||||
occurrences = new Map();
|
||||
const rows = this.db.select({ occurrence: taskOccurrences }).from(taskOccurrences).innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id)).where(eq(habitDays.habitId, id)).orderBy(asc(taskOccurrences.id)).all();
|
||||
const rows = this.db.select({ occurrence: taskOccurrences }).from(taskOccurrences).innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id)).where(and(eq(habitDays.habitId, id), gte(habitDays.date, this.ranges.get(id)!.from), lte(habitDays.date, this.ranges.get(id)!.to), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`)).orderBy(asc(taskOccurrences.id)).all();
|
||||
for (const { occurrence } of rows) { const entries = occurrences.get(occurrence.dayId) ?? []; entries.push(occurrence); occurrences.set(occurrence.dayId, entries); }
|
||||
this.occurrencesCache.set(id, occurrences);
|
||||
}
|
||||
return occurrences;
|
||||
}
|
||||
private materialize(habit: Habit) {
|
||||
let date = habit.materializedThrough ?? habit.createdDate;
|
||||
while (date <= this.today) {
|
||||
private materialize(habit: Habit, through = this.today, force = false) {
|
||||
let date = force ? through : habit.materializedThrough ? addDays(habit.materializedThrough, 1) : habit.createdDate;
|
||||
if (date > through) return;
|
||||
this.loadRevisions(habit.id, date, through);
|
||||
while (date <= through) {
|
||||
const revision = this.revision(habit.id, date)!;
|
||||
const existing = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get();
|
||||
if (!existing || existing.revisionId !== revision.id) {
|
||||
@@ -193,23 +261,43 @@ export class HabitService {
|
||||
.innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id))
|
||||
.where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date)))
|
||||
.orderBy(desc(habitDays.id)).all().map(row => row.occurrence) : [];
|
||||
for (const task of revision.config.tasks.filter(t => scheduled(t.schedule, date))) {
|
||||
const occurrences = revision.config.tasks.filter(t => scheduled(t.schedule, date)).map(task => {
|
||||
const old = previous.find(t => t.taskId === task.id);
|
||||
this.db.insert(taskOccurrences).values({ id: crypto.randomUUID(), dayId: day.id, taskId: task.id, name: task.name,
|
||||
done: old?.done ?? false, expiredAt: old ? old.expiredAt : day.endsAt <= this.now ? day.endsAt : null, closedAt: old?.closedAt ?? (day.endsAt <= this.now ? day.endsAt : null), updatedAt: old?.updatedAt ?? this.now }).run();
|
||||
}
|
||||
return { id: crypto.randomUUID(), dayId: day.id, taskId: task.id, name: task.name,
|
||||
done: old?.done ?? false, expiredAt: old ? old.expiredAt : day.endsAt <= this.now ? day.endsAt : null, closedAt: old?.closedAt ?? (day.endsAt <= this.now ? day.endsAt : null), updatedAt: old?.updatedAt ?? this.now };
|
||||
});
|
||||
if (occurrences.length) this.db.insert(taskOccurrences).values(occurrences).run();
|
||||
}
|
||||
}
|
||||
date = addDays(date, 1);
|
||||
}
|
||||
this.db.update(habits).set({ materializedThrough: this.today }).where(eq(habits.id, habit.id)).run();
|
||||
this.db.update(habits).set({ materializedThrough: through }).where(eq(habits.id, habit.id)).run();
|
||||
}
|
||||
private counts(id: string) {
|
||||
const cached = this.countCache.get(id); if (cached) return cached;
|
||||
const rows = this.db.select({ day: habitDays, config: habitRevisions.config }).from(habitDays)
|
||||
.innerJoin(habitRevisions, eq(habitDays.revisionId, habitRevisions.id))
|
||||
.where(and(eq(habitDays.habitId, id), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`))
|
||||
.orderBy(asc(habitDays.date)).all();
|
||||
const range = this.ranges.get(id)!;
|
||||
const latest = sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`;
|
||||
const select = () => this.db.select({ day: habitDays, config: habitRevisions.config }).from(habitDays).innerJoin(habitRevisions, eq(habitDays.revisionId, habitRevisions.id));
|
||||
const rows = select().where(and(eq(habitDays.habitId, id), gte(habitDays.date, range.from), lte(habitDays.date, range.to), latest)).orderBy(asc(habitDays.date)).all();
|
||||
// Only carry-enabled ranges need a prefix. Walk backwards in bounded pages
|
||||
// until a logged value or requirements reset establishes the starting state.
|
||||
const first = rows.find(({day, config}) => config.method !== 'count' || config.archived || scheduled(config.schedule, day.date));
|
||||
if (first?.config.method === 'count' && first.config.carryPartialProgress && !first.day.countSet) {
|
||||
let before = range.from;
|
||||
const prefix: typeof rows = [];
|
||||
let found = false;
|
||||
while (!found) {
|
||||
const page = select().where(and(eq(habitDays.habitId, id), lt(habitDays.date, before), latest)).orderBy(desc(habitDays.date)).limit(64).all();
|
||||
if (!page.length) break;
|
||||
for (const entry of page) {
|
||||
prefix.push(entry);
|
||||
const { day, config } = entry;
|
||||
if (config.method !== 'count' || config.archived || (scheduled(config.schedule, day.date) && (day.countSet || !config.carryPartialProgress))) { found = true; break; }
|
||||
}
|
||||
before = page.at(-1)!.day.date;
|
||||
}
|
||||
rows.unshift(...prefix.reverse());
|
||||
}
|
||||
const result = new Map<string, { value: number; carriedFrom: string | null }>();
|
||||
let carry = 0; let source: string | null = null;
|
||||
for (const { day, config } of rows) {
|
||||
@@ -223,6 +311,7 @@ export class HabitService {
|
||||
}
|
||||
day(id: string, date: string) {
|
||||
this.owned(id);
|
||||
this.range(id, date);
|
||||
const revision = this.revision(id, date);
|
||||
const record = this.records(id).get(date);
|
||||
const config = revision?.config;
|
||||
@@ -235,9 +324,9 @@ export class HabitService {
|
||||
const value = !due ? 0 : config!.method === 'count' ? counted?.value ?? 0 : config!.method === 'manual' ? Number(record?.done ?? false) : tasks.filter(t => t.done).length;
|
||||
const target = !config ? null : config.method === 'count' ? config.target : config.method === 'manual' ? 1 : tasks.length;
|
||||
const ratio = due ? Math.min(1, value / target!) : null;
|
||||
return { date, habitId: id, name: config?.name ?? null, method: config?.method ?? null, revisionId: revision?.id ?? null,
|
||||
return { date, habitId: id, calendarRevision: this.owned(id).calendarRevision, name: config?.name ?? null, method: config?.method ?? null, revisionId: revision?.id ?? null,
|
||||
timezone: record?.timezone ?? this.user.timezone, endsAt: record?.endsAt ?? null, due, future,
|
||||
status: future ? 'future' : !due ? 'not_due' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty',
|
||||
status: !due ? 'not_due' : future ? 'future' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty',
|
||||
value, target, carriedFrom: counted?.carriedFrom ?? null, loggedCount: record?.countSet ? record.count : null, unit: config?.method === 'count' ? config.unit : config?.method === 'tasks' ? 'tasks' : 'completion',
|
||||
ratio, complete: due && ratio === 1, tasks, requirements: config ?? null };
|
||||
}
|
||||
@@ -256,6 +345,7 @@ export class HabitService {
|
||||
this.db.transaction(() => {
|
||||
this.db.insert(progressEvents).values({ dayId: record.id, before: { count: detail.value, done: record.done, loggedCount: record.countSet ? record.count : null, carriedFrom: detail.carriedFrom }, after: input, createdAt: this.now }).run();
|
||||
this.db.update(habitDays).set('count' in input ? { ...input, countSet: true } : input).where(eq(habitDays.id, record.id)).run();
|
||||
this.changed(id);
|
||||
});
|
||||
this.invalidate(id);
|
||||
return this.day(id, date);
|
||||
@@ -267,6 +357,7 @@ export class HabitService {
|
||||
this.db.transaction(() => {
|
||||
this.db.insert(progressEvents).values({ dayId: record.id, occurrenceId: occurrence.id, before: { done: occurrence.done }, after: { done }, createdAt: this.now }).run();
|
||||
this.db.update(taskOccurrences).set({ done, updatedAt: this.now }).where(eq(taskOccurrences.id, occurrence.id)).run();
|
||||
this.changed(id);
|
||||
});
|
||||
this.invalidate(id);
|
||||
return this.day(id, date);
|
||||
@@ -290,6 +381,7 @@ export class HabitService {
|
||||
saveSettings(id: string, settings: CalendarSettings) {
|
||||
this.owned(id);
|
||||
this.db.insert(habitCalendarSettings).values({ habitId: id, settings }).onConflictDoUpdate({ target: habitCalendarSettings.habitId, set: { settings } }).run();
|
||||
this.changed(id);
|
||||
return settings;
|
||||
}
|
||||
chart(id: string) {
|
||||
@@ -306,8 +398,8 @@ export class HabitService {
|
||||
return this.chart(id);
|
||||
}
|
||||
deleteChart(id: string) { this.chart(id); this.db.delete(combinedCharts).where(eq(combinedCharts.id, id)).run(); }
|
||||
calendar(ids: string[], from: string, to: string, settings: CalendarSettings, combined: boolean) {
|
||||
ids.forEach(id => this.owned(id));
|
||||
calendar(ids: string[], from: string, to: string, settings: CalendarSettings, combined: boolean, compact = false) {
|
||||
ids.forEach(id => { this.owned(id); this.range(id, from, to); });
|
||||
const days = [];
|
||||
for (let date = from; date <= to; date = addDays(date, 1)) {
|
||||
const details = ids.map(id => this.day(id, date));
|
||||
@@ -317,12 +409,15 @@ export class HabitService {
|
||||
const steps = manual ? 1 : settings.shadeCount === 'auto' ? combined ? 4 : details[0]!.target ?? 1 : settings.shadeCount;
|
||||
const progress = shade(ratio, steps, settings, !combined && (settings.shadeCount === 'auto' || manual));
|
||||
const future = date > this.today;
|
||||
days.push({ date, due: due.length, completed, ratio, future, status: future ? 'future' : ratio === null ? 'not_due' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty',
|
||||
shadeCount: steps, ...progress, color: future ? settings.futureColor : progress.color, habits: details });
|
||||
days.push({ date, due: due.length, completed, ratio, future, status: ratio === null ? 'not_due' : future ? 'future' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty',
|
||||
shadeCount: steps, ...progress, color: future && ratio !== null ? settings.futureColor : progress.color, habits: details.map(detail => {
|
||||
const { tasks, requirements, ...summary } = detail;
|
||||
return { ...summary, tasks: compact ? undefined : tasks, requirements: compact ? undefined : requirements };
|
||||
}) });
|
||||
}
|
||||
return { kind: combined ? 'combined' as const : 'individual' as const, from, to, today: this.today, timezone: this.user.timezone, settings, days,
|
||||
// Explicit colors and details are supplied for the client's Nivo colorScale/tooltip adapters.
|
||||
data: days.map(d => ({ day: d.date, value: d.future ? -2 : d.ratio === null ? -1 : d.level! })) };
|
||||
data: days.map(d => ({ day: d.date, value: d.ratio === null ? -1 : d.future ? -2 : d.level! })) };
|
||||
}
|
||||
setTimezone(timezone: string) {
|
||||
this.db.update(users).set({ timezone, updatedAt: sql`max(${users.updatedAt} + 1, ${this.now})` }).where(eq(users.id, this.user.id)).run();
|
||||
|
||||
@@ -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,3 +1,5 @@
|
||||
import { browserFont } from "./browser-fonts";
|
||||
import { pwaAsset } from "./pwa/assets";
|
||||
import { startReminders } from "./reminders/worker";
|
||||
import { backupConfig, startBackups } from "./ops/backups";
|
||||
import { databasePath } from "./db/config";
|
||||
@@ -15,6 +17,7 @@ if (process.env.NODE_ENV !== "production") {
|
||||
}
|
||||
|
||||
const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined;
|
||||
await backups?.ready;
|
||||
if (import.meta.hot) import.meta.hot.dispose(() => backups?.stop());
|
||||
|
||||
const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin, commandsAvailable: /^[a-f\d]{64}$/i.test(process.env.DISCORD_PUBLIC_KEY ?? "") && Boolean(readAuthConfig().clientId) }) : undefined;
|
||||
@@ -28,6 +31,10 @@ const server = Bun.serve({
|
||||
hostname: process.env.HOST ?? "127.0.0.1",
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
routes: {
|
||||
"/manifest.webmanifest": pwaAsset,
|
||||
"/sw.js": pwaAsset,
|
||||
"/pwa/*": pwaAsset,
|
||||
"/fonts/browser/*": browserFont,
|
||||
"/api": req => app.fetch(req),
|
||||
"/api/*": req => app.fetch(req),
|
||||
"/*": index,
|
||||
|
||||
@@ -14,6 +14,8 @@ export async function habitRequest<T>(
|
||||
path: string,
|
||||
options?: RequestInit,
|
||||
): Promise<T> {
|
||||
if (typeof navigator !== "undefined" && navigator.onLine === false)
|
||||
throw new Error("You’re offline. Reconnect and try again. Changes have not been saved or queued.");
|
||||
const response = await fetch(`/api${path}`, options);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401)
|
||||
@@ -46,3 +48,15 @@ export function formatTrackingDate(date: string) {
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
/** Preserve unchanged row references when a background refresh returns new JSON objects. */
|
||||
export function reconcileToday(current: TodayResponse | null, next: TodayResponse): TodayResponse {
|
||||
if (!current || current.date !== next.date || current.timezone !== next.timezone) return next;
|
||||
const previous = new Map(current.habits.map(habit => [habit.habitId, habit]));
|
||||
const habits = next.habits.map(habit => {
|
||||
const old = previous.get(habit.habitId);
|
||||
return old && JSON.stringify(old) === JSON.stringify(habit) ? old : habit;
|
||||
});
|
||||
if (current.due === next.due && current.completed === next.completed && habits.length === current.habits.length && habits.every((habit, index) => habit === current.habits[index])) return current;
|
||||
return { ...next, habits };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { mkdtempSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fixture } from "../habits/test-fixture";
|
||||
@@ -35,6 +35,7 @@ test("WAL snapshots restore committed progress, revoke sessions, replicate, and
|
||||
expect(readdirSync(config.replica!)).toHaveLength(2);
|
||||
expect(readdirSync(config.directory)).toContain("keep.txt");
|
||||
const runner = startBackups(f.sqlite, config);
|
||||
await runner.ready;
|
||||
expect(runner.state.failed).toBe(false); expect(runner.state.lastSuccess).toBeGreaterThan(0); runner.stop();
|
||||
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -71,3 +72,78 @@ test("backup and restore CLIs work against a live database without overwriting i
|
||||
expect(f.sqlite.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 });
|
||||
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('scheduled backups yield to the event loop, retain WAL data, and verify replicas', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'minabot-async-backup-'));
|
||||
const path = join(directory, 'live.sqlite'), f = fixture(path);
|
||||
let runner: ReturnType<typeof startBackups> | undefined;
|
||||
try {
|
||||
f.sqlite.exec('PRAGMA journal_mode=WAL');
|
||||
await f.json('/habits', 'POST', { name: 'Async snapshot', method: 'manual' }, 201);
|
||||
const config = backupConfig(path, { BACKUP_REPLICA_DIR: 'replica' });
|
||||
mkdirSync(config.replica!);
|
||||
let yielded = false;
|
||||
const timer = new Promise<void>(resolve => setTimeout(() => { yielded = true; resolve(); }, 0));
|
||||
runner = startBackups(path, config);
|
||||
expect(runner.state.lastSuccess).toBe(0);
|
||||
await runner.ready;
|
||||
expect(yielded).toBe(true); await timer;
|
||||
expect(runner.state.failed).toBe(false);
|
||||
expect(runner.state.lastSuccess).toBeGreaterThan(0);
|
||||
const snapshots = readdirSync(config.directory).filter(file => file.endsWith('.sqlite'));
|
||||
expect(snapshots).toHaveLength(1);
|
||||
const check = new Database(join(config.replica!, snapshots[0]!), { readonly: true });
|
||||
try { expect(check.query('SELECT count(*) AS n FROM habits').get()).toEqual({ n: 1 }); } finally { check.close(); }
|
||||
await runner.tick();
|
||||
expect(readdirSync(config.directory).filter(file => file.endsWith('.sqlite'))).toHaveLength(1);
|
||||
} finally { runner?.stop(); f.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('scheduled backup ticks coalesce, expose failure and recovery, and stop admitting work', async () => {
|
||||
const config = backupConfig('/tmp/test-only.sqlite', {});
|
||||
let calls = 0, release!: (value: number) => void;
|
||||
const run = async () => { calls++; return new Promise<number>(resolve => { release = resolve; }); };
|
||||
const runner = startBackups('/tmp/test-only.sqlite', config, run);
|
||||
expect(runner.tick()).toBe(runner.ready);
|
||||
expect(calls).toBe(1);
|
||||
release(123); await runner.ready;
|
||||
expect(runner.state.lastSuccess).toBe(123);
|
||||
const second = runner.tick(); expect(calls).toBe(2);
|
||||
runner.stop(); release(456); await second;
|
||||
await runner.tick(); expect(calls).toBe(2);
|
||||
const failed = startBackups('/tmp/test-only.sqlite', config, async () => { throw new Error('test failure'); });
|
||||
await failed.ready; expect(failed.state.failed).toBe(true); failed.stop();
|
||||
const disabled = startBackups('/tmp/test-only.sqlite', { ...config, enabled: false }, run);
|
||||
await disabled.ready; expect(calls).toBe(2); disabled.stop();
|
||||
});
|
||||
|
||||
|
||||
test('the first scheduled snapshot initializes cold WAL bookkeeping without changing source rows', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'minabot-cold-wal-'));
|
||||
const path = join(directory, 'live.sqlite'), f = fixture(path);
|
||||
f.sqlite.exec('PRAGMA journal_mode=DELETE'); f.close();
|
||||
const db = new Database(path, { readwrite: true, create: false, strict: true });
|
||||
let runner: ReturnType<typeof startBackups> | undefined;
|
||||
try {
|
||||
db.exec('PRAGMA journal_mode=WAL');
|
||||
expect(existsSync(`${path}-shm`)).toBe(false);
|
||||
runner = startBackups(db, backupConfig(path, {}));
|
||||
await runner.ready;
|
||||
expect(runner.state.failed).toBe(false);
|
||||
expect(runner.state.lastSuccess).toBeGreaterThan(0);
|
||||
expect(db.query('SELECT count(*) AS n FROM users').get()).toEqual({ n: 2 });
|
||||
expect(db.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 });
|
||||
expect(db.query('SELECT total_changes() AS n').get()).toEqual({ n: 0 });
|
||||
} finally { runner?.stop(); db.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('the scheduled snapshot never creates a missing source database', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'minabot-missing-source-'));
|
||||
const path = join(directory, 'missing.sqlite');
|
||||
const runner = startBackups(path, backupConfig(path, {}));
|
||||
try {
|
||||
await runner.ready;
|
||||
expect(runner.state.failed).toBe(true);
|
||||
expect(existsSync(path)).toBe(false);
|
||||
} finally { runner.stop(); rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
@@ -82,18 +83,40 @@ export function restoreBackup(source: string, destination: string) {
|
||||
} finally { if (existsSync(temporary)) unlinkSync(temporary); }
|
||||
return destination;
|
||||
}
|
||||
export function startBackups(database: Database, config: BackupConfig) {
|
||||
const state = { enabled: config.enabled, lastSuccess: 0, failed: false };
|
||||
if (!config.enabled) return { state, stop() {} };
|
||||
const tick = () => {
|
||||
try {
|
||||
const latest = latestBackupTime(config);
|
||||
if (latest && Date.now() - latest < config.intervalMs) { state.lastSuccess = latest; state.failed = false; return; }
|
||||
createBackup(database, config); state.lastSuccess = Date.now(); state.failed = false;
|
||||
console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date().toISOString() }));
|
||||
} catch { state.failed = true; console.error(JSON.stringify({ event: "backup_failed", timestamp: new Date().toISOString() })); }
|
||||
};
|
||||
tick();
|
||||
const timer = setInterval(tick, 60000); timer.unref();
|
||||
return { state, stop: () => clearInterval(timer) };
|
||||
/** Runs all filesystem and SQLite backup work in a separate Bun process. */
|
||||
export async function runScheduledBackup(database: string, config: BackupConfig): Promise<number> {
|
||||
const source = new URL("../../scripts/scheduled-backup.ts", import.meta.url);
|
||||
const built = new URL("../scripts/scheduled-backup.ts", import.meta.url);
|
||||
const script = fileURLToPath(existsSync(source) ? source : built);
|
||||
const child = Bun.spawn([process.execPath, script, resolve(database), JSON.stringify(config)], {
|
||||
stdout: "pipe", stderr: "ignore",
|
||||
});
|
||||
const [output, status] = await Promise.all([new Response(child.stdout).text(), child.exited]);
|
||||
if (status !== 0) throw new Error("Scheduled backup process failed");
|
||||
const { timestamp, created } = JSON.parse(output) as { timestamp: number; created: boolean };
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) throw new Error("Scheduled backup returned an invalid timestamp");
|
||||
if (created) console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date(timestamp).toISOString() }));
|
||||
return timestamp;
|
||||
}
|
||||
export function startBackups(database: Database | string, config: BackupConfig, run = runScheduledBackup) {
|
||||
const state = { enabled: config.enabled, lastSuccess: 0, failed: false };
|
||||
let running: Promise<void> | undefined;
|
||||
let stopped = false;
|
||||
const tick = (): Promise<void> => {
|
||||
if (stopped || !config.enabled) return Promise.resolve();
|
||||
if (running) return running;
|
||||
running = (async () => {
|
||||
try {
|
||||
const path = typeof database === 'string' ? database : database.filename;
|
||||
if (!path || path === ':memory:') throw new Error('Scheduled backups require a file database');
|
||||
state.lastSuccess = await run(path, config); state.failed = false;
|
||||
} catch { state.failed = true; console.error(JSON.stringify({ event: "backup_failed", timestamp: new Date().toISOString() })); }
|
||||
})().finally(() => { running = undefined; });
|
||||
return running;
|
||||
};
|
||||
// Callers await readiness before accepting traffic, matching the original startup check.
|
||||
const ready = tick();
|
||||
const timer = config.enabled ? setInterval(() => { void tick(); }, 60000) : undefined;
|
||||
timer?.unref();
|
||||
return { state, ready, tick, stop: () => { stopped = true; clearInterval(timer); } };
|
||||
}
|
||||
|
||||
@@ -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,69 +1,73 @@
|
||||
import { ProgressOverview } from "../components/ProgressOverview";
|
||||
import { deferred } from "../components/design-system/deferred";
|
||||
import { useOnline } from "../pwa/client";
|
||||
import { CalendarCacheProvider } from "../components/CalendarCache";
|
||||
import { Archive, Plus, Settings2 } from "lucide-react";
|
||||
import { ArchivedHabits } from "../components/ArchivedHabits";
|
||||
import { AccountSettings } from "../components/AccountSettings";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
const ArchivedHabits = deferred(() => import("../components/ArchivedHabits").then(module => module.ArchivedHabits));
|
||||
const AccountSettings = deferred(() => import("../components/AccountSettings").then(module => module.AccountSettings));
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "../components/AuthProvider";
|
||||
import { DiscordSignInButton } from "../components/DiscordSignInButton";
|
||||
import { PageLayout } from "../components/design-system/PageLayout";
|
||||
import {
|
||||
Button,
|
||||
ButtonLink,
|
||||
Checkbox,
|
||||
Counter,
|
||||
SectionHeading,
|
||||
} from "../components/design-system/primitives";
|
||||
import { Card, CardGrid } from "../components/design-system/Card";
|
||||
import { HabitChart } from "../components/design-system/HabitChart";
|
||||
import { HABIT_COLORS } from "../components/design-system/calendar-model";
|
||||
import { HabitForm } from "../components/HabitForm";
|
||||
import { habitRequest, scheduleLabel, type TodayHabit, type TodayResponse } from "../lib/dashboard";
|
||||
const HabitForm = deferred(() => import("../components/HabitForm").then(module => module.HabitForm));
|
||||
import { habitRequest, reconcileToday, scheduleLabel, type TodayHabit, type TodayResponse } from "../lib/dashboard";
|
||||
import { HabitHistory } from "../components/HabitHistory";
|
||||
import { WelcomePanel } from "../components/design-system/WelcomePanel";
|
||||
import type { PublicUser } from "../shared/user";
|
||||
import { ItemActions } from "../components/design-system/ItemActions";
|
||||
import { type ItemMode } from "../components/InlineItemForm";
|
||||
import { InlineHabitEditor } from "../components/InlineHabitEditor";
|
||||
import { InlineTaskEditor } from "../components/InlineTaskEditor";
|
||||
const InlineHabitEditor = deferred(() => import("../components/InlineHabitEditor").then(module => module.InlineHabitEditor));
|
||||
const InlineTaskEditor = deferred(() => import("../components/InlineTaskEditor").then(module => module.InlineTaskEditor));
|
||||
import type { Schedule } from "../habits/contracts";
|
||||
import { ShareProgress } from "../components/ShareProgress";
|
||||
const ShareProgress = deferred(() => import("../components/ShareProgress").then(module => module.ShareProgress));
|
||||
|
||||
export function Home() {
|
||||
const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();
|
||||
const online = useOnline();
|
||||
const { user, loading, busy, error, accountError, connectionError, signIn, signOut, retry } = useAuth();
|
||||
const disconnected = !online || connectionError;
|
||||
return (
|
||||
<PageLayout
|
||||
header={
|
||||
loading ? (
|
||||
<span className="ds-library-label">Loading account…</span>
|
||||
) : user ? (
|
||||
<Button variant="text" disabled={busy} onClick={() => void signOut()}>
|
||||
<Button variant="text" disabled={busy || !online} onClick={() => void signOut()}>
|
||||
{busy ? "Signing out…" : "Sign out"}
|
||||
</Button>
|
||||
) : (
|
||||
!accountError && <DiscordSignInButton variant="secondary" onClick={signIn} />
|
||||
!accountError && <DiscordSignInButton disabled={!online} variant="secondary" onClick={signIn} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
{error && !disconnected && (
|
||||
<div className="ds-form-feedback" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<section className="ds-section" aria-busy="true">
|
||||
<p role="status">Getting things ready…</p>
|
||||
<p role="status">Loading account…</p>
|
||||
</section>
|
||||
) : accountError ? (
|
||||
<section className="ds-section">
|
||||
<h1 className="type-section">Let’s try that again.</h1>
|
||||
<Card headingLevel={2} heading="Account unavailable">
|
||||
<p>Your account couldn’t be loaded.</p>
|
||||
<h1 className="type-section">{disconnected ? "Connection unavailable" : "Account unavailable"}</h1>
|
||||
<div className="ds-form-stack">
|
||||
<p>{disconnected ? "Check your connection. We’ll retry automatically." : "Your account couldn’t be loaded."}</p>
|
||||
<div className="ds-actions">
|
||||
<Button onClick={retry}>Try again</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
) : user ? (
|
||||
<Dashboard key={user.id} user={user} onExpired={retry} />
|
||||
<CalendarCacheProvider key={`${user.id}:${user.timezone}`}><Dashboard user={user} onExpired={retry} /></CalendarCacheProvider>
|
||||
) : (
|
||||
<Welcome onSignIn={signIn} />
|
||||
)}
|
||||
@@ -72,43 +76,22 @@ export function Home() {
|
||||
}
|
||||
|
||||
function Welcome({ onSignIn }: { onSignIn: () => void }) {
|
||||
const online = useOnline();
|
||||
const [water, setWater] = useState(3);
|
||||
const [read, setRead] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<section className="ds-section ds-split-section" aria-labelledby="welcome-title">
|
||||
<div className="ds-section-heading">
|
||||
<p className="ds-eyebrow">A LITTLE, EVERY DAY</p>
|
||||
<h1 id="welcome-title" className="type-section">
|
||||
Small steps.
|
||||
<br />
|
||||
<em>Lasting rhythm.</em>
|
||||
</h1>
|
||||
<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
|
||||
@@ -139,31 +122,16 @@ function Welcome({ onSignIn }: { onSignIn: () => void }) {
|
||||
</HabitChart>
|
||||
</div>
|
||||
</section>
|
||||
<section className="ds-section" aria-label="Your own rhythm">
|
||||
<CardGrid>
|
||||
<Card heading="Your habits. Your pace." eyebrow="MAKE IT FIT">
|
||||
<p>
|
||||
Pick a check-in, a count target, or a task list. Repeat daily, on selected weekdays,
|
||||
or at your own interval.
|
||||
</p>
|
||||
</Card>
|
||||
<Card heading="See the days add up." eyebrow="KEEP PERSPECTIVE" variant="outlined">
|
||||
<p>
|
||||
Explore your calendar to see the progress behind each square. Days off stay distinct
|
||||
from missed days.
|
||||
</p>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => void }) {
|
||||
const [today, setToday] = useState<TodayResponse | null>(null);
|
||||
const todaySnapshot = useRef(today);
|
||||
todaySnapshot.current = today;
|
||||
const [error, setError] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [calendarRevision, setCalendarRevision] = useState(0);
|
||||
const [habitRevisions, setHabitRevisions] = useState<Record<string, number>>({});
|
||||
const [pendingHabits, setPendingHabits] = useState<Set<string>>(new Set());
|
||||
const progressSaves = useRef(new Set<string>());
|
||||
@@ -171,6 +139,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [habitView, setHabitView] = useState<"today" | "all">("today");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [addingOverview, setAddingOverview] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const [settings, setSettings] = useState(false);
|
||||
const [archive, setArchive] = useState(false);
|
||||
@@ -203,10 +172,10 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
habitRequest<TodayResponse>("/today", { signal: controller.signal })
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setToday(data);
|
||||
const next = reconcileToday(todaySnapshot.current, data);
|
||||
setToday(next);
|
||||
setNeedsRefresh(false);
|
||||
setRevision((value) => value + 1);
|
||||
setCalendarRevision((value) => value + 1);
|
||||
if (next !== todaySnapshot.current) setRevision((value) => value + 1);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -227,14 +196,16 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
// Refresh after returning to the page and across the account's local midnight.
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
if (!saving.current && progressSaves.current.size === 0 && document.visibilityState === "visible")
|
||||
if (navigator.onLine !== false && !saving.current && progressSaves.current.size === 0 && document.visibilityState === "visible")
|
||||
setAttempt((value) => value + 1);
|
||||
};
|
||||
const timer = window.setInterval(refresh, 60_000);
|
||||
window.addEventListener("focus", refresh);
|
||||
window.addEventListener("online", refresh);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("focus", refresh);
|
||||
window.removeEventListener("online", refresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -302,7 +273,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
try {
|
||||
const refreshed = await habitRequest<TodayResponse>("/today");
|
||||
if (mounted.current) {
|
||||
setToday(refreshed);
|
||||
setToday(current => reconcileToday(current, refreshed));
|
||||
// Keep a habit reachable when an edit makes it unscheduled (including an empty routine).
|
||||
if (refreshed.habits.some(item => item.habitId === habit.habitId && !item.due)) setHabitView("all");
|
||||
setNeedsRefresh(false);
|
||||
@@ -337,6 +308,13 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
}
|
||||
}
|
||||
|
||||
const actions = useRef({ update, manage });
|
||||
actions.current = { update, manage };
|
||||
const updateHabit = useCallback((...args: Parameters<typeof update>) => actions.current.update(...args), []);
|
||||
const manageHabit = useCallback((...args: Parameters<typeof manage>) => actions.current.manage(...args), []);
|
||||
const refreshHistory = useCallback(() => setAttempt(value => value + 1), []);
|
||||
const expire = useCallback(() => expired.current(), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WelcomePanel
|
||||
@@ -356,16 +334,13 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
</span>
|
||||
<span>
|
||||
habits complete today
|
||||
{today.completed === today.due && (
|
||||
<span className="ds-welcome-complete">A little, all done.</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
{today.habits.length
|
||||
? "Nothing scheduled today. Enjoy a little breathing room."
|
||||
: "Start with one habit. Your first small step starts here."}
|
||||
? "Nothing scheduled today."
|
||||
: "No habits yet."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -381,6 +356,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
>
|
||||
Add a habit +
|
||||
</Button>
|
||||
<Button id="create-overview" variant="secondary" disabled={busy || loading || needsRefresh} aria-haspopup="dialog" onClick={() => setAddingOverview(true)}>Create overview</Button>
|
||||
{today.habits.length > 0 && (
|
||||
<>
|
||||
<Button id="open-sharing" aria-haspopup="dialog" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button>
|
||||
@@ -430,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}
|
||||
@@ -439,42 +414,38 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
<div className="ds-section-top">
|
||||
<SectionHeading
|
||||
id="habits-title"
|
||||
title={
|
||||
<>
|
||||
Today, <em>at your pace.</em>
|
||||
</>
|
||||
}
|
||||
title="Habits"
|
||||
/>
|
||||
<div className="ds-action-row ds-habit-filters" role="group" aria-label="Show habits">
|
||||
<Button variant={habitView === "today" ? "primary" : "text"} aria-pressed={habitView === "today"} onClick={() => setHabitView("today")}>Today · {today.due}</Button>
|
||||
<Button variant={habitView === "all" ? "primary" : "text"} aria-pressed={habitView === "all"} onClick={() => setHabitView("all")}>All habits · {today.habits.length}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{habitView === "today" && today.due === 0 && <p className="ds-supporting-copy">You’re all set for today. Choose All habits to view or edit your routines.</p>}
|
||||
{habitView === "today" && today.habits.length > 0 && today.due === 0 && <p className="ds-supporting-copy">Choose All habits to view or edit your routines.</p>}
|
||||
<div className="ds-habit-chart-grid ds-habit-list">
|
||||
{today.habits.filter(habit => habitView === "all" || habit.due).map((habit) => (
|
||||
<SavedHabit
|
||||
key={habit.habitId}
|
||||
habit={habit}
|
||||
date={today.date}
|
||||
revision={calendarRevision + (habitRevisions[habit.habitId] ?? 0)}
|
||||
revision={habit.calendarRevision ?? habitRevisions[habit.habitId] ?? 0}
|
||||
disabled={busy || pendingHabits.has(habit.habitId) || loading || needsRefresh}
|
||||
onHistorySaved={() => setAttempt(value => value + 1)}
|
||||
onUpdate={update}
|
||||
onManage={manage}
|
||||
onExpired={() => expired.current()}
|
||||
onHistorySaved={refreshHistory}
|
||||
onUpdate={updateHabit}
|
||||
onManage={manageHabit}
|
||||
onExpired={expire}
|
||||
/>
|
||||
))}
|
||||
<ProgressOverview creating={addingOverview} onCloseCreate={() => setAddingOverview(false)} date={today.date} revision={revision + attempt} disabled={busy || loading || needsRefresh || pendingHabits.size > 0} onExpired={expire} onHistorySaved={refreshHistory} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedHabit({
|
||||
const SavedHabit = memo(function SavedHabit({
|
||||
habit,
|
||||
date,
|
||||
revision,
|
||||
@@ -604,7 +575,7 @@ function SavedHabit({
|
||||
) : undefined}
|
||||
</HabitHistory>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function SavedTask({
|
||||
task,
|
||||
|
||||
26
src/pwa/assets.ts
Normal file
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);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user