Compare commits
36 Commits
2671fe0f34
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccd998ab04 | ||
|
|
e081e9acbf | ||
|
|
b8df2001ec | ||
|
|
95f3d9a2c7 | ||
|
|
8f15419703 | ||
|
|
7c7a048b32 | ||
|
|
27beb01144 | ||
|
|
149ec36c77 | ||
|
|
c3b52e8fe1 | ||
|
|
ba049efdc8 | ||
|
|
e3e07404f8 | ||
|
|
17190219fa | ||
|
|
b0950cb4b4 | ||
|
|
82fe4a1db1 | ||
|
|
c91202bbfc | ||
|
|
03eb8d9e7a | ||
|
|
9f552bf608 | ||
|
|
b22c933fc4 | ||
|
|
9a0fc108a9 | ||
|
|
b4619dec33 | ||
|
|
31e4f3b2ef | ||
|
|
30dee55799 | ||
|
|
4b8c7dff05 | ||
|
|
85376a54b3 | ||
|
|
833d5673eb | ||
|
|
4d719afd07 | ||
|
|
838567e9b8 | ||
|
|
f79d593a04 | ||
|
|
d088179c5e | ||
|
|
2c135398d7 | ||
|
|
a1ae65634b | ||
|
|
5dbec04b04 | ||
|
|
3487488f7b | ||
|
|
ab2aa74eba | ||
|
|
07a0f80fc0 | ||
|
|
8b9bd75f24 |
@@ -1,4 +1,4 @@
|
||||
mina.teppelinlabs.com {
|
||||
mina.teppelin.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy app:3000
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
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_*"
|
||||
|
||||
@@ -45,7 +45,7 @@ services:
|
||||
image: ${APP_IMAGE:?Set APP_IMAGE to the tested release image}
|
||||
command: [bun, scripts/monitor.ts]
|
||||
environment:
|
||||
MONITOR_URL: https://mina.teppelinlabs.com/api/health
|
||||
MONITOR_URL: https://mina.teppelin.com/api/health
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
@@ -101,7 +101,7 @@ cp "$release/Caddyfile" Caddyfile
|
||||
mv next.env .env
|
||||
compose up -d --wait --wait-timeout 120 app monitor
|
||||
compose up -d --wait --wait-timeout 120 caddy
|
||||
curl --fail --silent --show-error --retry 5 --retry-all-errors --retry-delay 2 --max-time 15 https://mina.teppelinlabs.com/api/health > "$release/public-health.json"
|
||||
curl --fail --silent --show-error --retry 5 --retry-all-errors --retry-delay 2 --max-time 15 https://mina.teppelin.com/api/health > "$release/public-health.json"
|
||||
printf '%s\n' "$target" > deployed-sha.tmp
|
||||
mv deployed-sha.tmp deployed-sha
|
||||
rm -f failed-sha
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Minabot deployment plan
|
||||
|
||||
Target: `https://mina.teppelinlabs.com` on `syntaxbullet@46.62.156.93`.
|
||||
Target: `https://mina.teppelin.com` on `syntaxbullet@46.62.156.93`.
|
||||
Prepared September 7, 2026. Executed the same day; see
|
||||
[production operations](PRODUCTION.md) for the deployed configuration and results.
|
||||
|
||||
@@ -54,11 +54,11 @@ An unhealthy health check alone does not restart a container; monitor it separat
|
||||
then recheck SSH protection. Open TCP 80/443 in UFW and any Hetzner firewall.
|
||||
5. Put runtime secrets in `/opt/minabot/.env.production` with mode 0600 and inject
|
||||
through Compose `env_file`. Set `NODE_ENV=production`, `HOST=0.0.0.0`, `PORT=3000`,
|
||||
`APP_ORIGIN=https://mina.teppelinlabs.com`, and `DATABASE_PATH=/data/minabot.sqlite`.
|
||||
`APP_ORIGIN=https://mina.teppelin.com`, and `DATABASE_PATH=/data/minabot.sqlite`.
|
||||
Supply `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, a stable random
|
||||
`AUTH_COOKIE_SECRET`, and the bot/channel settings. Do not bake secrets into
|
||||
images or build arguments. Register this Discord OAuth redirect exactly:
|
||||
`https://mina.teppelinlabs.com/api/auth/discord/callback`.
|
||||
`https://mina.teppelin.com/api/auth/discord/callback`.
|
||||
6. Default to a fresh production database. If existing local data should be moved,
|
||||
use a verified SQLite snapshot, not a live database-file copy. Avoid running
|
||||
two workers against copied live Discord message state. Resolve the data choice
|
||||
@@ -70,7 +70,7 @@ An unhealthy health check alone does not restart a container; monitor it separat
|
||||
Proposed Caddyfile:
|
||||
|
||||
```caddyfile
|
||||
mina.teppelinlabs.com {
|
||||
mina.teppelin.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy app:3000
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Minabot production
|
||||
|
||||
Live: https://mina.teppelinlabs.com
|
||||
Live: https://mina.teppelin.com
|
||||
|
||||
SSH: `ssh syntaxbullet@46.62.156.93` (key authentication; passwordless sudo).
|
||||
Deployment root: `/opt/minabot`. Compose manages `app`, `caddy`, and `monitor`.
|
||||
@@ -22,7 +22,7 @@ logs in `/opt/minabot`. Bun 1.3.14 and Caddy images are pinned by digest.
|
||||
`/opt/minabot/.env` selects release images. `/opt/minabot/.env.production` contains
|
||||
runtime secrets (0600). Discord client and bot credentials were copied from the
|
||||
effective development environment; production has a separate generated cookie
|
||||
signing secret. Runtime uses `APP_ORIGIN=https://mina.teppelinlabs.com`,
|
||||
signing secret. Runtime uses `APP_ORIGIN=https://mina.teppelin.com`,
|
||||
`TRUST_PROXY=true`, `HOST=0.0.0.0`, and `DATABASE_PATH=/data/minabot.sqlite`.
|
||||
|
||||
`TRUST_PROXY` only accepts HTTPS from `X-Forwarded-Proto`, retains the actual
|
||||
@@ -32,7 +32,7 @@ is not published. Caddy exposes TCP 80/443 and persists certificate state in
|
||||
the iptables-compatible SSH action alongside Docker/UFW.
|
||||
|
||||
The registered Discord callback is
|
||||
`https://mina.teppelinlabs.com/api/auth/discord/callback`; localhost remains registered.
|
||||
`https://mina.teppelin.com/api/auth/discord/callback`; localhost remains registered.
|
||||
|
||||
## Data and backups
|
||||
|
||||
|
||||
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";
|
||||
@@ -48,13 +49,14 @@ const app = createApi(
|
||||
let reminderRunning = false;
|
||||
if (sharingPreview) setInterval(async () => {
|
||||
if (reminderRunning) return; reminderRunning = true;
|
||||
try { await sendDueReminders(f.db, { token: "preview-bot-token", origin }, previewDiscord, () => Date.parse("2026-09-04T12:00:00Z")); }
|
||||
try { await sendDueReminders(f.db, { token: "preview-bot-token", origin, commandsAvailable: false }, previewDiscord, () => Date.parse("2026-09-04T12:00:00Z")); }
|
||||
finally { reminderRunning = false; }
|
||||
}, 1000).unref();
|
||||
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(); }
|
||||
}
|
||||
162
src/App.test.tsx
162
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,12 +236,21 @@ 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 () => {
|
||||
@@ -233,6 +258,7 @@ describe("homepage", () => {
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm.");
|
||||
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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -273,6 +300,7 @@ describe("homepage", () => {
|
||||
expect(container.textContent).toContain("Welcome back, alice.");
|
||||
expect(container.textContent).toContain("Start with one habit.");
|
||||
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 () => {
|
||||
@@ -282,9 +310,10 @@ describe("homepage", () => {
|
||||
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
|
||||
expect((await f.json("/habits")).habits[0].name).toBe("Read a little");
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
expect(container.textContent).toContain("Read a little created.");
|
||||
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,115 @@ 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);
|
||||
const reading = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
|
||||
await render("/");
|
||||
const calendar = container.querySelector(`#history-${reading.id} .ds-calendar`);
|
||||
const originalFetch = fetchMock.getMockImplementation()!;
|
||||
let finish!: () => void;
|
||||
const pending = new Promise<void>(resolve => { finish = resolve; });
|
||||
fetchMock.mockImplementation(Object.assign(async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
if (init?.method === "PUT" && String(input).includes(`/habits/${water.id}/`)) await pending;
|
||||
return originalFetch(input, init);
|
||||
}, { 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);
|
||||
await act(async () => check.click());
|
||||
expect(check.checked).toBe(true);
|
||||
expect(buttonNamed("Increase Water").disabled).toBe(true);
|
||||
expect((await f.json("/today")).completed).toBe(1);
|
||||
fetchMock.mockClear();
|
||||
await act(async () => { finish(); await pending; });
|
||||
expect(buttonNamed("Increase Water").disabled).toBe(false);
|
||||
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')!.value).toBe("1");
|
||||
expect(container.querySelector(`#history-${reading.id} .ds-calendar`)).toBe(calendar);
|
||||
const historyRequests = fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?"));
|
||||
expect(historyRequests).toHaveLength(1);
|
||||
expect(historyRequests[0]).toContain(`/habits/${water.id}/calendar?`);
|
||||
expect((await f.json("/today")).completed).toBe(1);
|
||||
});
|
||||
|
||||
test("inline habit edits persist configuration and delete archives without losing earlier history", async () => {
|
||||
const f = connectAccount();
|
||||
f.setTime("2026-09-03T12:00:00Z");
|
||||
@@ -334,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 () => {
|
||||
@@ -351,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`);
|
||||
@@ -374,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")!;
|
||||
@@ -386,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);
|
||||
@@ -402,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")!;
|
||||
@@ -444,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") {
|
||||
@@ -456,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") {
|
||||
@@ -463,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);
|
||||
@@ -482,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")!;
|
||||
@@ -512,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);
|
||||
});
|
||||
@@ -532,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,10 +18,13 @@ 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);
|
||||
const liveSharing = createLiveDiscord(db, auth, sharingConfig, now ?? Date.now, discordRequest);
|
||||
const sharing = createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest);
|
||||
app.use("*", async (c, next) => {
|
||||
await next();
|
||||
if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method) && /^\/api\/(habits|me|account)(\/|$)/.test(c.req.path) && c.res.status < 400 && c.get("user"))
|
||||
@@ -33,14 +36,14 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
|
||||
if (operations.healthy && !operations.healthy()) return c.json({ status: "degraded" }, 503);
|
||||
return c.json({ status: "ok" });
|
||||
});
|
||||
app.route("/api/discord/interactions", createDiscordRoutes(db, operations.discord ?? { publicKey: "", applicationId: config.clientId, origin: config.origin }, now ?? Date.now, liveSharing.changed));
|
||||
app.route("/api/discord/interactions", createDiscordRoutes(db, { ...(operations.discord ?? { publicKey: "", applicationId: config.clientId, origin: config.origin }), remindersAvailable: Boolean(sharingConfig.token) }, now ?? Date.now, liveSharing.changed, { progress: { sharing, live: liveSharing } }));
|
||||
app.route("/api/sharing/live", liveSharing.routes);
|
||||
app.route("/api/auth", auth.routes);
|
||||
app.route("/api/reminders", createReminderRoutes(db, auth, Boolean(sharingConfig.token), now ?? Date.now));
|
||||
app.route("/api/account", createAccountRoutes(db, auth, now ?? Date.now));
|
||||
app.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
|
||||
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
|
||||
app.route("/api/sharing", createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest));
|
||||
app.route("/api/sharing", sharing);
|
||||
app.notFound(c => c.json({ error: "Not found" }, 404));
|
||||
app.onError((_error, c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -24,9 +24,9 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
|
||||
catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); }
|
||||
finally { saving.current = false; setBusy(false); }
|
||||
}
|
||||
return <WorkspacePanel id="account-settings" eyebrow="YOUR WORKSPACE" title="Account settings" description="A few preferences to make this space yours." onClose={onClose} busy={busy || reminderBusy} closeLabel="Close settings">
|
||||
<SettingRow title="Appearance" description={<p>Follow your device or choose a theme for this browser.</p>}><ThemeControl /></SettingRow>
|
||||
<SettingRow title="Your daily rhythm" description={<p>Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.</p>}>
|
||||
return <WorkspacePanel id="account-settings" title="Account settings" onClose={onClose} busy={busy || reminderBusy} closeLabel="Close settings">
|
||||
<SettingRow title="Appearance"><ThemeControl /></SettingRow>
|
||||
<SettingRow title="Daily timezone" description={<p>Sets day boundaries. Earlier deadlines stay unchanged.</p>}>
|
||||
<form className="ds-form-stack" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}>
|
||||
<Field label="Timezone">
|
||||
{id => <input id={id} list={listId} value={timezone} required maxLength={100} disabled={busy || reminderBusy} onChange={event => setTimezone(event.target.value)} />}
|
||||
@@ -36,7 +36,7 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
|
||||
</form>
|
||||
</SettingRow>
|
||||
<ReminderSettings timezone={user.timezone} onBusyChange={setReminderBusy} disabled={busy} />
|
||||
<SettingRow title="A copy for you" description={<p>Take your profile, habits, history, and sharing records with you. Sessions and credentials are excluded.</p>}>
|
||||
<SettingRow title="Export data" description={<p>Download your profile, habits, and history.</p>}>
|
||||
<div className="ds-action-row"><ButtonLink variant="secondary" href="/api/account/export" download="mina-habits-export.json"><Download size={16} aria-hidden="true" />Export my data</ButtonLink><span className="ds-meta-label">JSON file</span></div>
|
||||
</SettingRow>
|
||||
<SettingRow title="Delete account" description={<p>Permanently remove your account and habit history, and sign out every device.</p>}>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
|
||||
setDeleteError(message); if (message.includes("session has expired")) expired.current();
|
||||
} finally { saving.current = false; setBusy(false); }
|
||||
}
|
||||
return <WorkspacePanel id="archived-habits" eyebrow="SAVED FOR LATER" title="Archived habits" description="Earlier progress stays here. Restore a habit whenever you’re ready." onClose={onClose} busy={busy} closeLabel="Close archive">
|
||||
return <WorkspacePanel id="archived-habits" title="Archived habits" onClose={onClose} busy={busy} closeLabel="Close archive">
|
||||
<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>}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Window } from "happy-dom";
|
||||
import { act, useState } from "react";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { HabitForm } from "./HabitForm";
|
||||
import { HabitColorPicker } from "./design-system/HabitColorPicker";
|
||||
import { HABIT_COLOR_CHOICES, HabitColorPicker } from "./design-system/HabitColorPicker";
|
||||
|
||||
const dom = new Window({ url: "http://localhost:3000/design-system" });
|
||||
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
@@ -46,33 +46,33 @@ function button(text: string) {
|
||||
return [...container.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent?.trim() === text)!;
|
||||
}
|
||||
|
||||
test("habit starters remain editable and custom creation resets the draft", async () => {
|
||||
test("blank habit creation exposes tracking-specific fields without templates", async () => {
|
||||
await act(async () => root.render(<HabitForm date="2026-09-05" onCancel={() => {}} onCreated={() => {}} onExpired={() => {}} />));
|
||||
await act(async () => button("Reading").click());
|
||||
expect(container.querySelector<HTMLInputElement>('input[placeholder="e.g. Read ten pages"]')?.value).toBe("Read");
|
||||
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("count");
|
||||
expect(container.querySelector<HTMLInputElement>('input[type="number"]')?.value).toBe("10");
|
||||
await act(async () => button("Evening routine").click());
|
||||
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("tasks");
|
||||
expect([...container.querySelectorAll("input")].map(input => input.value)).toContain("Prepare for tomorrow");
|
||||
await act(async () => button("Start from scratch").click());
|
||||
expect(container.querySelector<HTMLInputElement>('input[placeholder="e.g. Read ten pages"]')?.value).toBe("");
|
||||
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("manual");
|
||||
expect(container.querySelector('[aria-label="Start with an idea"]')).toBeNull();
|
||||
const method = container.querySelector<HTMLSelectElement>("select")!;
|
||||
expect(method.value).toBe("manual");
|
||||
await act(async () => { method.value = "count"; method.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
expect(container.querySelector<HTMLInputElement>('input[type="number"]')?.value).toBe("10");
|
||||
await act(async () => { method.value = "tasks"; method.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
expect(container.querySelector('input[type="number"]')).toBeNull();
|
||||
await act(async () => button("Add task +").click());
|
||||
expect([...container.querySelectorAll("label")].filter(label => /^Task \d+$/.test(label.textContent ?? ""))).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("compact palette discloses full controls and preserves custom color", async () => {
|
||||
test("continuous colors disclose custom controls and preserve the selected color", async () => {
|
||||
function Picker() {
|
||||
const [color, setColor] = useState("#123456");
|
||||
return <HabitColorPicker value={color} onChange={setColor} compact />;
|
||||
return <HabitColorPicker value={color} onChange={setColor} />;
|
||||
}
|
||||
await act(async () => root.render(<Picker />));
|
||||
expect(container.querySelector('[aria-label="Suggested colors"]')?.querySelectorAll("button").length).toBe(8);
|
||||
const disclosure = button("More colors");
|
||||
expect(container.querySelector('[aria-label="Habit colors"]')?.querySelectorAll('button[aria-label^="Color "]').length).toBe(HABIT_COLOR_CHOICES.length);
|
||||
const disclosure = button("Custom");
|
||||
const details = document.getElementById(disclosure.getAttribute("aria-controls")!)!;
|
||||
expect(details.hidden).toBe(true);
|
||||
await act(async () => disclosure.click());
|
||||
expect(details.hidden).toBe(false);
|
||||
expect(container.querySelector<HTMLInputElement>('input[type="text"]')?.value).toBe("#123456");
|
||||
await act(async () => button("Fewer colors").click());
|
||||
await act(async () => button("Custom").click());
|
||||
expect(container.querySelector('[aria-label="Progress shades using #123456"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -7,12 +7,6 @@ import { HabitColorPicker } from "./design-system/HabitColorPicker";
|
||||
import { habitInput, type Schedule } from "../habits/contracts";
|
||||
import { habitRequest } from "../lib/dashboard";
|
||||
|
||||
const STARTERS = [
|
||||
{ label: "Reading", name: "Read", method: "count", target: 10, unit: "pages", tasks: [""], color: "#426582" },
|
||||
{ label: "Get outside", name: "Get outside", method: "manual", target: 1, unit: "times", tasks: [""], color: "#58765b" },
|
||||
{ label: "Evening routine", name: "Wind down", method: "tasks", target: 1, unit: "times", tasks: ["Put things away", "Prepare for tomorrow"], color: "#79618d" },
|
||||
] as const;
|
||||
|
||||
/** Account behavior composed from the design system's existing form components. */
|
||||
export function HabitForm({
|
||||
date,
|
||||
@@ -33,22 +27,10 @@ export function HabitForm({
|
||||
const [unit, setUnit] = useState("pages");
|
||||
const [tasks, setTasks] = useState([""]);
|
||||
const [schedule, setSchedule] = useState<Schedule>({ type: "daily" });
|
||||
const [color, setColor] = useState("#58765b");
|
||||
const [color, setColor] = useState("#3e805d");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function applyStarter(starter: (typeof STARTERS)[number] | null) {
|
||||
setName(starter?.name ?? "");
|
||||
setMethod(starter?.method ?? "manual");
|
||||
setTarget(starter?.target ?? 10);
|
||||
setUnit(starter?.unit ?? "pages");
|
||||
setTasks(starter ? [...starter.tasks] : [""]);
|
||||
setColor(starter?.color ?? "#58765b");
|
||||
setSchedule({ type: "daily" });
|
||||
setError("");
|
||||
form.current?.querySelector<HTMLInputElement>("input")?.focus();
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (saving.current) return;
|
||||
@@ -93,17 +75,9 @@ export function HabitForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal id="new-habit" eyebrow="A NEW HABIT" title={<>Make it <em>yours.</em></>} description="Schedules follow your account’s timezone." onClose={onCancel} closeLabel="Close new habit" busy={busy} size="compact" initialFocus="input" returnFocus={() => document.getElementById("add-habit")}>
|
||||
<Modal id="new-habit" title="Create habit" onClose={onCancel} closeLabel="Close new habit" busy={busy} size="compact" initialFocus="input" returnFocus={() => document.getElementById("add-habit")}>
|
||||
<form ref={form} className="ds-edit-content" onSubmit={submit}>
|
||||
<fieldset className="ds-task-editor-fieldset" disabled={busy}>
|
||||
<div className="ds-habit-starters" role="group" aria-label="Start with an idea">
|
||||
<div className="ds-habit-starter-options">
|
||||
{STARTERS.map((starter) => (
|
||||
<Button key={starter.label} variant="secondary" onClick={() => applyStarter(starter)}>{starter.label}</Button>
|
||||
))}
|
||||
<Button variant="text" onClick={() => applyStarter(null)}>Start from scratch</Button>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className="ds-task-editor-fieldset ds-habit-form-grid" disabled={busy}>
|
||||
<Field label="Habit name">
|
||||
{(id) => (
|
||||
<input
|
||||
@@ -116,7 +90,7 @@ export function HabitForm({
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="How will you track it?">
|
||||
<Field label="Tracking">
|
||||
{(id) => (
|
||||
<select
|
||||
id={id}
|
||||
@@ -131,9 +105,10 @@ export function HabitForm({
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
|
||||
{method === "count" && (
|
||||
<div className="ds-form-grid">
|
||||
<Field label="Target per scheduled day">
|
||||
<Field label="Daily target">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
@@ -202,17 +177,9 @@ export function HabitForm({
|
||||
>
|
||||
Add task +
|
||||
</Button>
|
||||
<p className="ds-form-feedback">
|
||||
Tasks repeat on each scheduled day.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ScheduleEditor
|
||||
value={schedule}
|
||||
onChange={setSchedule}
|
||||
anchorDate={date}
|
||||
/>
|
||||
<HabitColorPicker value={color} onChange={setColor} mode="create" compact />
|
||||
<HabitColorPicker value={color} onChange={setColor} mode="create" />
|
||||
</fieldset>
|
||||
{error && (
|
||||
<p className="ds-form-feedback" role="alert">
|
||||
|
||||
@@ -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 { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import 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,26 +73,20 @@ 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 =
|
||||
const days = useMemo(() =>
|
||||
calendar?.days.map((day) => ({
|
||||
date: day.date,
|
||||
color: day.color,
|
||||
@@ -80,14 +98,14 @@ export function HabitHistory({
|
||||
: day.due
|
||||
? ("due" as const)
|
||||
: ("not-due" as const),
|
||||
})) ?? [];
|
||||
})) ?? [], [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 +119,7 @@ export function HabitHistory({
|
||||
).color,
|
||||
),
|
||||
]
|
||||
: [];
|
||||
: [], [calendar, steps]);
|
||||
const chart = error ? (
|
||||
<div className="ds-form-feedback">
|
||||
<p role="alert">{error}</p>
|
||||
@@ -119,17 +137,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`}
|
||||
@@ -139,9 +160,9 @@ export function HabitHistory({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{editingDay && <Modal id={`check-in-${habit.habitId}`} eyebrow="YOUR HISTORY" title="Edit check-in" description={habit.name ?? "Habit"} closeLabel="Close check-in editor" onClose={() => setEditingDay(false)} busy={savingDay}>
|
||||
{editingDay && <Modal id={`check-in-${habit.habitId}`} title="Edit check-in" description={habit.name ?? "Habit"} closeLabel="Close check-in editor" onClose={() => setEditingDay(false)} busy={savingDay}>
|
||||
<div className="ds-history-editor">
|
||||
<div className="ds-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>}
|
||||
|
||||
@@ -30,13 +30,14 @@ export function InlineHabitEditor({ config, color: initialColor, date, mode, dis
|
||||
if (!parsed.success) throw new Error(parsed.error.issues.map((issue) => issue.message).join(" "));
|
||||
await onSave(parsed.data);
|
||||
}}>
|
||||
<Field label="How will you track it?">
|
||||
<Field label="Tracking">
|
||||
{(id) => <select id={id} value={method} onChange={(event) => setMethod(event.target.value as HabitConfig["method"])}>
|
||||
<option value="manual">A simple check-in</option>
|
||||
<option value="count">A count target</option>
|
||||
<option value="tasks">A list of tasks</option>
|
||||
</select>}
|
||||
</Field>
|
||||
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
|
||||
{method !== config.method && <p className="ds-footnote">Changing the tracking method starts today’s progress over. Earlier history is kept.{method === "tasks" ? " After saving, add tasks below." : config.method === "tasks" ? " Existing tasks will leave this habit." : ""}</p>}
|
||||
{method === "count" && (<>
|
||||
<div className="ds-form-grid">
|
||||
@@ -44,11 +45,9 @@ export function InlineHabitEditor({ config, color: initialColor, date, mode, dis
|
||||
<Field label="Unit">{(id) => <input id={id} required maxLength={80} value={unit} onChange={(event) => setUnit(event.target.value)} />}</Field>
|
||||
</div>
|
||||
<Checkbox label="Carry unfinished counts to the next scheduled day" checked={carryPartialProgress} onChange={(event) => setCarryPartialProgress(event.target.checked)} />
|
||||
<p className="ds-footnote">Completed counts reset. Explicitly recorded counts are kept.</p>
|
||||
</>)}
|
||||
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
|
||||
<HabitColorPicker value={color} onChange={setColor} mode="edit" />
|
||||
<p className="ds-footnote">Changes start today. Earlier targets and schedules stay as they were.</p>
|
||||
<p className="ds-footnote">Applies from today. Earlier history is kept.</p>
|
||||
</InlineItemForm>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
|
||||
const action = mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete";
|
||||
|
||||
return (
|
||||
<Modal id={`item-${id}`} eyebrow={kind === "habit" ? "YOUR HABIT" : "YOUR TASK"} title={formLabel ?? `${action} ${kind}`} description={name || undefined} onClose={onClose} closeLabel={`Close ${kind} editor`} busy={busy} size="compact" returnFocus={() => [...document.querySelectorAll<HTMLButtonElement>("button[aria-label]")].find(button => button.getAttribute("aria-label") === `${action} ${kind} ${name}`) ?? null} initialFocus={mode === "edit" ? "input" : 'button[type="button"]:not(.ds-panel-close)'}>
|
||||
<Modal id={`item-${id}`} title={formLabel ?? `${action} ${kind}`} onClose={onClose} closeLabel={`Close ${kind} editor`} busy={busy} size="compact" returnFocus={() => [...document.querySelectorAll<HTMLButtonElement>("button[aria-label]")].find(button => button.getAttribute("aria-label") === `${action} ${kind} ${name}`) ?? null} initialFocus={mode === "edit" ? "input" : 'button[type="button"]:not(.ds-panel-close)'}>
|
||||
<form ref={form} className="ds-modal-form" aria-label={formLabel ?? `${mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete"} ${kind} ${name}`}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -48,7 +48,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
|
||||
}
|
||||
}}>
|
||||
{mode === "edit" ? (
|
||||
<fieldset className="ds-inline-item-fields" disabled={busy || disabled}>
|
||||
<fieldset className={`ds-inline-item-fields${kind === "habit" ? " ds-habit-form-grid" : ""}`} disabled={busy || disabled}>
|
||||
<Field label={kind === "habit" ? "Habit name" : "Task name"}>
|
||||
{(id) => <input id={id} value={draft} required maxLength={200} onChange={(event) => setDraft(event.target.value)} />}
|
||||
</Field>
|
||||
@@ -57,7 +57,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
|
||||
) : (
|
||||
<p className="ds-inline-delete-copy">
|
||||
{kind === "habit" ? "Archive" : "Delete"} <strong>{name}</strong>? {kind === "habit"
|
||||
? "This removes the habit from your dashboard starting today. Earlier history is kept. You can restore it from Archived habits."
|
||||
? "History is kept. Restore it anytime from Archived habits."
|
||||
: "This removes the task from today and future check-ins. Earlier history is kept."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -26,8 +26,8 @@ export function InlineTaskEditor({ name, schedule: savedSchedule, habitSchedule,
|
||||
await onSave(parsed.data);
|
||||
}}>
|
||||
<ScheduleEditor value={schedule} onChange={setSchedule} anchorDate={date} />
|
||||
<p className="ds-footnote">This task is due when both its recurrence and the habit’s schedule match. Habit: {scheduleLabel(habitSchedule)}.</p>
|
||||
{!creating && <p className="ds-footnote">Changes start today. Earlier check-ins keep their original schedule.</p>}
|
||||
<p className="ds-footnote">Runs when both schedules match. Habit: {scheduleLabel(habitSchedule)}.</p>
|
||||
{!creating && <p className="ds-footnote">Applies from today. Earlier check-ins stay unchanged.</p>}
|
||||
</InlineItemForm>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,11 +42,11 @@ export function LiveDiscordShare({ revision }: { revision: number }) {
|
||||
const ready = preview?.theme === theme && preview.revision === revision ? preview : null;
|
||||
const active = state?.status === 'active' || state?.status === 'sending';
|
||||
return <section className="ds-live-discord" aria-label="Live Discord image">
|
||||
<div className="ds-live-discord-heading"><div><h3>All your habits. One shared image.</h3><p>1920 × 1080 · 16:9 · Last 30 days</p></div><ThemeControl disabled={busy} /></div>
|
||||
<div className="ds-live-discord-heading"><div><h3>All habits</h3><p>Last 30 days</p></div><ThemeControl disabled={busy} /></div>
|
||||
<div className="ds-live-discord-preview" aria-busy={!ready && !previewError}>
|
||||
{previewError ? <p role="alert">{previewError}</p> : ready ? <img src={ready.url} alt="16:9 Discord card containing all active habit names and their daily progress for the last 30 days." /> : <p role="status">Creating your image…</p>}
|
||||
</div>
|
||||
<p className="ds-live-discord-description">The bot posts this image in {connection?.connected ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a> : 'the configured Discord channel'}, then replaces the attachment on that same message after changes settle for two seconds. Updates continue with this page closed.</p>
|
||||
<p className="ds-live-discord-description">Automatically updates in {connection?.connected ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a> : 'the configured Discord channel'}. Updates continue with this page closed.</p>
|
||||
<p className="ds-live-discord-privacy">Shares your display name, avatar, all active habit names, and progress—including new habits. Task details stay private. Stopping updates leaves the last image in Discord.</p>
|
||||
<div className="ds-live-discord-footer">
|
||||
<div aria-live="polite">{state?.messageUrl && <a href={state.messageUrl} target="_blank" rel="noreferrer">View message in Discord ↗</a>}
|
||||
|
||||
43
src/components/PwaControls.tsx
Normal file
43
src/components/PwaControls.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useState } from "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="text" onClick={() => { if (installPrompt) void installApp(); else setHelp(true); }}>Install app</Button>
|
||||
{help && <Modal id="install-app" size="compact" title="Keep Mina Habits close." onClose={() => setHelp(false)}>
|
||||
<div className="ds-form-stack">
|
||||
<p>Open Mina Habits from your home screen or dock.</p>
|
||||
<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">Installation options depend on your browser. Your habits still need a connection to load and save.</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>}
|
||||
</>;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export function ReminderSettings({ timezone, onBusyChange, disabled = false }: {
|
||||
return () => controller.abort();
|
||||
}, [attempt]);
|
||||
const changeEntry = (index: number, entry: ReminderEntry) => setDraft(value => ({ ...value, reminders: value.reminders.map((previous, position) => position === index ? entry : previous) }));
|
||||
return <SettingRow title="Reminders" description={<><p>A private Discord nudge when you still have habits left today. Named reminders include their name in the message. No habit names are sent.</p><p className="ds-meta-label">Times in {timezone}</p></>}>
|
||||
return <SettingRow title="Reminders" description={<><p>Private Discord reminders for unfinished habits. Reminder names are included; habit names stay private.</p><p className="ds-meta-label">Times in {timezone}</p></>}>
|
||||
{!settings ? error ? <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Reload reminders</Button> : <p className="ds-supporting-copy" role="status">Loading reminders…</p> : <form className="ds-form-stack" onSubmit={async event => {
|
||||
event.preventDefault(); if (saving.current || disabled) return;
|
||||
const form = event.currentTarget;
|
||||
@@ -40,7 +40,7 @@ export function ReminderSettings({ timezone, onBusyChange, disabled = false }: {
|
||||
}}>
|
||||
{!settings.available && <p className="ds-supporting-copy">Discord reminders are not configured on this server.</p>}
|
||||
<fieldset className="ds-inline-item-fields ds-form-stack" disabled={busy || disabled}>
|
||||
<Checkbox className="ds-preference-toggle" description="Choose when to receive a nudge. Turn off anytime." label="Enable Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} />
|
||||
<Checkbox className="ds-preference-toggle" label="Enable Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} />
|
||||
{draft.reminders.map((entry, index) => <div key={index} className="ds-form-stack">
|
||||
<Field label={`Reminder ${index + 1} name (optional)`}>{id => <input id={id} value={entry.name ?? ""} placeholder="e.g. Morning check-in" maxLength={80} onChange={event => changeEntry(index, { ...entry, name: event.target.value })} />}</Field>
|
||||
<div className="ds-clock-fields">
|
||||
|
||||
@@ -76,9 +76,9 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
||||
finally { lock.current = false; setSending(false); }
|
||||
}
|
||||
return (
|
||||
<Modal id="share-progress" eyebrow="SHARE A LITTLE PROGRESS" title="Made for your people." description="Choose what to include. See exactly what you’ll share." onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
|
||||
<Modal id="share-progress" title="Share progress" onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
|
||||
<div className="ds-sharing-mode" role="group" aria-label="Sharing format">
|
||||
<Button variant={mode === "live" ? "primary" : "secondary"} disabled={busy} aria-pressed={mode === "live"} onClick={() => setMode("live")}>Live image · 16:9</Button>
|
||||
<Button variant={mode === "live" ? "primary" : "secondary"} disabled={busy} aria-pressed={mode === "live"} onClick={() => setMode("live")}>Live image</Button>
|
||||
<Button variant={mode === "snapshot" ? "primary" : "secondary"} disabled={busy} aria-pressed={mode === "snapshot"} onClick={() => setMode("snapshot")}>Snapshot</Button>
|
||||
</div>
|
||||
{mode === "live" ? <LiveDiscordShare revision={revision} /> : <>
|
||||
@@ -91,7 +91,7 @@ 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">{thumbnail ? "A small-size preview, like an image in chat." : "A closer look at your card."} Same image in Discord and your download.</p>
|
||||
<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">
|
||||
@@ -104,7 +104,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
||||
<fieldset className="ds-share-fieldset" disabled={busy}>
|
||||
<legend>Habits <span className="ds-share-count">{ids.length} / 6</span></legend>
|
||||
<div className="ds-share-habits">{today.habits.map(habit => <Checkbox key={habit.habitId} label={habit.name ?? "Habit"} checked={ids.includes(habit.habitId)} disabled={!ids.includes(habit.habitId) && ids.length >= 6} onChange={e => setIds(values => e.target.checked ? [...values, habit.habitId] : values.filter(id => id !== habit.habitId))} />)}</div>
|
||||
<p className="ds-share-hint">Choose up to six. Task names stay private.</p>
|
||||
<p className="ds-share-hint">Task names stay private.</p>
|
||||
</fieldset>
|
||||
<details className="ds-share-privacy"><summary><ShieldCheck size={17} aria-hidden="true" /><span>Privacy & details</span><ChevronDown size={16} aria-hidden="true" /></summary>
|
||||
<fieldset className="ds-share-fieldset" disabled={busy}><legend className="ds-visually-hidden">Show on the card</legend>
|
||||
@@ -118,7 +118,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
||||
</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 when you are." : "Your card is taking shape."}<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…"}<span>{sending ? "This may take a moment." : "Only the details in your preview are shared."}</span></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)");
|
||||
@@ -37,9 +38,9 @@ export function ThemeControl({ disabled = false }: { disabled?: boolean }) {
|
||||
{ value: "light", label: "Light", Icon: Sun },
|
||||
{ value: "dark", label: "Dark", Icon: Moon },
|
||||
] as const).map(({ value, label, Icon }) => (
|
||||
<label className="ds-theme-option" key={value}>
|
||||
<label className="ds-theme-option" key={value} title={`${label} theme`}>
|
||||
<input type="radio" name={`theme-${id}`} value={value} checked={preference === value} disabled={disabled} onChange={() => setPreference(value)} />
|
||||
<span><Icon size={16} strokeWidth={1.75} aria-hidden="true" />{label}</span>
|
||||
<span><Icon size={16} strokeWidth={1.75} aria-hidden="true" /><span className="ds-visually-hidden">{label}</span></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
@@ -110,13 +118,14 @@ export function CalendarHeatmap({
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}{" "}
|
||||
–{" "}
|
||||
{months !== 1 && <>–{" "}
|
||||
{new Date(`${days.at(-1)!.date}T12:00:00Z`).toLocaleDateString("en", {
|
||||
month: "short",
|
||||
day: months === "custom" ? "numeric" : undefined,
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
</>}
|
||||
</span>
|
||||
<div
|
||||
className="ds-month-views"
|
||||
@@ -127,9 +136,9 @@ export function CalendarHeatmap({
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-label={`${value} months`}
|
||||
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>
|
||||
@@ -141,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
|
||||
@@ -159,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();
|
||||
@@ -170,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("");
|
||||
}}
|
||||
>
|
||||
@@ -186,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}
|
||||
@@ -200,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"
|
||||
|
||||
@@ -63,8 +63,8 @@ export function HabitChart({
|
||||
<div className="ds-goal-body">
|
||||
<div className="ds-goal-completion">
|
||||
{trackingMethod === "count" && <div className="ds-goal-count">
|
||||
<p className="ds-goal-target"><span className="ds-goal-unit">{unit}</span></p>
|
||||
{children}
|
||||
<p className="ds-goal-target"><span className="ds-goal-unit">{unit}</span></p>
|
||||
</div>}
|
||||
{trackingMethod === "manual" && <div className="ds-goal-check-in">{children}<label htmlFor={checkInId}>{complete ? "Done for today" : "Mark as done"}</label></div>}
|
||||
{trackingMethod === "tasks" && <>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
HABIT_PALETTES,
|
||||
HABIT_COLOR_CHOICES,
|
||||
isHabitColor,
|
||||
previewHabitColors,
|
||||
} from "./HabitColorPicker";
|
||||
import { HABIT_COLORS } from "./calendar-model";
|
||||
|
||||
test("eight suggested palettes contain thirty-two unique, valid habit colors", () => {
|
||||
const colors = HABIT_PALETTES.flatMap((palette) => [...palette.colors]);
|
||||
expect(HABIT_PALETTES.length).toBe(8);
|
||||
expect(HABIT_PALETTES.every((palette) => palette.colors.length === 4)).toBe(true);
|
||||
expect(new Set(colors).size).toBe(32);
|
||||
expect(colors.every(isHabitColor)).toBe(true);
|
||||
test("the continuous color selection contains distinct, valid colors", () => {
|
||||
expect(new Set(HABIT_COLOR_CHOICES).size).toBe(HABIT_COLOR_CHOICES.length);
|
||||
expect(HABIT_COLOR_CHOICES.every(isHabitColor)).toBe(true);
|
||||
});
|
||||
|
||||
test("custom colors accept six hex digits and reject incomplete or malformed colors", () => {
|
||||
|
||||
@@ -24,15 +24,11 @@ export function previewHabitColors(
|
||||
return colors;
|
||||
}
|
||||
|
||||
export const HABIT_PALETTES = [
|
||||
{ name: "Earth", colors: ["#58765b", "#977344", "#a3604d", "#76734e"] },
|
||||
{ name: "Coast", colors: ["#426582", "#427b80", "#657e9c", "#667d70"] },
|
||||
{ name: "Dusk", colors: ["#79618d", "#9c647c", "#736a9c", "#686878"] },
|
||||
{ name: "Forest", colors: ["#386641", "#52734d", "#6b705c", "#3d6b62"] },
|
||||
{ name: "Citrus", colors: ["#b45309", "#a16207", "#9a5b35", "#7a801c"] },
|
||||
{ name: "Blossom", colors: ["#b05276", "#a34e62", "#a65f5a", "#8e5572"] },
|
||||
{ name: "Jewel", colors: ["#6d28a8", "#1d678d", "#087f73", "#a12e54"] },
|
||||
{ name: "Slate", colors: ["#475569", "#52616b", "#65605b", "#404047"] },
|
||||
export const HABIT_COLOR_CHOICES = [
|
||||
"#b44f57", "#b86643", "#a7792c", "#858337",
|
||||
"#5c813f", "#3e805d", "#337e79", "#387b98",
|
||||
"#466db0", "#6263ad", "#885ba6", "#a35488",
|
||||
"#956976", "#89705a", "#667b82", "#606571",
|
||||
] as const;
|
||||
|
||||
export function isHabitColor(value: string) {
|
||||
@@ -42,65 +38,33 @@ export function isHabitColor(value: string) {
|
||||
export function HabitColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
compact = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
mode?: "demo" | "create" | "edit";
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const id = useId();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const showDetails = !compact || expanded;
|
||||
const valid = isHabitColor(value);
|
||||
const showCustom = expanded || !valid;
|
||||
return (
|
||||
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
|
||||
<legend>Habit color</legend>
|
||||
<p id={`${id}-hint`} className={showDetails ? "ds-footnote" : "ds-visually-hidden"}>
|
||||
<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>
|
||||
{compact && (
|
||||
<>
|
||||
<div className="ds-color-swatches ds-color-swatches--compact" role="group" aria-label="Suggested colors">
|
||||
{HABIT_PALETTES.map((palette) => (
|
||||
<button key={palette.name} type="button" aria-label={`${palette.name} ${palette.colors[0]}`} aria-pressed={value.toLowerCase() === palette.colors[0]} title={palette.name} onClick={() => onChange(palette.colors[0])}>
|
||||
<span style={{ backgroundColor: habitProgressColor(palette.colors[0], 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>{value.toLowerCase() === palette.colors[0] ? "✓" : ""}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="ds-button ds-button--text" aria-expanded={expanded} aria-controls={`${id}-details`} onClick={() => setExpanded(!expanded)}>{expanded ? "Fewer colors" : "More colors"}</button>
|
||||
</>
|
||||
)}
|
||||
<div id={`${id}-details`} hidden={!showDetails}>
|
||||
<div className="ds-color-palettes">
|
||||
{HABIT_PALETTES.map((palette) => (
|
||||
<div
|
||||
key={palette.name}
|
||||
className="ds-color-palette"
|
||||
role="group"
|
||||
aria-label={`${palette.name} palette`}
|
||||
>
|
||||
<span>{palette.name}</span>
|
||||
<div className="ds-color-swatches">
|
||||
{palette.colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
aria-label={`${palette.name} ${color}`}
|
||||
aria-pressed={value.toLowerCase() === color}
|
||||
title={color}
|
||||
onClick={() => onChange(color)}
|
||||
>
|
||||
<span style={{ backgroundColor: habitProgressColor(color, 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>
|
||||
{value.toLowerCase() === color ? "✓" : ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ds-color-swatches" role="group" aria-label="Habit colors">
|
||||
{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" }}>
|
||||
{value.toLowerCase() === color ? "✓" : ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="ds-color-custom-toggle" aria-pressed={valid && !HABIT_COLOR_CHOICES.some(color => color === value.toLowerCase())} aria-expanded={showCustom} aria-controls={`${id}-custom`} onClick={() => setExpanded(!showCustom)}>Custom</button>
|
||||
</div>
|
||||
<div id={`${id}-custom`} hidden={!showCustom}>
|
||||
<div className="ds-custom-color">
|
||||
<Field label="Custom color" id={`${id}-native`}>
|
||||
{(inputId) => (
|
||||
@@ -137,7 +101,6 @@ export function HabitColorPicker({
|
||||
className="ds-color-live-preview"
|
||||
aria-label="Live calendar color preview"
|
||||
>
|
||||
<span className="ds-muted">Calendar preview</span>
|
||||
<div
|
||||
className="ds-color-preview-shades"
|
||||
role="img"
|
||||
@@ -152,7 +115,6 @@ export function HabitColorPicker({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="ds-muted">0 → complete</span>
|
||||
</div>
|
||||
)}
|
||||
{!valid && (
|
||||
|
||||
@@ -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({
|
||||
@@ -34,10 +35,12 @@ export function PageLayout({
|
||||
<div className="ds-header-actions">{header}<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>
|
||||
<InstallApp />
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "instant" })}
|
||||
|
||||
@@ -3,13 +3,13 @@ import { Modal } from "./Modal";
|
||||
|
||||
/** Secondary workspace flows share one modal presentation. */
|
||||
export function WorkspacePanel({ id, eyebrow, title, description, children, onClose, closeLabel = "Close panel", busy }: {
|
||||
id: string; eyebrow: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string; busy?: boolean;
|
||||
id: string; eyebrow?: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string; busy?: boolean;
|
||||
}) {
|
||||
return <Modal id={id} eyebrow={eyebrow} title={title} description={description} onClose={onClose} closeLabel={closeLabel} busy={busy}>{children}</Modal>;
|
||||
}
|
||||
|
||||
/** Consistent label, explanation and control columns for preference forms. */
|
||||
export function SettingRow({ title, description, children }: { title: string; description: ReactNode; children: ReactNode }) {
|
||||
export function SettingRow({ title, description, children }: { title: string; description?: ReactNode; children: ReactNode }) {
|
||||
const id = useId();
|
||||
return <section className="ds-setting-row" aria-labelledby={id}>
|
||||
<header className="ds-setting-heading"><h3 id={id}>{title}</h3><div>{description}</div></header>
|
||||
|
||||
@@ -190,13 +190,13 @@ test("invalid custom colors associate both guidance and corrective feedback", as
|
||||
|
||||
test("unified calendar retains every date and navigates across months in every range", async () => {
|
||||
await act(async () => root.render(<CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" />));
|
||||
for (const months of [3, 4, 6, 12]) {
|
||||
for (const months of [1, 3, 4, 6, 12]) {
|
||||
await act(async () => button(`${months}m`).click());
|
||||
expect(container.querySelectorAll(".ds-calendar-grid")).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".ds-calendar-weekdays")).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".ds-calendar-month-label")).toHaveLength(months);
|
||||
const dates = [...container.querySelectorAll<HTMLButtonElement>(".ds-day")];
|
||||
expect(dates).toHaveLength({ 3: 92, 4: 122, 6: 183, 12: 365 }[months]!);
|
||||
expect(dates).toHaveLength({ 1: 30, 3: 92, 4: 122, 6: 183, 12: 365 }[months]!);
|
||||
expect(new Set(dates.map(day => day.getAttribute("aria-label"))).size).toBe(dates.length);
|
||||
expect(dates.filter(day => day.tabIndex === 0)).toHaveLength(1);
|
||||
const firstDate = new Date(`${dates[0]!.getAttribute("aria-label")!.slice(0, 10)}T00:00:00Z`);
|
||||
|
||||
@@ -104,11 +104,14 @@ test("month presets include exact whole months and cross year and leap-year boun
|
||||
expect(
|
||||
MONTH_VIEWS.map((months) => monthWindow("2026-09-04", months)),
|
||||
).toEqual([
|
||||
{ from: "2026-09-01", to: "2026-09-30" },
|
||||
{ from: "2026-07-01", to: "2026-09-30" },
|
||||
{ from: "2026-06-01", to: "2026-09-30" },
|
||||
{ from: "2026-04-01", to: "2026-09-30" },
|
||||
{ from: "2025-10-01", to: "2026-09-30" },
|
||||
]);
|
||||
expect(monthWindow("2024-02-29", 1)).toEqual({ from: "2024-02-01", to: "2024-02-29" });
|
||||
expect(monthWindow("2026-01-04", 1)).toEqual({ from: "2026-01-01", to: "2026-01-31" });
|
||||
expect(monthWindow("2024-02-29", 3)).toEqual({
|
||||
from: "2023-12-01",
|
||||
to: "2024-02-29",
|
||||
@@ -121,7 +124,7 @@ test("switching month views preserves dated values, complete coverage and future
|
||||
MONTH_VIEWS.map(
|
||||
(months) => visibleCalendarDays(days, months, "2026-09-04").length,
|
||||
),
|
||||
).toEqual([92, 122, 183, 365]);
|
||||
).toEqual([30, 92, 122, 183, 365]);
|
||||
for (const months of MONTH_VIEWS) {
|
||||
const visible = visibleCalendarDays(days, months, "2026-09-04");
|
||||
expect(visible.find((day) => day.date === "2026-09-04")).toEqual(
|
||||
|
||||
@@ -20,7 +20,7 @@ export type CalendarDay = {
|
||||
state: "due" | "not-due" | "future";
|
||||
};
|
||||
|
||||
export const MONTH_VIEWS = [3, 4, 6, 12] as const;
|
||||
export const MONTH_VIEWS = [1, 3, 4, 6, 12] as const;
|
||||
export type MonthView = (typeof MONTH_VIEWS)[number];
|
||||
|
||||
/** Whole calendar months, including the month containing the anchor date. */
|
||||
|
||||
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>;
|
||||
};
|
||||
}
|
||||
@@ -117,6 +117,7 @@ export function Counter({
|
||||
>
|
||||
−
|
||||
</Button>
|
||||
<div className="ds-counter-value">
|
||||
<input
|
||||
className="ds-counter-input"
|
||||
type="text"
|
||||
@@ -133,7 +134,8 @@ export function Counter({
|
||||
if (event.key === "Escape") { event.preventDefault(); updateDraft(String(value)); }
|
||||
}}
|
||||
/>
|
||||
<span className="ds-muted"> / {target}</span>
|
||||
<span className="ds-counter-target ds-muted"> / {target}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label={`Increase ${label}`}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -11,16 +11,121 @@ responses are ephemeral. Buttons/forms carry opaque persisted owner-bound state,
|
||||
expire after 15 minutes, and mutation controls are consumed after successful use.
|
||||
Interaction IDs retain replay receipts for 24 hours. Neither table stores tokens.
|
||||
|
||||
`daily.ts` implements the initial `/today` workflow. `types.ts`, `ui.ts`, and
|
||||
`daily.ts` implements the `/today` workflow. `types.ts`, `ui.ts`, and
|
||||
`state.ts` provide the shared feature building blocks. Feature tasks extend command
|
||||
registration and route dispatch while keeping their behavior in separate modules.
|
||||
|
||||
Tests use real Ed25519 signatures and disposable migrated SQLite databases. The
|
||||
foundation tests cover server/DM privacy, replay after restart, stale progress,
|
||||
cross-account controls, and matching website state. Command registration and live
|
||||
Discord validation will be completed during integration.
|
||||
cross-account controls, and matching website state. All feature modules are now
|
||||
composed in the shared API. End-to-end user interaction in Discord remains a
|
||||
manual acceptance check after deployment and registration.
|
||||
|
||||
`/help` lists the current command definitions automatically, explains check-in
|
||||
controls and privacy, and links to the website. It works before account setup and
|
||||
does not create an account or synchronize habits. Re-run the registration script
|
||||
with `--apply` after deployment to expose newly added commands in Discord.
|
||||
Reminder delivery accepts `commandsAvailable`; production enables it when
|
||||
`DISCORD_PUBLIC_KEY` is a 64-digit hexadecimal key and `DISCORD_CLIENT_ID` is set.
|
||||
The existing bot token still controls delivery. Configured reminders include an
|
||||
owner-bound `today` control with `mode: 'reminder'`: it opens a separate private
|
||||
flow, leaving the reminder unchanged, and expires at the sending account's next
|
||||
local midnight (including DST). Delivery claims, nonce, opt-out, and retry rules
|
||||
are unchanged. The dashboard preview explicitly disables buttons because it has
|
||||
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
|
||||
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
|
||||
list; the shared contract permits empty lists only while disabled. Invalid forms
|
||||
provide a prefilled retry button. UTC accounts receive a timezone setup hint.
|
||||
|
||||
`ReminderService` is shared with the website routes. Every save advances its
|
||||
`updatedAt` monotonically; Discord controls compare the saved preference snapshot
|
||||
and the user's timezone/update version before editing. `HabitService.setTimezone`
|
||||
also advances the user timestamp monotonically, including repeated writes in the
|
||||
same millisecond. This rejects old Discord forms after edits from either interface,
|
||||
including change-and-revert sequences. Website PUT retains its existing request
|
||||
contract. Delivery receipts do not invalidate preference controls.
|
||||
|
||||
Integration: retain the `preferences` command dispatch and `preferencesAction`
|
||||
component dispatch in `routes.ts`, the two definitions in `commands.ts`, and the
|
||||
preference fields in `ControlData`. `CommandConfig.remindersAvailable` supplies
|
||||
availability; `createApi` derives it from the existing sharing bot token, matching
|
||||
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
|
||||
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
|
||||
`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.
|
||||
Run `/progress` again to inspect status. Public snapshot and live deliveries
|
||||
retain their existing persistent quotas, nonce and uncertain-delivery guards.
|
||||
|
||||
`progress-image.ts` renders the focused 30-day snapshot at 1200×860 with bundled
|
||||
Mina Sans / Instrument Serif, shared theme colors, and existing habit shades.
|
||||
The 30 cells retain fixed readable sizes; symbols are Canvas paths. Text summaries
|
||||
and buttons accompany the PNG. To produce review images without sending messages:
|
||||
`PROGRESS_IMAGE_REVIEW_DIR=/tmp/minabot-progress-review bun test src/discord/progress-image.test.ts`.
|
||||
|
||||
|
||||
## Integrated commands
|
||||
|
||||
All commands use global registration with server and bot-DM contexts. `/help`
|
||||
reads this same registration metadata, so its command list stays in sync.
|
||||
|
||||
| Command | Function |
|
||||
| --- | --- |
|
||||
| `/help` | Command guide and website link |
|
||||
| `/today` | Daily manual/count/task check-ins |
|
||||
| `/habits` | Create, edit, schedule, archive, restore, permanently delete |
|
||||
| `/tasks` | Add, rename, schedule, delete routine tasks |
|
||||
| `/history` | Inspect and correct dated progress, including archived habits |
|
||||
| `/reminders` | Named schedules, multiple times, quiet hours, enable/disable |
|
||||
| `/settings` | Timezone and reminder delivery status |
|
||||
| `/progress` | Private graphics, confirmed snapshot sharing, live-image controls |
|
||||
|
||||
After the deployment controller installs a release, register its definitions:
|
||||
|
||||
```sh
|
||||
cd /opt/minabot
|
||||
sudo docker compose exec -T app bun scripts/register-discord-commands.ts --apply
|
||||
```
|
||||
|
||||
Registration upserts individual global commands and preserves unrelated commands.
|
||||
The public key and interactions URL are one-time setup; adding commands requires
|
||||
rerunning registration after their handlers have deployed.
|
||||
|
||||
## Additional command graphics
|
||||
|
||||
`/today` includes **View day card**, summarizing all due habits in pages of six.
|
||||
The selected daily check-in and `/history` editor include **View check-in card**,
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
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`.
|
||||
|
||||
98
src/discord/command-graphics.test.ts
Normal file
98
src/discord/command-graphics.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { users, reminderSettings, progressEvents } from '../db/schema';
|
||||
import { publicUser } from '../auth';
|
||||
import { HabitService } from '../habits/service';
|
||||
import { ControlStore } from './state';
|
||||
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';
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
afterEach(() => f?.close());
|
||||
function setup() {
|
||||
const jobs: (() => Promise<void>)[] = [], outputs: DeferredMessage[] = [];
|
||||
f = discordFixture(() => ({ schedule: job => jobs.push(job), complete: async (_i, result) => { outputs.push(result); } }));
|
||||
return { jobs, outputs, drain: async () => { while (jobs.length) await jobs.shift()!(); return { data: outputs.at(-1)!.message }; } };
|
||||
}
|
||||
const click = (id: string, values?: string[]) => ({ type: 3, message: { id: '123' }, data: { custom_id: id, values } });
|
||||
function context(): Context {
|
||||
const now = Date.parse('2026-09-04T12:00:00Z'), user = publicUser(f.db.select().from(users).where(eq(users.id, 'alice')).get()!);
|
||||
const service = new HabitService(f.db, user, now); service.sync();
|
||||
return { db: f.db, service, now, controls: new ControlStore(f.db, user.id, now), interaction: f.payload({}), origin: f.origin, changed: () => {}, remindersAvailable: true };
|
||||
}
|
||||
for (const guild of [false, true]) test(`day graphics retain private context and refresh without progress writes (${guild ? 'server' : 'DM'})`, async () => {
|
||||
const e = setup();
|
||||
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'count', unit: 'pages', target: 10 }, 201);
|
||||
let t = await f.run({ data: { name: 'today' } }, guild);
|
||||
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');
|
||||
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');
|
||||
t = await f.run({ data: { name: 'today' } }, guild);
|
||||
const d = await f.run(click(control(t, 'Choose a habit'), [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(f.changed).toHaveLength(0);
|
||||
});
|
||||
test('graphics retain ownership, expiry, pagination and selected historical requirements', async () => {
|
||||
const e = setup(); const h = await f.json('/habits', 'POST', { name: 'Old target', method: 'count', target: 4 }, 201);
|
||||
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 3 });
|
||||
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 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');
|
||||
f.setDiscordTime('2026-09-06T12:00:00Z');
|
||||
expect((await f.run(click(id), true)).data.content).toContain('expired');
|
||||
});
|
||||
test('six-row cards paginate complete habit and reminder collections', async () => {
|
||||
const e = setup();
|
||||
for (let i = 0; i < 8; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201);
|
||||
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);
|
||||
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');
|
||||
});
|
||||
test('rendered cards include task progress, future/off states, weekly/date reminders and bounded image dimensions', async () => {
|
||||
setup();
|
||||
const h = await f.json('/habits', 'POST', { name: 'Evening routine', method: 'tasks', tasks: [{ name: 'Read a chapter' }, { name: 'Stretch' }, { name: 'Prepare for tomorrow' }] }, 201);
|
||||
const tasks = (await f.json(`/habits/${h.id}/tasks`)).tasks;
|
||||
await f.json(`/habits/${h.id}/days/2026-09-04/tasks/${tasks[0].id}`, 'PUT', { done: true });
|
||||
f.db.insert(reminderSettings).values({ userId: 'alice', enabled: true, quietStart: '22:00', quietEnd: '08:00', updatedAt: 1, reminders: [
|
||||
{ type: 'weekly', name: 'Morning reading', time: '09:00', days: [1,2,3,4,5] },
|
||||
{ type: 'weekly', name: 'Evening wind-down', time: '21:00', days: [0,1,2,3,4,5,6] },
|
||||
{ type: 'date', name: 'A little extra encouragement', time: '23:00', date: '2026-09-10' },
|
||||
] }).run();
|
||||
for (const [action, data] of [['graphic.today', {}], ['graphic.day', { habitId: h.id, date: '2026-09-04' }], ['graphic.reminders', {}]] as const) {
|
||||
const model = commandCard(context(), action, data);
|
||||
for (const theme of ['light','dark'] as const) {
|
||||
const png = await renderCommandCard(model, theme), b = Buffer.from(png);
|
||||
expect(b.readUInt32BE(16)).toBe(1200); expect(b.readUInt32BE(20)).toBe(commandCardSize(model).height); expect(b.length).toBeLessThan(4 * 1024 * 1024);
|
||||
if (process.env.COMMAND_IMAGE_REVIEW_DIR) await Bun.write(`${process.env.COMMAND_IMAGE_REVIEW_DIR}/${action}-${theme}.png`, png);
|
||||
}
|
||||
}
|
||||
const future = commandCard(context(), 'graphic.day', { habitId: h.id, date: '2026-09-05' });
|
||||
expect(future.summary).toContain('Future date'); expect(future.history!.at(-1)!.future).toBe(true);
|
||||
expect(commandCard(context(), 'graphic.day', { habitId: h.id, date: '1970-01-01' }).history).toHaveLength(1);
|
||||
const long = commandCard(context(), 'graphic.today', {}); long.title = '🌸Very long habit name '.repeat(50); long.rows[0]!.label = '漢字'.repeat(200);
|
||||
expect((await renderCommandCard(long)).length).toBeLessThan(4 * 1024 * 1024);
|
||||
});
|
||||
62
src/discord/command-graphics.ts
Normal file
62
src/discord/command-graphics.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { addDays } from '../habits/calendar';
|
||||
import { dateSchema } from '../habits/contracts';
|
||||
import { ApiError } from '../habits/service';
|
||||
import { ReminderService } from '../reminders/service';
|
||||
import { inQuietHours } from '../reminders/contracts';
|
||||
import type { Context, ControlData, Reply } from './types';
|
||||
import { button, message, row, text } from './ui';
|
||||
import { renderCommandCard, type CommandCard, type CardRow } from './command-image';
|
||||
|
||||
const size = 6;
|
||||
export function commandCard(c: Context, action: string, data: ControlData): CommandCard {
|
||||
const base = { owner: c.service.user.displayName, subtitle: c.service.user.timezone };
|
||||
let rows: CardRow[], title: string, summary: string, note: string, history: CommandCard['history'];
|
||||
if (action === 'graphic.today') {
|
||||
if (data.date && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today for a new card.');
|
||||
const days = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due);
|
||||
title = 'Today, at a glance'; base.subtitle = `${c.service.today} · ${base.subtitle}`;
|
||||
summary = `${days.filter(d => d.complete).length} of ${days.length} habits complete`;
|
||||
note = 'Snapshot · Use /today to update progress';
|
||||
rows = days.map(d => ({ label: d.name ?? 'Habit', detail: `${d.complete ? 'Complete' : d.value > 0 ? 'In progress' : 'Not started'}${d.carriedFrom ? ` · Carried from ${d.carriedFrom}` : ''}`, value: `${d.value}/${d.target} ${d.unit}`, ratio: d.ratio ?? 0, color: c.service.settings(d.habitId).mainColor }));
|
||||
} else if (action === 'graphic.day') {
|
||||
const date = dateSchema.parse(data.date ?? c.service.today);
|
||||
if (data.today && date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today for a new card.');
|
||||
const d = c.service.day(data.habitId!, date), color = c.service.settings(data.habitId!).mainColor;
|
||||
title = d.name ?? 'Not tracked yet'; base.subtitle = `${date} · ${d.timezone}`;
|
||||
summary = d.future ? 'Future date · read-only' : !d.requirements ? 'Not tracked on this date' : d.requirements.archived ? 'Archived on this date' : !d.due ? 'Day off · not scheduled' : `${d.value}/${d.target} ${d.unit} · ${d.complete ? 'Complete' : 'Incomplete'}`;
|
||||
note = 'Snapshot · Historical requirements for the selected date';
|
||||
rows = d.method === 'tasks' ? d.tasks.map(t => ({ label: t.name, detail: d.future ? 'Not yet due' : t.done ? 'Complete' : 'Incomplete', ratio: Number(t.done), color })) : [{ label: d.method === 'count' ? 'Recorded amount' : 'Check-in', detail: d.carriedFrom ? `Carried from ${d.carriedFrom}` : d.due ? 'Requirements saved for this date' : 'Read-only date', value: d.due && !d.future ? `${d.value}/${d.target ?? 0}` : '—', ...(!d.future && d.due ? { ratio: d.ratio ?? 0 } : {}), color }];
|
||||
history = Array.from({ length: 14 }, (_, i) => addDays(date, i - 13)).filter(day => day >= '1970-01-01').map(day => { const value = c.service.day(data.habitId!, day); return { date: day, ratio: value.ratio, future: value.future, color }; });
|
||||
} else if (action === 'graphic.reminders') {
|
||||
const prefs = new ReminderService(c.db, c.service.user.id, c.remindersAvailable ?? false, c.now).read();
|
||||
title = 'Your reminder schedule';
|
||||
summary = !prefs.available ? 'Delivery not configured' : !prefs.enabled ? 'Delivery disabled' : `${prefs.reminders.length} reminder time slots`;
|
||||
note = 'DMs send only while habits remain unfinished';
|
||||
base.subtitle = `${base.subtitle} · Quiet hours ${prefs.quietStart === prefs.quietEnd ? 'off' : `${prefs.quietStart}–${prefs.quietEnd}`}`;
|
||||
rows = [...prefs.reminders].sort((a, b) => a.time.localeCompare(b.time)).map(e => ({ label: `${e.time} · ${e.name || 'Unnamed reminder'}`, detail: `${e.type === 'date' ? e.date : 'Weekly · Mon to Sun'}${inQuietHours(e.time, prefs.quietStart, prefs.quietEnd) ? ' · During quiet hours' : ''}`, ...(e.type === 'weekly' ? { weekdays: e.days } : {}) }));
|
||||
} else throw new ApiError(404, 'Unknown graphic.');
|
||||
const pages = Math.max(1, Math.ceil(rows.length / size)), page = Math.max(0, Math.min(Math.trunc(data.page ?? 0), pages - 1));
|
||||
return { ...base, title, summary, note, rows: rows.slice(page * size, (page + 1) * size), page, pages, history };
|
||||
}
|
||||
export function commandGraphicAction(c: Context, action: string, data: ControlData): Reply | undefined {
|
||||
if (!action.startsWith('graphic.')) return;
|
||||
if (!c.defer) throw new ApiError(409, 'Run the command again to open a fresh card.');
|
||||
// Read one owned, dated snapshot before yielding; these cards never change progress.
|
||||
const model = commandCard(c, action, data), theme = data.theme ?? 'light';
|
||||
const content = [ `**${text(model.title)}** · ${text(model.subtitle)}`, model.summary,
|
||||
...model.rows.map(r => `${text(r.label)}${r.value ? ` · ${text(r.value)}` : ''} — ${text(r.detail)}${r.weekdays ? ` (${r.weekdays.map(d => ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d]).join(', ')})` : ''}`),
|
||||
...(model.history ? [`Recent days: ${model.history.map(d => `${d.date}: ${d.future ? 'future' : d.ratio === null ? 'off' : `${Math.round(d.ratio * 100)}%`}`).join('; ')}`] : []),
|
||||
`Page ${model.page + 1}/${model.pages} · ${model.note}` ].join('\n');
|
||||
const controls = [
|
||||
...(model.page > 0 ? [button(c, 'Previous card', action, { ...data, page: model.page - 1 })] : []),
|
||||
...(model.page + 1 < model.pages ? [button(c, 'Next card', action, { ...data, page: model.page + 1 })] : []),
|
||||
button(c, 'Refresh card', action, { ...data, page: model.page }),
|
||||
button(c, 'Toggle theme', action, { ...data, page: model.page, theme: theme === 'light' ? 'dark' : 'light' }),
|
||||
];
|
||||
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, 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 } : {} };
|
||||
}
|
||||
70
src/discord/command-image.ts
Normal file
70
src/discord/command-image.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { renderImage } from '../rendering/queue';
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import '../sharing/live-card-image';
|
||||
import { themePalette, type Theme } from '../lib/theme';
|
||||
import { habitProgressColor } from '../lib/habit-palette';
|
||||
|
||||
export type CardRow = { label: string; detail: string; value?: string; ratio?: number; color?: string; weekdays?: number[] };
|
||||
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 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];
|
||||
const rect = (x: number, y: number, w: number, h: number, color: string, r = 0) => { ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, w, h, r); ctx.fill(); };
|
||||
const text = (value: string, x: number, y: number, size: number, maxWidth: number, color: string = p.ink, serif = false) => {
|
||||
ctx.font = `${size}px "${serif ? 'Instrument Serif' : 'Mina Sans'}"`; ctx.fillStyle = color;
|
||||
const characters = Array.from(value.replace(/[\r\n\t]/g, ' ')); let fitted = characters.join('');
|
||||
while (characters.length && ctx.measureText(fitted).width > maxWidth) { characters.pop(); fitted = characters.join('') + '…'; }
|
||||
ctx.fillText(fitted, x, y);
|
||||
};
|
||||
rect(0, 0, width, height, p.paper); rect(0, 0, width, 8, '#285d49');
|
||||
text(data.owner, 64, 72, 26, 760); text('Mina Habits.', 938, 76, 38, 205, p.ink, true);
|
||||
text(data.title, 64, 150, 60, 1072, p.ink, true);
|
||||
text(data.subtitle, 64, 200, 25, 1072, p.muted);
|
||||
text(data.summary, 64, 270, 38, 1072);
|
||||
rect(64, 312, 1072, 1, p.rule);
|
||||
if (!data.rows.length) text('Nothing scheduled here.', 64, 392, 30, 1072, p.muted);
|
||||
data.rows.forEach((r, index) => {
|
||||
const y = 346 + index * 116, color = r.color ?? '#285d49';
|
||||
rect(64, y, 6, 88, color, 3);
|
||||
const valueWidth = r.weekdays ? 0 : 270;
|
||||
text(r.label, 92, y + 30, 30, 1016 - valueWidth);
|
||||
if (r.value) text(r.value, 860, y + 30, 25, 270);
|
||||
text(r.detail, 92, y + 68, 22, r.weekdays ? 580 : 1016, p.muted);
|
||||
if (r.weekdays) {
|
||||
['M', 'T', 'W', 'T', 'F', 'S', 'S'].forEach((label, i) => {
|
||||
const active = r.weekdays!.includes((i + 1) % 7), x = 732 + i * 58;
|
||||
rect(x, y + 45, 46, 35, active ? p.avatar : p.empty, 6);
|
||||
text(label, x + 12, y + 71, 22, 28, active ? p.ink : p.muted);
|
||||
if (active) rect(x + 12, y + 83, 22, 3, habitProgressColor(color, 1, theme), 1);
|
||||
});
|
||||
} else if (r.ratio !== undefined) {
|
||||
rect(92, y + 87, 1044, 6, p.empty, 3);
|
||||
if (r.ratio > 0) rect(92, y + 87, 1044 * Math.max(0, Math.min(1, r.ratio)), 6, habitProgressColor(color, 1, theme), 3);
|
||||
}
|
||||
});
|
||||
const bottom = 350 + Math.max(1, data.rows.length) * 116;
|
||||
if (data.history) {
|
||||
text(`Recent days · ${data.history[0]?.date ?? ''} to ${data.history.at(-1)?.date ?? ''}`, 64, bottom + 26, 23, 1072, p.muted);
|
||||
data.history.forEach((day, i) => {
|
||||
const x = 64 + i * 77;
|
||||
const fill = day.future || day.ratio === null ? p.avatar : !day.ratio ? p.empty : habitProgressColor(day.color, day.ratio, theme);
|
||||
rect(x, bottom + 47, 62, 42, fill, 6);
|
||||
// State symbols retain meaning without color; draw them without font dependence.
|
||||
ctx.strokeStyle = p.ink; ctx.lineWidth = 2;
|
||||
if (day.future || day.ratio === null) { ctx.beginPath(); ctx.moveTo(x + 24, bottom + 68); ctx.lineTo(x + 38, bottom + 68); ctx.stroke(); }
|
||||
text(day.date.slice(8), x + 15, bottom + 119, 20, 45, p.muted);
|
||||
if (i === data.history!.length - 1) rect(x, bottom + 127, 62, 3, p.ink, 1);
|
||||
});
|
||||
}
|
||||
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);
|
||||
try { return new Uint8Array(await canvas.encode('png')); }
|
||||
finally { canvas.width = 1; canvas.height = 1; }
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
// Global commands are required for bot DMs; guild-only registrations cannot run there.
|
||||
export const commands = [
|
||||
{ name: 'help', description: 'Show commands and how to use Minabot' },
|
||||
{ name: 'tasks', description: 'Add, rename, schedule and delete habit tasks' },
|
||||
{ name: 'habits', description: 'Create, edit, archive and restore your habits' },
|
||||
{ name: 'reminders', description: 'Manage named reminders, schedules, and quiet hours' },
|
||||
{ name: 'settings', description: 'Set your timezone and view delivery settings' },
|
||||
{ name: 'progress', description: 'Preview progress and manage public snapshot or live sharing' },
|
||||
{ name: 'today', description: 'View and complete today’s habits' },
|
||||
{ name: 'history', description: 'View and correct habit progress for a calendar date' },
|
||||
].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] }));
|
||||
|
||||
112
src/discord/daily.test.ts
Normal file
112
src/discord/daily.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { discordFixture, 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';
|
||||
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);
|
||||
}
|
||||
for (const guild of [false, true]) {
|
||||
test(`daily count carry, above-target, reversal and recoverable modal saves (${guild ? 'server' : 'DM'})`, async () => {
|
||||
f = discordFixture();
|
||||
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');
|
||||
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);
|
||||
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');
|
||||
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((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);
|
||||
});
|
||||
test(`manual and paginated task completion reverse and reflect website edits (${guild ? 'server' : 'DM'})`, async () => {
|
||||
f = discordFixture();
|
||||
const manual = await f.json('/habits', 'POST', { name: 'Walk', method: 'manual' }, 201);
|
||||
let d = await open(manual.id, guild);
|
||||
d = await f.run(click(control(d, 'Mark complete')), guild);
|
||||
expect((await f.json(`/habits/${manual.id}/days/2026-09-04`)).complete).toBe(true);
|
||||
d = await f.run(click(control(d, 'Mark incomplete')), guild);
|
||||
expect((await f.json(`/habits/${manual.id}/days/2026-09-04`)).complete).toBe(false);
|
||||
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');
|
||||
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);
|
||||
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);
|
||||
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');
|
||||
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((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');
|
||||
});
|
||||
}
|
||||
test('habit pagination clamps after website archives', async () => {
|
||||
f = discordFixture(); const habits = [];
|
||||
for (let n = 0; n < 11; n++) habits.push(await f.json('/habits', 'POST', { name: `Habit ${n}`, method: 'manual' }, 201));
|
||||
const t = await f.run({ data: { name: 'today' } });
|
||||
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');
|
||||
});
|
||||
test('daily controls reject midnight rollover even within their fifteen minute lifetime', async () => {
|
||||
f = discordFixture(); f.setDiscordTime('2026-09-04T21:59:00Z');
|
||||
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
|
||||
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');
|
||||
const fresh = await f.run(click(control(expired, 'Today')));
|
||||
expect(fresh.data.content).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 () => {
|
||||
f = discordFixture(); f.setDiscordTime('2026-09-04T18:00:00Z');
|
||||
await f.json('/habits', 'POST', { name: 'Walk', method: 'manual' }, 201);
|
||||
f.db.insert(reminderSettings).values({ userId: 'alice', ...defaultReminder, enabled: true, updatedAt: 0 }).run();
|
||||
const sent: any[] = []; const now = Date.parse('2026-09-04T18:00:00Z');
|
||||
const send = () => sendDueReminders(f.db, { token: 'fake', origin: f.origin, commandsAvailable: true }, async (url, init) => {
|
||||
if (url.endsWith('/users/@me/channels')) return Response.json({ id: '223456789012345678' });
|
||||
sent.push(JSON.parse(String(init?.body))); return Response.json({ id: '323456789012345678' });
|
||||
}, () => now);
|
||||
await Promise.all([send(), send()]); await send(); expect(sent).toHaveLength(1);
|
||||
const id = control({ data: sent[0] }, 'Check in');
|
||||
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');
|
||||
}
|
||||
f.setDiscordTime('2026-09-04T22:00:00Z'); expect((await f.run(click(id))).data.content).toContain('expired');
|
||||
});
|
||||
test('reminder expiry follows DST boundary', () => {
|
||||
f = discordFixture();
|
||||
const now = Date.parse('2026-10-25T00:30:00Z');
|
||||
const components = reminderCheckIn(f.db, 'alice', 'Europe/Belgrade', now);
|
||||
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'));
|
||||
});
|
||||
@@ -1,42 +1,54 @@
|
||||
import { ZodError } from 'zod';
|
||||
import { ApiError } from '../habits/service';
|
||||
import { endOfDay, localDate } from '../habits/calendar';
|
||||
import { ControlStore } from './state';
|
||||
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';
|
||||
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) })];
|
||||
}
|
||||
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 = data.page ?? 0, visible = all.slice(page * 10, page * 10 + 10);
|
||||
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', data, all.length), row(button(c, 'Refresh', 'today'))], c.interaction.data?.custom_id?.startsWith('mina:') && data.mode !== 'reminder');
|
||||
[...(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');
|
||||
}
|
||||
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.');
|
||||
const d = c.service.day(data.habitId!, date);
|
||||
const state: ControlData = { ...data, date, expected: JSON.stringify([d.value, d.revisionId, d.tasks.map(t => [t.taskId, t.done])]) };
|
||||
data = { ...data, date, page: pageFor(data.page, d.tasks.length) };
|
||||
const state: ControlData = { ...data, expected: snapshot(d) };
|
||||
const controls = [];
|
||||
if (d.due && !d.future) {
|
||||
if (d.method === 'manual') controls.push(row(button(c, d.complete ? 'Mark incomplete' : 'Mark complete', 'daily.save', { ...state, done: !d.complete }, 1)));
|
||||
if (d.method === 'count') controls.push(row(button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1), button(c, 'Enter amount', 'daily.amount', state)));
|
||||
if (d.method === 'count') controls.push(row(...(d.value < 1_000_000_000 ? [button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1)] : []), ...(d.value > 0 ? [button(c, '−1', 'daily.save', { ...state, value: d.value - 1 })] : []), button(c, 'Enter amount', 'daily.amount', state)));
|
||||
if (d.method === 'tasks') {
|
||||
const visible = d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10);
|
||||
controls.push(select(c, 'Choose a task to change its completion', 'daily.task', state, visible.map(t => ({ id: t.taskId, name: `${t.done ? '✓ ' : '○ '}${t.name}` }))));
|
||||
controls.push(...pages(c, 'daily.detail', data, d.tasks.length));
|
||||
}
|
||||
}
|
||||
controls.push(row(button(c, 'Refresh', 'daily.detail', data), button(c, 'Today', 'today')));
|
||||
return message(c, `**${text(d.name ?? 'Not tracked')} · ${date}**\n${d.due ? `${d.value}/${d.target} ${text(d.unit ?? '')} · ${d.complete ? 'Complete' : 'Incomplete'}` : 'Not scheduled'}${d.future ? ' · Future dates are read-only' : ''}${d.method === 'tasks' ? '\n\n' + d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10).map(t => `${t.done ? '✓' : '○'} ${text(t.name)}`).join('\n') : ''}`, controls);
|
||||
controls.push(row(button(c, 'Refresh', 'daily.detail', data), button(c, 'Today', 'today'), button(c, 'View check-in card', 'graphic.day', { ...data, page: 0 })));
|
||||
return message(c, `**${text(d.name ?? 'Not tracked')} · ${date}**\n${d.due ? `${d.value}/${d.target} ${text(d.unit ?? '')} · ${d.complete ? 'Complete' : 'Incomplete'}` : 'Not scheduled'}${d.carriedFrom ? ` · Carried from ${d.carriedFrom}` : ''}${d.future ? ' · Future dates are read-only' : ''}${d.method === 'tasks' ? '\n\n' + d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10).map(t => `${t.done ? '✓' : '○'} ${text(t.name)}`).join('\n') : ''}`, controls);
|
||||
}
|
||||
export function dailyAction(c: Context, action: string, data: ControlData): Reply | undefined {
|
||||
function action(c: Context, action: string, data: ControlData): Reply | undefined {
|
||||
if (action === 'today') return today(c, data);
|
||||
if (action === 'daily.select') return detail(c, { ...data, habitId: c.interaction.data?.values?.[0] });
|
||||
if (action === 'daily.select') return detail(c, { ...data, page: 0, mode: undefined, habitId: c.interaction.data?.values?.[0] });
|
||||
if (action === 'daily.detail') return detail(c, data);
|
||||
if (action === 'daily.amount' && data.today && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today again.');
|
||||
if (action === 'daily.amount') return modal(c, 'Record amount', 'daily.save', data, [{ id: 'amount', label: 'Total for this date', value: String(c.service.day(data.habitId!, data.date!).value), max: 10 }]);
|
||||
if (action === 'daily.save' || action === 'daily.task') {
|
||||
if (data.today && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today again.');
|
||||
const d = c.service.day(data.habitId!, data.date!);
|
||||
if (data.expected !== JSON.stringify([d.value, d.revisionId, d.tasks.map(t => [t.taskId, t.done])])) throw new ApiError(409, 'Progress changed since this message was opened. Refresh it before saving.');
|
||||
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]);
|
||||
if (!task) throw new ApiError(404, 'Task not found.');
|
||||
@@ -48,3 +60,14 @@ export function dailyAction(c: Context, action: string, data: ControlData): Repl
|
||||
c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return detail(c, data);
|
||||
}
|
||||
}
|
||||
|
||||
export function dailyAction(c: Context, name: string, data: ControlData): Reply | undefined {
|
||||
try { return action(c, name, data); }
|
||||
catch (error) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
199
src/discord/history.test.ts
Normal file
199
src/discord/history.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { control, discordFixture, submit } 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 ?? []);
|
||||
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]);
|
||||
expect(form.type).toBe(9);
|
||||
return f.run(submit(form.data.custom_id, { date }), guild);
|
||||
}
|
||||
async function changeDate(reply: any, date: string, guild = false) {
|
||||
const form = await click(reply, 'Change date', guild);
|
||||
return f.run(submit(form.data.custom_id, { date }), guild);
|
||||
}
|
||||
function readOnly(reply: any) {
|
||||
expect(reply.data.content).toContain('Read-only');
|
||||
expect(items(reply).some(i => ['+1', 'Enter amount', 'Mark complete', 'Mark incomplete'].includes(i.label) || i.type === 3)).toBe(false);
|
||||
}
|
||||
|
||||
describe('/history', () => {
|
||||
test('is global and available in both interaction contexts', () => {
|
||||
expect(commands.find(c => c.name === 'history')).toMatchObject({ type: 1, contexts: [0, 1], integration_types: [0] });
|
||||
});
|
||||
|
||||
for (const guild of [false, true]) test(`corrects archived count/manual/task history from ${guild ? 'server' : 'DM'} using dated website requirements`, async () => {
|
||||
f = discordFixture();
|
||||
const h = await f.json('/habits', 'POST', { name: 'Practice', method: 'count', target: 5, unit: 'pages' }, 201);
|
||||
f.setDiscordTime('2026-09-05T12:00:00Z');
|
||||
await f.json(`/habits/${h.id}`, 'PATCH', { target: 20, unit: 'chapters' });
|
||||
f.setDiscordTime('2026-09-06T12:00:00Z');
|
||||
await f.json(`/habits/${h.id}`, 'PATCH', { method: 'manual' });
|
||||
f.setDiscordTime('2026-09-07T12:00:00Z');
|
||||
await f.json(`/habits/${h.id}`, 'PATCH', { method: 'tasks' });
|
||||
const task = await f.json(`/habits/${h.id}/tasks`, 'POST', { name: 'Original task' }, 201);
|
||||
f.setDiscordTime('2026-09-08T12:00:00Z');
|
||||
await f.json(`/habits/${h.id}/tasks/${task.id}`, 'PATCH', { name: 'New task', schedule: { type: 'weekdays', days: [1] } });
|
||||
f.setDiscordTime('2026-09-09T12:00:00Z');
|
||||
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');
|
||||
let d = await open(h.id, '2026-09-04', guild);
|
||||
expect(d.data.content).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(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');
|
||||
d = await click(d, 'Next date', guild);
|
||||
expect(d.data.content).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((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(f.changed).toEqual(['alice', 'alice', 'alice', 'alice']);
|
||||
expect((await f.json(`/habits/${h.id}`)).archived).toBe(true);
|
||||
});
|
||||
|
||||
test('validates actual calendar dates and navigates read-only days and date boundaries', async () => {
|
||||
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');
|
||||
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');
|
||||
}
|
||||
d = await changeDate(d, '2024-02-29');
|
||||
readOnly(d);
|
||||
expect(d.data.content).toContain('not tracked yet');
|
||||
d = await changeDate(d, '2026-09-05');
|
||||
expect(d.data.content).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');
|
||||
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');
|
||||
expect(items(d).some(i => i.label === 'Next date')).toBe(false);
|
||||
expect(f.changed).toEqual([]);
|
||||
});
|
||||
|
||||
test('paginates habits and dated tasks, preserving history through task pagination and refresh', async () => {
|
||||
f = discordFixture();
|
||||
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);
|
||||
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' }));
|
||||
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');
|
||||
d = await click(d, 'Refresh');
|
||||
expect(d.data.content).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);
|
||||
});
|
||||
|
||||
for (const guild of [false, true]) test(`rejects cross-account controls and forged selections in ${guild ? 'server' : 'DM'}`, async () => {
|
||||
f = discordFixture();
|
||||
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');
|
||||
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');
|
||||
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((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
|
||||
expect(f.changed).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps stale controls, mutation consumption, replay receipts and website edits consistent', async () => {
|
||||
f = discordFixture();
|
||||
const h = await f.json('/habits', 'POST', { name: 'Count', method: 'count', target: 10 }, 201);
|
||||
f.setDiscordTime('2026-09-05T12:00:00Z');
|
||||
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');
|
||||
d = await click(d, 'Refresh');
|
||||
expect(d.data.content).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((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');
|
||||
});
|
||||
|
||||
test('uses account-local dates at midnight and retains historical record timezone', async () => {
|
||||
f = discordFixture();
|
||||
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]);
|
||||
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((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([]);
|
||||
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');
|
||||
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');
|
||||
d = await changeDate(d, '2026-09-07');
|
||||
expect(d.data.content).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);
|
||||
});
|
||||
});
|
||||
64
src/discord/history.ts
Normal file
64
src/discord/history.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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';
|
||||
|
||||
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
function scheduleLabel(schedule: Schedule): string {
|
||||
if (schedule.type === 'daily') return 'Every day';
|
||||
if (schedule.type === 'weekdays') return schedule.days.map(day => weekdays[day]).join(', ');
|
||||
if (schedule.type === 'interval') return `Every ${schedule.every} days from ${schedule.anchor}`;
|
||||
return `Every ${schedule.every} weeks on ${weekdays[schedule.weekday]} from ${schedule.anchor}`;
|
||||
}
|
||||
|
||||
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)]);
|
||||
}
|
||||
|
||||
function dateForm(c: Context, data: ControlData): Reply {
|
||||
c.service.owned(data.habitId!);
|
||||
return modal(c, 'Choose history date', 'history.date', data, [{ id: 'date', label: 'Calendar date (YYYY-MM-DD)', value: data.date ?? c.service.today, max: 10 }]);
|
||||
}
|
||||
|
||||
// Keep the dated editor and all mutation checks in daily.ts. Its opaque payloads
|
||||
// preserve mode/today, so subsequent saves, forms and task pages return here.
|
||||
function withNavigation(c: Context, data: ControlData, reply: Reply): Reply {
|
||||
if (![4, 7].includes(reply.type)) return reply;
|
||||
const d = c.service.day(data.habitId!, data.date!);
|
||||
const state: ControlData = { habitId: data.habitId, date: data.date, mode: 'history', today: false, page: 0 };
|
||||
const navigation = [
|
||||
...(data.date! > '1970-01-01' ? [button(c, 'Previous date', 'history.detail', { ...state, date: addDays(data.date!, -1) })] : []),
|
||||
...(data.date! < '9998-12-31' ? [button(c, 'Next date', 'history.detail', { ...state, date: addDays(data.date!, 1) })] : []),
|
||||
button(c, 'Change date', 'history.form', state),
|
||||
button(c, 'Choose habit', 'history', { date: data.date }),
|
||||
];
|
||||
const explanation = !d.requirements ? 'Read-only · This habit was not tracked yet.'
|
||||
: d.requirements.archived ? 'Read-only · This habit was archived on this date.'
|
||||
: !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)] } };
|
||||
}
|
||||
|
||||
export function historyAction(c: Context, action: string, data: ControlData): Reply | undefined {
|
||||
if (action === 'history') return history(c, data);
|
||||
if (action === 'history.select') return dateForm(c, { ...data, habitId: c.interaction.data?.values?.[0], page: 0 });
|
||||
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))]);
|
||||
data = { ...data, date: parsed.data, page: 0 };
|
||||
action = 'history.detail';
|
||||
}
|
||||
if (action === 'history.detail' || (data.mode === 'history' && action.startsWith('daily.'))) {
|
||||
const state = { ...data, date: dateSchema.parse(data.date), mode: 'history', today: false };
|
||||
const reply = action === 'history.detail' ? detail(c, state) : dailyAction(c, action, state);
|
||||
return reply && withNavigation(c, state, reply);
|
||||
}
|
||||
}
|
||||
123
src/discord/management-tasks.test.ts
Normal file
123
src/discord/management-tasks.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { discordFixture, control, submit } 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 list(id: string, guild = false) {
|
||||
const home = await f.run({ data: { name: 'tasks' } }, guild);
|
||||
expect(home.data.flags).toBe(guild ? 64 : undefined);
|
||||
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]); }
|
||||
async function setup() { return f.json('/habits', 'POST', { name: 'Morning', method: 'tasks' }, 201); }
|
||||
for (const guild of [false, true]) test(`task add, rename, schedule and delete match website state in ${guild ? 'server' : 'DM'}`, async () => {
|
||||
f = discordFixture(); const habit = await setup();
|
||||
const add = await click(await list(habit.id, guild), 'Add task', guild);
|
||||
let detail = await f.run(submit(add.data.custom_id, { name: 'Stretch' }), guild);
|
||||
let tasks = (await f.json(`/habits/${habit.id}/tasks`)).tasks;
|
||||
expect(tasks).toHaveLength(1); expect(tasks[0].name).toBe('Stretch'); expect(tasks[0].schedule).toEqual({ type: 'daily' });
|
||||
const taskId = tasks[0].id;
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${taskId}`, 'PUT', { done: true });
|
||||
const rename = await click(await task(habit.id, taskId, guild), 'Rename task', guild);
|
||||
detail = await f.run(submit(rename.data.custom_id, { name: 'Mobility' }), guild);
|
||||
tasks = (await f.json(`/habits/${habit.id}/tasks`)).tasks;
|
||||
expect(tasks[0].name).toBe('Mobility'); expect(tasks[0].id).toBe(taskId);
|
||||
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).tasks[0].done).toBe(true);
|
||||
const menu = await click(detail, 'Change task schedule', guild);
|
||||
const form = await click(menu, 'Recurrence', guild, ['weekly']);
|
||||
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((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);
|
||||
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).due).toBe(false);
|
||||
expect(f.changed).toEqual(['alice', 'alice', 'alice', 'alice']);
|
||||
});
|
||||
test('task scheduling supports daily, selected weekdays, day intervals and overlap warnings', async () => {
|
||||
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Monday', method: 'tasks', schedule: { type: 'weekdays', days: [1] }, tasks: [{ name: 'Plan' }] }, 201);
|
||||
const id = h.tasks[0].id;
|
||||
let detail = await task(h.id, id);
|
||||
let form = await click(await click(detail, 'Change task schedule'), 'Recurrence', false, ['interval']);
|
||||
detail = await f.run(submit(form.data.custom_id, { every: '3', anchor: '2026-09-01' }));
|
||||
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');
|
||||
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((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 () => {
|
||||
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: [{ name: 'Old task' }] }, 201);
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
const aliceMenu = await f.run({ data: { name: 'tasks' } });
|
||||
expect((await click(aliceMenu, 'Choose a task habit', false, [bob.id])).data.content).toContain('available options');
|
||||
});
|
||||
test('add forms are replay safe after restart and task pagination stays within limits', async () => {
|
||||
f = discordFixture(); const h = await setup();
|
||||
const add = await click(await list(h.id), 'Add task');
|
||||
const payload = f.payload(submit(add.data.custom_id, { name: 'Once' }));
|
||||
await f.requestDiscord(payload); f.restart(); await f.requestDiscord(payload);
|
||||
expect((await f.json(`/habits/${h.id}/tasks`)).tasks).toHaveLength(1);
|
||||
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);
|
||||
const next = await click(first, 'Next');
|
||||
expect(next.data.components[0].components[0].options).toHaveLength(2);
|
||||
expect(control(next, 'Previous')).toBeTruthy();
|
||||
});
|
||||
test('midnight, color-only website edits, and count progress invalidate management snapshots', async () => {
|
||||
f = discordFixture(); f.setDiscordTime('2026-09-04T21:59:00Z');
|
||||
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');
|
||||
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');
|
||||
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 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');
|
||||
});
|
||||
test('a complete checklist routine can be created entirely in Discord', async () => {
|
||||
f = discordFixture();
|
||||
const empty = await f.run({ data: { name: 'tasks' } }, true);
|
||||
const methods = await click(empty, 'Create habit', true);
|
||||
const form = await click(methods, 'Tracking method', true, ['tasks']);
|
||||
const habit = await f.run(submit(form.data.custom_id, { name: 'Evening', color: '#abcdef' }), true);
|
||||
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');
|
||||
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`);
|
||||
expect(settings.mainColor).toBe('#abcdef');
|
||||
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');
|
||||
});
|
||||
98
src/discord/management.test.ts
Normal file
98
src/discord/management.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { discordFixture, control, submit } 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 create(method: string, guild = false) {
|
||||
const list = await f.run({ data: { name: 'habits' } }, guild);
|
||||
expect(list.data.flags).toBe(guild ? 64 : undefined);
|
||||
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');
|
||||
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 () => {
|
||||
f = discordFixture(); const { result, habit } = await create('count', guild);
|
||||
expect(habit.method).toBe('count'); expect(habit.target).toBe(10);
|
||||
let detail = await click(result, 'Enable carryover', guild);
|
||||
expect((await f.json('/habits')).habits[0].carryPartialProgress).toBe(true);
|
||||
const edit = await click(detail, 'Edit details', guild);
|
||||
detail = await f.run(submit(edit.data.custom_id, { name: 'Books', target: '20', unit: 'pages', color: '#654321' }), guild);
|
||||
expect((await f.json('/habits')).habits[0].name).toBe('Books');
|
||||
const confirmation = await click(detail, 'Archive habit', guild);
|
||||
detail = await click(confirmation, 'Confirm', guild);
|
||||
expect((await f.json('/habits')).habits).toEqual([]);
|
||||
detail = await click(await click(detail, 'Restore habit', guild), 'Confirm', guild);
|
||||
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();
|
||||
detail = await click(await click(detail, 'Permanently delete', guild), 'Confirm', guild);
|
||||
expect(detail.data.content).toContain('No habits');
|
||||
expect((await f.request(`/habits/${habit.id}/days/2026-09-04`)).status).toBe(404);
|
||||
expect(f.changed.length).toBe(7);
|
||||
});
|
||||
test('all recurrences use validated friendly controls', async () => {
|
||||
f = discordFixture(); const { habit } = await create('manual');
|
||||
for (const [kind, values, expected] of [
|
||||
['interval', { every: '3', anchor: '2026-09-01' }, { type: 'interval', every: 3, anchor: '2026-09-01' }],
|
||||
['weekly', { every: '2', anchor: '2026-09-01', weekday: 'Friday' }, { type: 'weekly', every: 2, anchor: '2026-09-01', weekday: 5 }],
|
||||
] as const) {
|
||||
const menu = await click(await open(habit.id), 'Change schedule');
|
||||
const form = await click(menu, 'Recurrence', false, [kind]);
|
||||
await f.run(submit(form.data.custom_id, { ...values }));
|
||||
expect((await f.json('/habits')).habits[0].schedule).toEqual(expected);
|
||||
}
|
||||
let menu = await click(await click(await open(habit.id), 'Change schedule'), 'Recurrence', false, ['weekdays']);
|
||||
await click(menu, 'Select weekdays', false, ['1', '3', '5']);
|
||||
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'weekdays', days: [1, 3, 5] });
|
||||
menu = await click(await open(habit.id), 'Change schedule');
|
||||
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((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'daily' });
|
||||
});
|
||||
test('method conversion warns, resets current progress and preserves past dated requirements', async () => {
|
||||
f = discordFixture(); const { habit } = await create('manual');
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
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');
|
||||
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`);
|
||||
expect(past.method).toBe('manual'); expect(past.value).toBe(1);
|
||||
const today = await f.json(`/habits/${habit.id}/days/2026-09-05`);
|
||||
expect(today.method).toBe('count'); expect(today.value).toBe(0);
|
||||
const next = await click(await click(await open(habit.id), 'Change method'), 'New tracking method', false, ['tasks']);
|
||||
await click(next, 'Continue conversion');
|
||||
expect((await f.json('/habits')).habits[0].tasks).toEqual([]);
|
||||
});
|
||||
test('stale, replayed, and cross-account management controls cannot write', async () => {
|
||||
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');
|
||||
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((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);
|
||||
const next = await click(page, 'Next');
|
||||
expect(next.data.components[0].components[0].options.length).toBe(2);
|
||||
expect(control(next, 'Previous')).toBeTruthy();
|
||||
});
|
||||
187
src/discord/management.ts
Normal file
187
src/discord/management.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
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';
|
||||
|
||||
const prefix = 'manage:';
|
||||
const methods = [{ id: 'manual', name: 'Manual check-in' }, { id: 'count', name: 'Count toward a target' }, { id: 'tasks', name: 'Checklist of tasks' }];
|
||||
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const recurrence = [{ id: 'daily', name: 'Every day' }, { id: 'weekdays', name: 'Selected weekdays' }, { id: 'interval', name: 'Every N days' }, { id: 'weekly', name: 'Every N weeks' }];
|
||||
function stamp(c: Context, id: string) {
|
||||
const h = c.service.current(id), day = c.service.day(id, c.service.today);
|
||||
return JSON.stringify([h.revisionId, c.service.settings(id), c.service.today, c.service.user.timezone, day.value, day.tasks.map(t => [t.taskId, t.done])]);
|
||||
}
|
||||
function snapshot(c: Context, id: string): ControlData { return { habitId: id, expected: stamp(c, id) }; }
|
||||
function check(c: Context, d: ControlData) {
|
||||
if (!d.habitId || d.expected !== stamp(c, d.habitId)) throw new ApiError(409, 'This habit changed. Run /habits or /tasks again before saving.');
|
||||
return c.service.current(d.habitId);
|
||||
}
|
||||
function consume(c: Context) { c.controls.consume(c.interaction.data!.custom_id!); c.changed(); }
|
||||
function chosen(c: Context, allowed: string[]) {
|
||||
const value = c.interaction.data?.values?.[0];
|
||||
if (!value || !allowed.includes(value)) throw new ApiError(422, 'Choose one of the available options.');
|
||||
return value;
|
||||
}
|
||||
export function scheduleLabel(s: Schedule): string {
|
||||
if (s.type === 'daily') return 'Every day';
|
||||
if (s.type === 'weekdays') return s.days.map(d => weekdays[d]).join(', ');
|
||||
if (s.type === 'interval') return `Every ${s.every} days from ${s.anchor}`;
|
||||
return `Every ${s.every} weeks on ${weekdays[s.weekday]} from ${s.anchor}`;
|
||||
}
|
||||
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 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),
|
||||
]);
|
||||
}
|
||||
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.`, [
|
||||
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')),
|
||||
]);
|
||||
}
|
||||
function detailsForm(c: Context, d: ControlData, creating = false): Reply {
|
||||
const h = creating ? undefined : check(c, d), method = d.method ?? h!.method;
|
||||
return modal(c, creating ? 'Create habit' : 'Edit habit', prefix + (creating ? 'save-create' : 'save-details'), { ...d, method }, [
|
||||
{ id: 'name', label: 'Habit name', value: h?.name },
|
||||
...(method === 'count' ? [{ id: 'target', label: 'Target (1–10000)', value: h?.method === 'count' ? String(h.target) : '1' }, { id: 'unit', label: 'Unit (pages, steps, minutes…)', max: 80, value: h?.method === 'count' ? h.unit : 'times' }] : []),
|
||||
{ id: 'color', label: 'Color (#RRGGBB)', value: h ? c.service.settings(h.id).mainColor : '#196127', max: 7 },
|
||||
]);
|
||||
}
|
||||
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))]);
|
||||
}
|
||||
function saveSchedule(c: Context, d: ControlData, schedule: Schedule): Reply {
|
||||
check(c, d);
|
||||
if (d.taskId) c.service.changeTask(d.habitId!, d.taskId, taskPatch.parse({ schedule }));
|
||||
else c.service.edit(d.habitId!, habitPatch.parse({ schedule }));
|
||||
consume(c); return d.taskId ? taskDetail(c, d) : detail(c, d.habitId!);
|
||||
}
|
||||
export function managementAction(c: Context, action: string, d: ControlData): Reply | undefined {
|
||||
if (!action.startsWith(prefix)) return;
|
||||
const a = action.slice(prefix.length);
|
||||
const taskReply = taskAction(c, a, d);
|
||||
if (taskReply) return taskReply;
|
||||
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-method') return detailsForm(c, { method: chosen(c, methods.map(m => m.id)) }, true);
|
||||
if (a === 'save-create') {
|
||||
const f = fields(c.interaction);
|
||||
const h = c.service.create(habitInput.parse({ name: f.name, color: f.color, method: d.method, ...(d.method === 'count' ? { target: Number(f.target), unit: f.unit } : {}) }));
|
||||
consume(c); return detail(c, h.id);
|
||||
}
|
||||
if (a === 'edit') return detailsForm(c, d);
|
||||
if (a === 'save-details') {
|
||||
check(c, d); const f = fields(c.interaction);
|
||||
c.service.edit(d.habitId!, habitPatch.parse({ name: f.name, color: f.color, method: d.method, ...(d.method === 'count' ? { target: Number(f.target), unit: f.unit } : {}) }));
|
||||
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-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))]);
|
||||
}
|
||||
if (a === 'convert') {
|
||||
check(c, d);
|
||||
if (d.method === 'count') return detailsForm(c, d);
|
||||
c.service.edit(d.habitId!, habitPatch.parse({ method: d.method })); consume(c); return detail(c, d.habitId!);
|
||||
}
|
||||
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))]);
|
||||
}
|
||||
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);
|
||||
if (a === 'schedule-kind') {
|
||||
const h = check(c, d), kind = chosen(c, recurrence.map(r => r.id));
|
||||
if (kind === 'daily') return saveSchedule(c, d, { type: 'daily' });
|
||||
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]);
|
||||
}
|
||||
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.');
|
||||
return modal(c, kind === 'weekly' ? 'Repeat every N weeks' : 'Repeat every N days', prefix + 'save-interval', { ...d, mode: kind }, [
|
||||
{ id: 'every', label: kind === 'weekly' ? 'Number of weeks (1–520)' : 'Number of days (1–3650)', value: 'every' in s ? String(s.every) : '1' },
|
||||
{ id: 'anchor', label: 'Anchor date (YYYY-MM-DD)', value: 'anchor' in s ? s.anchor : c.service.today, max: 10 },
|
||||
...(kind === 'weekly' ? [{ id: 'weekday', label: 'Weekday (Monday, Tuesday…)', value: s.type === 'weekly' ? weekdays[s.weekday] : 'Monday' }] : []),
|
||||
]);
|
||||
}
|
||||
if (a === 'save-weekdays') return saveSchedule(c, d, scheduleSchema.parse({ type: 'weekdays', days: (c.interaction.data?.values ?? []).map(Number) }));
|
||||
if (a === 'save-interval') {
|
||||
const f = fields(c.interaction), weekday = weekdays.findIndex(day => day.toLowerCase() === f.weekday?.trim().toLowerCase());
|
||||
return saveSchedule(c, d, scheduleSchema.parse({ type: d.mode, every: Number(f.every), anchor: f.anchor, ...(d.mode === 'weekly' ? { weekday } : {}) }));
|
||||
}
|
||||
throw new ApiError(422, 'Unknown management action. Run /habits again.');
|
||||
}
|
||||
|
||||
|
||||
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.'}`, [
|
||||
...(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),
|
||||
]);
|
||||
}
|
||||
function taskList(c: Context, d: ControlData): Reply {
|
||||
const h = c.service.current(d.habitId!);
|
||||
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.'}`, [
|
||||
...(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),
|
||||
]);
|
||||
}
|
||||
function taskDetail(c: Context, d: ControlData): Reply {
|
||||
const h = c.service.current(d.habitId!), task = c.service.tasks(h.id).find(t => t.id === d.taskId);
|
||||
if (!task) throw new ApiError(404, 'Task not found. Run /tasks again.');
|
||||
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.'}`, [
|
||||
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)),
|
||||
]);
|
||||
}
|
||||
function taskAction(c: Context, a: string, d: ControlData): Reply | undefined {
|
||||
if (a === 'tasks-home') return manageTasks(c, d);
|
||||
if (a === 'tasks-open') return taskList(c, { habitId: chosen(c, c.service.list().filter(h => h.method === 'tasks').map(h => h.id)) });
|
||||
if (a === 'tasks-list') return taskList(c, d);
|
||||
if (a === 'task-open') { check(c, d); return taskDetail(c, { ...d, taskId: chosen(c, c.service.tasks(d.habitId!).map(t => t.id)) }); }
|
||||
if (a === 'task-detail') return taskDetail(c, d);
|
||||
if (a === 'task-add' || a === 'task-rename') {
|
||||
check(c, d);
|
||||
const task = a === 'task-rename' ? c.service.tasks(d.habitId!).find(t => t.id === d.taskId) : undefined;
|
||||
if (a === 'task-rename' && !task) throw new ApiError(404, 'Task not found.');
|
||||
return modal(c, task ? 'Rename task' : 'Add task (daily recurrence)', prefix + 'task-save', { ...d, taskId: task?.id }, [{ id: 'name', label: 'Task name', value: task?.name }]);
|
||||
}
|
||||
if (a === 'task-save') {
|
||||
check(c, d);
|
||||
const input = { name: fields(c.interaction).name };
|
||||
const task = c.service.changeTask(d.habitId!, d.taskId, d.taskId ? taskPatch.parse(input) : taskInput.parse(input))!;
|
||||
consume(c); return taskDetail(c, { ...d, taskId: task.id });
|
||||
}
|
||||
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))]);
|
||||
}
|
||||
if (a === 'task-delete-confirm') { check(c, d); c.service.changeTask(d.habitId!, d.taskId, null); consume(c); return taskList(c, { habitId: d.habitId }); }
|
||||
}
|
||||
134
src/discord/preferences.test.ts
Normal file
134
src/discord/preferences.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
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 { 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);
|
||||
}
|
||||
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]);
|
||||
});
|
||||
for (const guild of [false, true]) test(`full named reminder management shares website state in ${guild ? 'server' : 'DM'}`, async () => {
|
||||
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);
|
||||
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();
|
||||
f.restart(); await f.requestDiscord(interaction);
|
||||
let web = await f.json('/reminders');
|
||||
expect(web.reminders).toEqual(['09:00', '18:30'].map(time => ({ type: 'weekly', name: draft.name, time, days: [1, 3, 5] })));
|
||||
expect(f.changed).toEqual(['alice']);
|
||||
list = await f.run(click(list, 'Enable delivery'), guild);
|
||||
expect((await f.json('/reminders')).enabled).toBe(true);
|
||||
let detail = await choose(list, guild);
|
||||
const edit = await f.run(click(detail, 'Edit reminder'), guild);
|
||||
list = await f.run(submit(edit.data.custom_id, { name: 'Birthday', times: '12:00, 14:00', days: '', date: '2026-09-30' }), guild);
|
||||
web = await f.json('/reminders');
|
||||
expect(web.reminders).toEqual(['12:00', '14:00'].map(time => ({ type: 'date', name: 'Birthday', time, date: '2026-09-30' })));
|
||||
const quiet = await f.run(click(list, 'Quiet hours'), guild);
|
||||
list = await f.run(submit(quiet.data.custom_id, { start: '23:00', end: '07:00' }), guild);
|
||||
expect((await f.json('/reminders')).quietStart).toBe('23:00');
|
||||
detail = await choose(list, guild);
|
||||
const confirmation = await f.run(click(detail, 'Remove reminder'), guild);
|
||||
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');
|
||||
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);
|
||||
expect((await f.json('/me')).timezone).toBe('America/New_York');
|
||||
expect((await f.json('/today')).timezone).toBe('America/New_York');
|
||||
expect(f.changed.length).toBe(6);
|
||||
});
|
||||
|
||||
test('bad input can reopen a prefilled form without consuming the valid control', async () => {
|
||||
f = discordFixture();
|
||||
const list = await f.run({ data: { name: 'reminders' } });
|
||||
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);
|
||||
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);
|
||||
const enabled = await f.run(click(saved, 'Enable delivery'));
|
||||
expect(enabled.data.content).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');
|
||||
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');
|
||||
});
|
||||
|
||||
test('website edits and timezone changes invalidate old controls even within one millisecond', async () => {
|
||||
f = discordFixture();
|
||||
const list = await f.run({ data: { name: 'reminders' } });
|
||||
const form = await f.run(click(list, 'Add reminder'));
|
||||
const before = await f.json('/reminders');
|
||||
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');
|
||||
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');
|
||||
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((await f.json('/me')).timezone).toBe('Europe/Belgrade');
|
||||
});
|
||||
|
||||
test('old forms cannot cross accounts; new Discord accounts discover UTC setup', async () => {
|
||||
f = discordFixture();
|
||||
const list = await f.run({ data: { name: 'reminders' } });
|
||||
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((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(f.db.select().from(users).where(eq(users.discordId, id)).get()?.timezone).toBe('UTC');
|
||||
});
|
||||
|
||||
test('paginated list, 20-slot limit, delivery status and quiet-hour validation', async () => {
|
||||
f = discordFixture(true);
|
||||
const reminders = Array.from({ length: 20 }, (_, i) => ({ type: 'weekly', name: `Reminder ${i + 1}`, time: '12:00', days: [1] }));
|
||||
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');
|
||||
const next = await f.run(click(list, 'Next'));
|
||||
expect(next.data.content).toContain('Reminder 20');
|
||||
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((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)');
|
||||
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');
|
||||
});
|
||||
113
src/discord/preferences.ts
Normal file
113
src/discord/preferences.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { users } from '../db/schema';
|
||||
import { timezoneInput } from '../habits/contracts';
|
||||
import { ApiError } from '../habits/service';
|
||||
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';
|
||||
|
||||
const deliveryLabels: Record<string, string> = {
|
||||
sent: 'Delivered', deferred: 'Waiting to retry', uncertain: 'Delivery unconfirmed; check Discord. This attempt will not be retried.',
|
||||
failed: 'Could not deliver. Check your DM permissions and that you share a server with the bot.', skipped: 'Skipped because check-ins or settings changed',
|
||||
};
|
||||
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const service = (c: Context) => new ReminderService(c.db, c.service.user.id, c.remindersAvailable ?? false, c.now);
|
||||
const user = (c: Context) => c.db.select().from(users).where(eq(users.id, c.service.user.id)).get()!;
|
||||
const timezoneVersion = (c: Context) => { const u = user(c); return JSON.stringify([u.timezone, u.updatedAt]); };
|
||||
const snapshot = (c: Context, data: ControlData = {}): ControlData => ({ ...data, preferenceVersion: service(c).version(), timezoneVersion: timezoneVersion(c) });
|
||||
function current(c: Context, data: ControlData, timezoneOnly = false) {
|
||||
if (data.timezoneVersion !== timezoneVersion(c) || (!timezoneOnly && data.preferenceVersion !== service(c).version()))
|
||||
throw new ApiError(409, 'Settings changed since this control was opened. Run /settings or /reminders to refresh.');
|
||||
}
|
||||
function groups(c: Context) {
|
||||
const result: { name: string; entry: ReminderEntry; indexes: number[]; times: string[] }[] = [];
|
||||
service(c).read().reminders.forEach((entry, index) => {
|
||||
const key = (e: ReminderEntry) => JSON.stringify([e.name ?? '', e.type, e.type === 'date' ? e.date : [...e.days].sort()]);
|
||||
let group = result.find(g => key(g.entry) === key(entry));
|
||||
if (!group) { group = { name: entry.name || 'Unnamed reminder', entry, indexes: [], times: [] }; result.push(group); }
|
||||
group.indexes.push(index); group.times.push(entry.time);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
function group(c: Context, data: ControlData) {
|
||||
const found = groups(c)[data.index ?? -1];
|
||||
if (!found) throw new ApiError(404, 'Reminder not found. Run /reminders to refresh.');
|
||||
return found;
|
||||
}
|
||||
const schedule = (e: ReminderEntry) => e.type === 'date' ? e.date : e.days.length === 7 ? 'Every day' : e.days.map(d => weekdays[d]).join(', ');
|
||||
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)),
|
||||
row(button(c, 'Settings', 'prefs.settings'), button(c, 'Refresh', 'prefs.list', { page }), button(c, 'View schedule card', 'graphic.reminders')),
|
||||
]);
|
||||
}
|
||||
function edit(c: Context, data: ControlData): Reply {
|
||||
const g = data.index === undefined ? undefined : group(c, data), draft = data.draft;
|
||||
return modal(c, g ? 'Edit reminder' : 'Add reminder', 'prefs.save', data, [
|
||||
{ id: 'name', label: 'Reminder name', value: draft?.name ?? g?.entry.name ?? '', max: 80 },
|
||||
{ id: 'times', label: 'Times (24-hour, separated by commas)', value: draft?.times ?? g?.times.join(', ') ?? '', placeholder: '09:00, 18:30', max: 160 },
|
||||
{ id: 'days', label: 'Weekdays (leave blank for a specific date)', value: draft?.days ?? (g?.entry.type === 'weekly' ? g.entry.days.map(d => weekdays[d]).join(', ') : ''), placeholder: 'Mon, Wed, Fri or every day', required: false, max: 100 },
|
||||
{ id: 'date', label: 'Specific date (leave blank for weekdays)', value: draft?.date ?? (g?.entry.type === 'date' ? g.entry.date : ''), placeholder: '2026-09-30', required: false, max: 10 },
|
||||
]);
|
||||
}
|
||||
function parseReminder(input: Record<string, string>): ReminderEntry[] {
|
||||
const name = input.name?.trim(), times = input.times?.split(',').map(t => t.trim()) ?? [], daysText = input.days?.trim() ?? '', date = input.date?.trim() ?? '';
|
||||
if (!name || name.length > 80) throw new ApiError(422, 'Enter a reminder name (up to 80 characters).');
|
||||
if (!times.length || times.some(t => !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(t)) || new Set(times).size !== times.length)
|
||||
throw new ApiError(422, 'Use distinct 24-hour times separated by commas, for example 09:00, 18:30.');
|
||||
if (!!daysText === !!date) throw new ApiError(422, 'Enter either weekdays or a specific date, leaving the other field blank.');
|
||||
if (date) return times.map(time => ({ type: 'date', name, time, date }));
|
||||
const days = /^(every day|daily)$/i.test(daysText) ? [0, 1, 2, 3, 4, 5, 6] : daysText.toLowerCase().split(/[\s,]+/).map(day => ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].findIndex(full => day === full || day === full.slice(0, 3)));
|
||||
if (days.some(d => d < 0) || new Set(days).size !== days.length) throw new ApiError(422, 'Choose distinct weekdays such as Mon, Wed, Fri, or enter every day.');
|
||||
return times.map(time => ({ type: 'weekly', name, time, days }));
|
||||
}
|
||||
function save(c: Context, data: ControlData, changes: Record<string, unknown>) {
|
||||
const prefs = service(c).read();
|
||||
service(c).save({ enabled: prefs.enabled, reminders: prefs.reminders, quietStart: prefs.quietStart, quietEnd: prefs.quietEnd, ...changes }, data.preferenceVersion);
|
||||
c.controls.consume(c.interaction.data!.custom_id!); c.changed();
|
||||
return preferences(c, 'reminders');
|
||||
}
|
||||
export function preferencesAction(c: Context, action: string, data: ControlData): Reply | undefined {
|
||||
if (!action.startsWith('prefs.')) return;
|
||||
if (action === 'prefs.list') return preferences(c, 'reminders', data);
|
||||
if (action === 'prefs.settings') return preferences(c, 'settings');
|
||||
current(c, data, action === 'prefs.timezone' || action === 'prefs.timezoneSave');
|
||||
if (action === 'prefs.timezone') return modal(c, 'Change timezone', 'prefs.timezoneSave', data, [{ id: 'timezone', label: 'Timezone (region/city)', value: data.draft?.timezone ?? user(c).timezone, placeholder: 'Europe/Belgrade, America/New_York, UTC', max: 100 }]);
|
||||
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] ?? '';
|
||||
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'))]);
|
||||
}
|
||||
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'))]);
|
||||
try {
|
||||
if (action === 'prefs.timezoneSave') {
|
||||
const parsed = timezoneInput.safeParse({ timezone: fields(c.interaction).timezone?.trim() });
|
||||
if (!parsed.success) throw new ApiError(422, 'Use a timezone such as Europe/Belgrade, America/New_York, or UTC.');
|
||||
c.service.setTimezone(parsed.data.timezone); c.controls.consume(c.interaction.data!.custom_id!); c.changed();
|
||||
return preferences(c, 'settings');
|
||||
}
|
||||
if (action === 'prefs.quietSave') { const input = fields(c.interaction); return save(c, data, { quietStart: input.start?.trim(), quietEnd: input.end?.trim() }); }
|
||||
if (action === 'prefs.toggle') return save(c, data, { enabled: data.done });
|
||||
if (action === 'prefs.save' || action === 'prefs.removeSave') {
|
||||
const removed = data.index === undefined ? [] : group(c, data).indexes;
|
||||
const remaining = service(c).read().reminders.filter((_, i) => !removed.includes(i));
|
||||
const reminders = [...remaining, ...(action === 'prefs.save' ? parseReminder(fields(c.interaction)) : [])];
|
||||
return save(c, data, { reminders, ...(!reminders.length ? { enabled: false } : {}) });
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
34
src/discord/progress-image.test.ts
Normal file
34
src/discord/progress-image.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { createCanvas, loadImage } from '@napi-rs/canvas';
|
||||
import { renderProgressImage } from './progress-image';
|
||||
import { addDays } from '../habits/calendar';
|
||||
import type { LiveImageData } from '../sharing/live-card-image';
|
||||
import { themePalette } from '../lib/theme';
|
||||
const sample: LiveImageData = {
|
||||
name: 'Mina', avatarUrl: null, from: '2026-08-06', to: '2026-09-04',
|
||||
habits: [{ name: 'Read a little, every day', color: '#285d49', completed: 7, scheduled: 23,
|
||||
days: Array.from({ length: 30 }, (_, i) => ({ date: addDays('2026-08-06', i), color: ['#eeeeee', '#a9beb5', '#285d49'][i % 3]!, due: i % 4 !== 0, ratio: i % 4 === 0 ? null : [0, .5, 1][i % 3]! })) }],
|
||||
};
|
||||
for (const theme of ['light', 'dark'] as const) test(`focused 30-day ${theme} PNG fits upload limits, has all cells and deterministic pixels`, async () => {
|
||||
const png = await renderProgressImage(sample, theme);
|
||||
const bytes = Buffer.from(png);
|
||||
expect(bytes.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a');
|
||||
expect(bytes.readUInt32BE(16)).toBe(1200); expect(bytes.readUInt32BE(20)).toBe(860);
|
||||
expect(bytes.length).toBeLessThan(4 * 1024 * 1024);
|
||||
expect(await renderProgressImage(sample, theme)).toEqual(png);
|
||||
const image = await loadImage(png), canvas = createCanvas(1200, 860), ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const rgb = (hex: string) => [1,3,5].map(i => parseInt(hex.slice(i, i + 2), 16));
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const pixel = [...ctx.getImageData(70 + (i % 10) * 108, 360 + Math.floor(i / 10) * 130, 1, 1).data];
|
||||
expect(pixel[3]).toBe(255);
|
||||
expect(pixel.slice(0, 3)).not.toEqual(rgb(themePalette[theme].paper));
|
||||
}
|
||||
// Opt-in artifact generation only; normal tests never write output files.
|
||||
if (process.env.PROGRESS_IMAGE_REVIEW_DIR) await Bun.write(`${process.env.PROGRESS_IMAGE_REVIEW_DIR}/progress-${theme}.png`, png);
|
||||
});
|
||||
test('long unicode names render safely and invalid ranges are rejected', async () => {
|
||||
const long = { ...sample, name: '🌸'.repeat(100), habits: [{ ...sample.habits[0]!, name: '読む 📚 '.repeat(100) }] };
|
||||
expect((await renderProgressImage(long)).length).toBeLessThan(4 * 1024 * 1024);
|
||||
expect(renderProgressImage({ ...sample, habits: [] })).rejects.toThrow('Choose one habit');
|
||||
});
|
||||
71
src/discord/progress-image.ts
Normal file
71
src/discord/progress-image.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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';
|
||||
import '../sharing/live-card-image';
|
||||
import { themePalette, type Theme } from '../lib/theme';
|
||||
import { themedHabitShade, habitProgressColor } from '../lib/habit-palette';
|
||||
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 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);
|
||||
const ctx = canvas.getContext('2d'), palette = themePalette[theme];
|
||||
const rect = (x: number, y: number, width: number, height: number, color: string, radius = 0) => {
|
||||
ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, width, height, radius); ctx.fill();
|
||||
};
|
||||
const text = (value: string, x: number, y: number, size: number, width: number, color: string = palette.ink, serif = false) => {
|
||||
ctx.font = `400 ${size}px "${serif ? 'Instrument Serif' : 'Mina Sans'}"`; ctx.fillStyle = color;
|
||||
const chars = Array.from(value); let fitted = value;
|
||||
while (chars.length && ctx.measureText(fitted).width > width) { chars.pop(); fitted = `${chars.join('')}…`; }
|
||||
ctx.fillText(fitted, x, y);
|
||||
};
|
||||
const color = (ratio: number | null, due: boolean, serverShade?: string) => !due ? palette.avatar : !ratio ? palette.empty : serverShade ? themedHabitShade(serverShade, habit.color, theme) : habitProgressColor(habit.color, ratio, theme);
|
||||
const contrast = (hex: string) => {
|
||||
const [r, g, b] = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
|
||||
return .299 * r! + .587 * g! + .114 * b! > 150 ? '#202a26' : '#ffffff';
|
||||
};
|
||||
rect(0, 0, 1200, 860, palette.paper);
|
||||
rect(0, 0, 1200, 8, habit.color);
|
||||
text(data.name, 64, 80, 28, 720);
|
||||
text('Mina Habits.', 940, 82, 40, 200, palette.ink, true);
|
||||
text(habit.name, 64, 175, 64, 1060, palette.ink, true);
|
||||
text(`${cardDate(data.from)} – ${cardDate(data.to)}`, 64, 228, 26, 720, palette.muted);
|
||||
text(`${habit.completed}/${habit.scheduled} days complete`, 800, 228, 26, 350);
|
||||
rect(64, 261, 1072, 1, palette.rule);
|
||||
habit.days.forEach((day, index) => {
|
||||
const x = 64 + (index % 10) * 108, y = 325 + Math.floor(index / 10) * 130;
|
||||
const label = new Date(`${day.date}T12:00:00Z`).toLocaleDateString('en', { month: 'short', day: 'numeric', timeZone: 'UTC' });
|
||||
text(label, x, y, 20, 96, palette.muted);
|
||||
rect(x, y + 15, 96, 70, color(day.ratio, day.due, day.color), 10);
|
||||
// Draw symbols as paths: bundled text fonts need not contain checkmark glyphs.
|
||||
const cx = x + 48, cy = y + 50;
|
||||
ctx.strokeStyle = contrast(color(day.ratio, day.due, day.color));
|
||||
ctx.fillStyle = ctx.strokeStyle; ctx.lineWidth = 3; ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
if (!day.due) { ctx.moveTo(cx - 7, cy); ctx.lineTo(cx + 7, cy); ctx.stroke(); }
|
||||
else if ((day.ratio ?? 0) >= 1) { ctx.moveTo(cx - 9, cy); ctx.lineTo(cx - 2, cy + 7); ctx.lineTo(cx + 11, cy - 8); ctx.stroke(); }
|
||||
else { ctx.arc(cx, cy, day.ratio ? 4 : 8, 0, Math.PI * 2); if (day.ratio) ctx.fill(); else ctx.stroke(); }
|
||||
});
|
||||
rect(64, 716, 1072, 1, palette.rule);
|
||||
const legend = [
|
||||
{ label: 'Not scheduled', ratio: null, due: false },
|
||||
{ label: 'No progress', ratio: 0, due: true },
|
||||
{ label: 'Partial', ratio: 0.5, due: true },
|
||||
{ label: 'Complete', ratio: 1, due: true },
|
||||
];
|
||||
legend.forEach((item, index) => {
|
||||
const x = 64 + index * 276;
|
||||
rect(x, 750, 24, 24, color(item.ratio, item.due), 5);
|
||||
text(item.label, x + 36, 772, 21, 230, palette.muted);
|
||||
});
|
||||
text('A little, every day.', 64, 822, 27, 1072, palette.muted, true);
|
||||
try { return new Uint8Array(await canvas.encode('png')); }
|
||||
finally { canvas.width = 1; canvas.height = 1; }
|
||||
}
|
||||
128
src/discord/progress.test.ts
Normal file
128
src/discord/progress.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
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 type { DeferredMessage } from './progress';
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
let stop = () => {};
|
||||
afterEach(() => { stop(); f?.close(); });
|
||||
function setup(settings: { allowed?: string[]; fail?: boolean; writes?: boolean } = {}) {
|
||||
const jobs: (() => Promise<void>)[] = [], replies: DeferredMessage[] = [], sends: RequestInit[] = [];
|
||||
f = discordFixture((base, now) => {
|
||||
const config = { token: 'test-bot-token', channelId: '555555555555555555', allowedUserIds: settings.allowed, writesEnabled: settings.writes ?? true };
|
||||
const request = async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'POST' || init?.method === 'PATCH') { sends.push(init); if (settings.fail) throw new Error('uncertain'); return Response.json({ id: '777777777777777777' }); }
|
||||
return Response.json({ id: config.channelId, guild_id: '666666666666666666', type: 0, name: 'habit-sharing' });
|
||||
};
|
||||
const auth = createAuth(base.db, { origin: base.origin, clientId: '', clientSecret: '', cookieSecret: 'test-only-secret-at-least-32-characters' }, undefined, now);
|
||||
const sharing = createSharingRoutes(base.db, auth, config, now, request), live = createLiveDiscord(base.db, auth, config, now, request);
|
||||
stop = live.stop;
|
||||
return { progress: { sharing, live }, schedule: job => jobs.push(job), complete: async (_i, result) => { replies.push(result); } };
|
||||
});
|
||||
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);
|
||||
}
|
||||
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(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');
|
||||
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(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();
|
||||
f.restart(); await f.requestDiscord(i); await env.drain();
|
||||
expect(env.sends).toHaveLength(1);
|
||||
await click(panel, 'Live image controls', guild); const live = await env.drain();
|
||||
await click(live, 'Start live image publicly', guild); await env.drain();
|
||||
expect((await f.json('/sharing/live')).status).toBe('active');
|
||||
expect(env.sends).toHaveLength(2);
|
||||
await click(panel, 'Live image controls', guild); const active = await env.drain();
|
||||
await click(active, 'Pause live image', guild); await env.drain();
|
||||
expect((await f.json('/sharing/live')).status).toBe('paused');
|
||||
await click(panel, 'Live image controls', guild); const paused = await env.drain();
|
||||
await click(paused, 'Resume live image publicly', guild); await env.drain();
|
||||
expect((await f.json('/sharing/live')).status).toBe('active');
|
||||
expect(env.sends).toHaveLength(2); // Unchanged image keeps the original message.
|
||||
});
|
||||
test('pagination, cross-account controls, stale snapshot and uncertain delivery never resend', async () => {
|
||||
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' } });
|
||||
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();
|
||||
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(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();
|
||||
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(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(env.sends).toHaveLength(0);
|
||||
});
|
||||
test('disabled writes, shared snapshot quotas, and stale live confirmations', async () => {
|
||||
const env = setup({ writes: false });
|
||||
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(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(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(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(env.sends).toHaveLength(1);
|
||||
f.setDiscordTime('2026-09-04T12:16:00Z');
|
||||
expect((await click(preview, 'Choose public destination')).data.content).toContain('expired');
|
||||
});
|
||||
|
||||
test('a website edit during a deferred public share invalidates the prepared snapshot', async () => {
|
||||
const env = setup();
|
||||
const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201);
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
await click(panel, 'Preview a habit', false, [habit.id]); const preview = await env.drain();
|
||||
await click(preview, 'Choose public destination'); const confirmation = await env.drain();
|
||||
await click(confirmation, 'Share snapshot publicly');
|
||||
const delivery = env.drain();
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
const result = await delivery;
|
||||
expect(result.data.content).toContain('changed');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
});
|
||||
99
src/discord/progress.ts
Normal file
99
src/discord/progress.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { renderProgressImage } from './progress-image';
|
||||
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';
|
||||
|
||||
export type ProgressOperations = {
|
||||
sharing: Pick<ReturnType<typeof createSharingRoutes>, 'send' | 'destination'>;
|
||||
live: Pick<ReturnType<typeof createLiveDiscord>, 'state' | 'start' | 'pause'>;
|
||||
};
|
||||
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) {
|
||||
// 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 = 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]!];
|
||||
}
|
||||
return { ...result, avatarUrl: null };
|
||||
}
|
||||
async function render(c: Context, data: ControlData, source = imageData(c, data)) {
|
||||
return renderProgressImage(source, data.theme ?? 'light', c.service.user.id);
|
||||
}
|
||||
function output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; }
|
||||
function 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 } };
|
||||
}
|
||||
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 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);
|
||||
}
|
||||
export function progressAction(c: Context, action: string, data: ControlData): Reply | undefined {
|
||||
if (action === 'progress') return progress(c, data);
|
||||
if (!action.startsWith('progress.')) return;
|
||||
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;
|
||||
c.service.current(habitId!);
|
||||
const selection = { habitId, 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);
|
||||
});
|
||||
}
|
||||
if (action === 'progress.confirm' && data.expected !== fingerprint(imageData(c, data))) throw new ApiError(409, 'Progress changed. Open a fresh preview before sharing.');
|
||||
const liveSnapshot = () => ({ state: ops.live.state(c.service.user.id), content: imageData(c, {}) });
|
||||
if (action === 'progress.confirm' || action === 'progress.live') return defer(c, async () => {
|
||||
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));
|
||||
});
|
||||
if (['progress.send', 'progress.start', 'progress.pause'].includes(action)) {
|
||||
const current = action === 'progress.send' ? imageData(c, data) : liveSnapshot();
|
||||
if (fingerprint(current) !== data.expected) throw new ApiError(409, 'Progress or live status changed. Open a fresh preview before sharing.');
|
||||
// Reserve the action in the same transaction as the interaction receipt, before outbound work.
|
||||
if (!c.defer) throw new ApiError(503, 'Progress rendering is unavailable. Try again later.');
|
||||
c.controls.consume(c.interaction.data!.custom_id!);
|
||||
return defer(c, async () => {
|
||||
if (action !== 'progress.pause') {
|
||||
const destination = await ops.sharing.destination(c.service.user);
|
||||
if (destination.channelUrl !== data.mode) throw new ApiError(409, 'The sharing destination changed. Confirm the new destination first.');
|
||||
}
|
||||
if (fingerprint(action === 'progress.send' ? imageData(c, data) : liveSnapshot()) !== data.expected) throw new ApiError(409, 'Progress or live status changed. Open a fresh preview before sharing.');
|
||||
if (action === 'progress.send') {
|
||||
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([new Uint8Array(image)], 'mina-habits-progress.png', { type: 'image/png' }));
|
||||
return output(message(c, delivery.status === 'sent' ? `Snapshot shared.${delivery.messageUrl ? ` [Open snapshot](${delivery.messageUrl})` : ''}` : 'Discord could not confirm delivery. Check the destination before creating another snapshot; this action will not be retried.', [], false));
|
||||
}
|
||||
const state = action === 'progress.pause' ? await ops.live.pause(c.service.user.id) : await ops.live.start(c.service.user, data.theme ?? 'light');
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,30 @@ import { ApiError, HabitService } from '../habits/service';
|
||||
import { ControlStore } from './state';
|
||||
import { interactionSchema, type Context, type Reply } from './types';
|
||||
import { dailyAction, today } from './daily';
|
||||
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 { help } from './help';
|
||||
import { commandGraphicAction } from './command-graphics';
|
||||
|
||||
export type CommandConfig = { publicKey: string; applicationId: string; origin: string };
|
||||
export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now = Date.now, changed: (userId: string) => void = () => {}) {
|
||||
export type CommandConfig = { publicKey: string; applicationId: string; origin: string; remindersAvailable?: boolean };
|
||||
export type DeferredDispatch = {
|
||||
progress?: ProgressOperations;
|
||||
complete?: (interaction: import('./types').Interaction, result: DeferredMessage) => Promise<void>;
|
||||
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) {
|
||||
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) }] : [] };
|
||||
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) });
|
||||
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 = {}) {
|
||||
const app = new Hono();
|
||||
const key = /^[a-f\d]{64}$/i.test(config.publicKey) ? createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(config.publicKey, 'hex')]), format: 'der', type: 'spki' }) : null;
|
||||
app.use('*', bodyLimit({ maxSize: 64 * 1024 }));
|
||||
@@ -34,23 +53,31 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
|
||||
if (!profile || ![2, 3, 4, 5].includes(i.type)) return c.json({ error: 'Unsupported interaction.' }, 400);
|
||||
// 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));
|
||||
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();
|
||||
let pending: (() => Promise<DeferredMessage>) | undefined;
|
||||
// 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, changed: () => changed(userId) };
|
||||
const ctx: Context = { db, service, controls, interaction: i, now: now(), origin: config.origin, remindersAvailable: config.remindersAvailable ?? false, changed: () => changed(userId), progress: dispatch.progress, defer: i.token ? job => { pending = job; } : undefined };
|
||||
let result: Reply;
|
||||
try {
|
||||
if (i.type === 4) result = { type: 8, data: { choices: [] } };
|
||||
else if (i.type === 2 && i.data?.name === 'tasks') result = manageTasks(ctx);
|
||||
else if (i.type === 2 && i.data?.name === 'habits') result = manageHabits(ctx);
|
||||
else if (i.type === 2 && i.data?.name === 'progress') result = progress(ctx);
|
||||
else if (i.type === 2 && i.data?.name === 'today') result = today(ctx);
|
||||
else if (i.type === 2 && ['settings', 'reminders'].includes(i.data?.name ?? '')) result = preferences(ctx, i.data!.name!);
|
||||
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 ?? '');
|
||||
result = dailyAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false);
|
||||
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;
|
||||
@@ -58,7 +85,18 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
|
||||
}
|
||||
db.insert(discordReceipts).values({ id: i.id, userId, response: result, expiresAt: now() + 86400000 }).run();
|
||||
return result;
|
||||
});
|
||||
}));
|
||||
if (pending) {
|
||||
const work = pending;
|
||||
const job = async () => {
|
||||
let result: DeferredMessage;
|
||||
try { result = await work(); }
|
||||
catch (error) { result = { message: { content: error instanceof ApiError ? error.message : 'Could not complete this action. Check the destination before trying another share.', components: [], allowed_mentions: { parse: [] } } }; }
|
||||
try { await (dispatch.complete ?? completeDeferred)(i, result); }
|
||||
catch { console.error('Discord deferred reply failed; it will not be retried.'); }
|
||||
};
|
||||
(dispatch.schedule ?? (job => { setTimeout(() => { void job(); }, 0); }))(job);
|
||||
}
|
||||
return c.json(reply);
|
||||
});
|
||||
return app;
|
||||
|
||||
@@ -2,9 +2,9 @@ import { generateKeyPairSync, sign } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { fixture } from '../habits/test-fixture';
|
||||
import { users } from '../db/schema';
|
||||
import { createDiscordRoutes } from './routes';
|
||||
import { createDiscordRoutes, type DeferredDispatch } from './routes';
|
||||
import type { Interaction } from './types';
|
||||
export function discordFixture() {
|
||||
export function discordFixture(setup?: boolean | ((f: ReturnType<typeof fixture>, now: () => number) => DeferredDispatch)) {
|
||||
const f = fixture();
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const key = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex');
|
||||
@@ -13,17 +13,19 @@ export function discordFixture() {
|
||||
f.db.update(users).set({ discordId: alice }).where(eq(users.id, 'alice')).run();
|
||||
f.db.update(users).set({ discordId: bob }).where(eq(users.id, 'bob')).run();
|
||||
const changed: string[] = [];
|
||||
let app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin }, () => time, id => changed.push(id));
|
||||
const dispatch = typeof setup === "function" ? setup(f, () => time) : undefined;
|
||||
const remindersAvailable = setup === true;
|
||||
let app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin, remindersAvailable }, () => time, id => changed.push(id), dispatch);
|
||||
function payload(data: Partial<Interaction>, guild = false, user = alice): Interaction {
|
||||
const profile = { id: user, username: user === alice ? 'alice' : 'bob' };
|
||||
return { id: String(++sequence), application_id: applicationId, type: 2, ...(guild ? { guild_id: '444444444444444444', member: { user: profile } } : { user: profile }), ...data };
|
||||
return { id: String(++sequence), application_id: applicationId, type: 2, token: 'test-interaction-token', ...(guild ? { guild_id: '444444444444444444', member: { user: profile } } : { user: profile }), ...data };
|
||||
}
|
||||
async function request(i: Interaction, headers: Record<string, string> = {}) {
|
||||
const body = JSON.stringify(i), timestamp = String(time / 1000);
|
||||
return app.request('http://localhost/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Signature-Timestamp': timestamp, 'X-Signature-Ed25519': sign(null, Buffer.from(timestamp + body), privateKey).toString('hex'), ...headers }, body });
|
||||
}
|
||||
async function run(data: Partial<Interaction>, guild = false, user = alice): Promise<any> { return (await request(payload(data, guild, user))).json(); }
|
||||
return { ...f, alice, bob, changed, payload, requestDiscord: request, run, restart: () => { app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin }, () => time, id => changed.push(id)); }, setDiscordTime: (value: string) => { time = Date.parse(value); f.setTime(value); } };
|
||||
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);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DeferredMessage, ProgressOperations } from './progress';
|
||||
import { z } from 'zod';
|
||||
import type { AppDatabase } from '../auth';
|
||||
import type { HabitService } from '../habits/service';
|
||||
@@ -13,8 +14,8 @@ 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 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 };
|
||||
export type Context = { db: AppDatabase; service: HabitService; controls: ControlStore; interaction: Interaction; now: number; origin: string; changed: () => void };
|
||||
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 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> = {};
|
||||
const walk = (parts: ComponentInput[]) => parts.forEach(c => { if (c.custom_id && c.value !== undefined) result[c.custom_id] = c.value; if (c.components) walk(c.components); });
|
||||
|
||||
@@ -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';
|
||||
@@ -6,24 +6,66 @@ import { addDays, endOfDay, localDate, scheduled, schedulesOverlap, shade } from
|
||||
import { calendarSettingsSchema, habitInput, type HabitConfig, type CalendarSettings } from './contracts';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: 400 | 404 | 409 | 422 | 429 | 502, message: string) { super(message); }
|
||||
constructor(public status: 400 | 403 | 404 | 409 | 422 | 429 | 502 | 503, message: string) { super(message); }
|
||||
}
|
||||
const missing = () => new ApiError(404, 'Not found');
|
||||
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,7 +324,7 @@ export class HabitService {
|
||||
const value = !due ? 0 : config!.method === 'count' ? counted?.value ?? 0 : config!.method === 'manual' ? Number(record?.done ?? false) : tasks.filter(t => t.done).length;
|
||||
const 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',
|
||||
value, target, carriedFrom: counted?.carriedFrom ?? null, loggedCount: record?.countSet ? record.count : null, unit: config?.method === 'count' ? config.unit : config?.method === 'tasks' ? 'tasks' : 'completion',
|
||||
@@ -256,6 +345,7 @@ export class HabitService {
|
||||
this.db.transaction(() => {
|
||||
this.db.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));
|
||||
@@ -318,14 +410,17 @@ export class HabitService {
|
||||
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 });
|
||||
shadeCount: steps, ...progress, color: future ? settings.futureColor : progress.color, habits: details.map(detail => {
|
||||
const { tasks, requirements, ...summary } = detail;
|
||||
return { ...summary, tasks: compact ? undefined : tasks, requirements: compact ? undefined : requirements };
|
||||
}) });
|
||||
}
|
||||
return { kind: combined ? 'combined' as const : 'individual' as const, from, to, today: this.today, timezone: this.user.timezone, settings, days,
|
||||
// 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! })) };
|
||||
}
|
||||
setTimezone(timezone: string) {
|
||||
this.db.update(users).set({ timezone, updatedAt: this.now }).where(eq(users.id, this.user.id)).run();
|
||||
this.db.update(users).set({ timezone, updatedAt: sql`max(${users.updatedAt} + 1, ${this.now})` }).where(eq(users.id, this.user.id)).run();
|
||||
return { ...this.user, timezone };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
<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>
|
||||
@@ -11,5 +18,6 @@
|
||||
</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,9 +17,10 @@ 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 }) : undefined;
|
||||
const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin, commandsAvailable: /^[a-f\d]{64}$/i.test(process.env.DISCORD_PUBLIC_KEY ?? "") && Boolean(readAuthConfig().clientId) }) : undefined;
|
||||
if (import.meta.hot) import.meta.hot.dispose(() => reminders?.stop());
|
||||
|
||||
const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig(), { discord: { publicKey: process.env.DISCORD_PUBLIC_KEY ?? "", applicationId: readAuthConfig().clientId, origin: readAuthConfig().origin }, healthy: () => !backups?.state.enabled || (!backups.state.failed && backups.state.lastSuccess > 0) });
|
||||
@@ -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,10 +1,10 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { habitProgressColor, themedHabitShade } from "./habit-palette";
|
||||
import { HABIT_PALETTES } from "../components/design-system/HabitColorPicker";
|
||||
import { HABIT_COLOR_CHOICES } from "../components/design-system/HabitColorPicker";
|
||||
import { shade } from "../habits/calendar";
|
||||
const luminance = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i+2),16)/255).map(v => v <= .04045 ? v/12.92 : ((v+.055)/1.055)**2.4).reduce((n,v,i) => n+v*[.2126,.7152,.0722][i]!,0);
|
||||
test("all suggested and extreme custom colors have eight distinct ordered steps in both themes", () => {
|
||||
for (const base of [...HABIT_PALETTES.flatMap(p=>[...p.colors]), '#ffffff','#000000','#ffff00','#0000ff','#ff0000','#00ff00']) {
|
||||
for (const base of [...HABIT_COLOR_CHOICES, '#ffffff','#000000','#ffff00','#0000ff','#ff0000','#00ff00']) {
|
||||
for (const theme of ['light','dark'] as const) {
|
||||
const colors = Array.from({length:8},(_,i)=>habitProgressColor(base,i/7,theme));
|
||||
expect(new Set(colors).size).toBe(8);
|
||||
|
||||
@@ -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); } };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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";
|
||||
@@ -15,35 +18,37 @@ import {
|
||||
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>
|
||||
@@ -54,16 +59,16 @@ export function Home() {
|
||||
</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 ? "A moment to reconnect." : "Let’s try that again."}</h1>
|
||||
<Card headingLevel={2} heading={disconnected ? "Your habits will be here." : "Account unavailable"}>
|
||||
<p>{disconnected ? "We can’t connect to Mina Habits. Check your internet connection to load your account and check in. We’ll try again automatically." : "Your account couldn’t be loaded."}</p>
|
||||
<div className="ds-actions">
|
||||
<Button onClick={retry}>Try again</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</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,6 +77,7 @@ export function Home() {
|
||||
}
|
||||
|
||||
function Welcome({ onSignIn }: { onSignIn: () => void }) {
|
||||
const online = useOnline();
|
||||
const [water, setWater] = useState(3);
|
||||
const [read, setRead] = useState(false);
|
||||
return (
|
||||
@@ -91,7 +97,7 @@ function Welcome({ onSignIn }: { onSignIn: () => void }) {
|
||||
day at a time.
|
||||
</p>
|
||||
<div className="ds-actions">
|
||||
<DiscordSignInButton onClick={onSignIn} />
|
||||
<DiscordSignInButton disabled={!online} 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>
|
||||
@@ -161,9 +167,13 @@ function Welcome({ onSignIn }: { onSignIn: () => void }) {
|
||||
|
||||
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 [notice, setNotice] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [habitRevisions, setHabitRevisions] = useState<Record<string, number>>({});
|
||||
const [pendingHabits, setPendingHabits] = useState<Set<string>>(new Set());
|
||||
const progressSaves = useRef(new Set<string>());
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [habitView, setHabitView] = useState<"today" | "all">("today");
|
||||
@@ -200,9 +210,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);
|
||||
if (next !== todaySnapshot.current) setRevision((value) => value + 1);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -223,14 +234,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 && 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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -239,11 +252,10 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
body: { count: number } | { done: boolean },
|
||||
taskId?: string
|
||||
) {
|
||||
if (saving.current || loading || needsRefresh || !today) return;
|
||||
saving.current = true;
|
||||
setBusy(true);
|
||||
if (saving.current || progressSaves.current.has(habit.habitId) || loading || needsRefresh || !today) return;
|
||||
progressSaves.current.add(habit.habitId);
|
||||
setPendingHabits(new Set(progressSaves.current));
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
const path = `/habits/${habit.habitId}/days/${today.date}/${taskId ? `tasks/${taskId}` : "progress"}`;
|
||||
const updated = await habitRequest<TodayHabit>(path, {
|
||||
@@ -265,12 +277,12 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
};
|
||||
});
|
||||
setRevision((value) => value + 1);
|
||||
setNotice(`${habit.name} saved.`);
|
||||
setHabitRevisions(current => ({ ...current, [habit.habitId]: (current[habit.habitId] ?? 0) + 1 }));
|
||||
} catch (error) {
|
||||
if (mounted.current) reportError(error);
|
||||
} finally {
|
||||
saving.current = false;
|
||||
if (mounted.current) setBusy(false);
|
||||
progressSaves.current.delete(habit.habitId);
|
||||
if (mounted.current) setPendingHabits(new Set(progressSaves.current));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,12 +292,11 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
taskId?: string,
|
||||
createTask = false
|
||||
) {
|
||||
if (saving.current || loading || needsRefresh)
|
||||
if (saving.current || progressSaves.current.size > 0 || loading || needsRefresh)
|
||||
throw new Error("Please wait for the dashboard to refresh.");
|
||||
saving.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
await habitRequest(
|
||||
`/habits/${habit.habitId}${createTask ? "/tasks" : taskId ? `/tasks/${taskId}` : ""}`,
|
||||
@@ -300,11 +311,12 @@ 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);
|
||||
setRevision((value) => value + 1);
|
||||
setHabitRevisions(current => ({ ...current, [habit.habitId]: (current[habit.habitId] ?? 0) + 1 }));
|
||||
}
|
||||
} catch (refreshError) {
|
||||
if (refreshError instanceof Error && refreshError.message.includes("session has expired"))
|
||||
@@ -317,9 +329,6 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
}
|
||||
}
|
||||
if (mounted.current) {
|
||||
setNotice(
|
||||
`${taskId || createTask ? "Task" : "Habit"} ${createTask ? "added" : patch ? "updated" : taskId ? "deleted" : "archived"}.`
|
||||
);
|
||||
if (!patch)
|
||||
window.requestAnimationFrame(() => {
|
||||
(
|
||||
@@ -337,6 +346,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
|
||||
@@ -422,18 +438,14 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
date={today.date}
|
||||
onCancel={() => setAdding(false)}
|
||||
onExpired={() => expired.current()}
|
||||
onCreated={(name) => {
|
||||
onCreated={() => {
|
||||
focusAddAfterRefresh.current = true;
|
||||
setAdding(false);
|
||||
setNotice(`${name} created.`);
|
||||
setHabitView("all");
|
||||
setAttempt((value) => value + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<p className="ds-form-feedback ds-save-notice" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
{today.habits.length > 0 && (
|
||||
<section
|
||||
className="ds-section ds-habits-section"
|
||||
@@ -461,12 +473,12 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
key={habit.habitId}
|
||||
habit={habit}
|
||||
date={today.date}
|
||||
revision={revision}
|
||||
disabled={busy || loading || needsRefresh}
|
||||
onHistorySaved={() => setAttempt(value => value + 1)}
|
||||
onUpdate={update}
|
||||
onManage={manage}
|
||||
onExpired={() => expired.current()}
|
||||
revision={habit.calendarRevision ?? habitRevisions[habit.habitId] ?? 0}
|
||||
disabled={busy || pendingHabits.has(habit.habitId) || loading || needsRefresh}
|
||||
onHistorySaved={refreshHistory}
|
||||
onUpdate={updateHabit}
|
||||
onManage={manageHabit}
|
||||
onExpired={expire}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -478,7 +490,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
||||
);
|
||||
}
|
||||
|
||||
function SavedHabit({
|
||||
const SavedHabit = memo(function SavedHabit({
|
||||
habit,
|
||||
date,
|
||||
revision,
|
||||
@@ -608,7 +620,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 }[];
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user