Compare commits
28 Commits
d088179c5e
...
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 |
@@ -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";
|
||||
@@ -55,6 +56,7 @@ const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
routes: {
|
||||
"/fonts/browser/*": browserFont,
|
||||
"/__preview/reminder": () => new Response(reminderMessage ?? "No reminder sent"),
|
||||
"/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }),
|
||||
"/__preview/login": () =>
|
||||
|
||||
17
scripts/pwa-icons.ts
Normal file
17
scripts/pwa-icons.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
// Package the approved master at exact platform sizes; do not regenerate the artwork.
|
||||
const master = await loadImage("docs/design/mina-habits-icon.png");
|
||||
await mkdir("src/pwa/public", { recursive: true });
|
||||
for (const [name, size, scale] of [
|
||||
["icon-192", 192, 1], ["icon-512", 512, 1], ["icon-maskable-512", 512, 0.7],
|
||||
] as const) {
|
||||
const canvas = createCanvas(size, size);
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
const inset = size * (1 - scale) / 2;
|
||||
ctx.drawImage(master, inset, inset, size * scale, size * scale);
|
||||
await Bun.write(`src/pwa/public/${name}.png`, canvas.toBuffer("image/png"));
|
||||
}
|
||||
194
scripts/pwa-smoke.ts
Normal file
194
scripts/pwa-smoke.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { cp, mkdtemp, mkdir, rm, symlink } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { chromium } from "playwright";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { hashToken } from "../src/auth";
|
||||
|
||||
// Exercise a copied production bundle and disposable account. No live data or Discord calls.
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "minabot-pwa-"));
|
||||
const project = process.cwd();
|
||||
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
|
||||
const port = probe.port!;
|
||||
probe.stop(true);
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const databasePath = path.join(directory, "test.sqlite");
|
||||
const env = { ...process.env, NODE_ENV: "production", HOST: "127.0.0.1", PORT: String(port), APP_ORIGIN: origin,
|
||||
DATABASE_PATH: databasePath, BACKUP_ENABLED: "false", DISCORD_CLIENT_ID: "", DISCORD_CLIENT_SECRET: "", DISCORD_PUBLIC_KEY: "",
|
||||
DISCORD_BOT_TOKEN: "", DISCORD_SHARING_CHANNEL_ID: "", AUTH_COOKIE_SECRET: "pwa-test-only-secret-at-least-32-characters" };
|
||||
let server: ReturnType<typeof Bun.spawn> | undefined;
|
||||
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
|
||||
const screenshots = path.resolve(".cache/pwa");
|
||||
try {
|
||||
await cp("dist", path.join(directory, "dist"), { recursive: true });
|
||||
await symlink(path.join(project, "node_modules"), path.join(directory, "node_modules"), "dir");
|
||||
const migration = Bun.spawn([process.execPath, "scripts/migrate.ts"], { env, stdout: "pipe", stderr: "pipe" });
|
||||
assert.equal(await migration.exited, 0, "Production migrations failed");
|
||||
const sqlite = new Database(databasePath);
|
||||
const now = Date.now();
|
||||
const token = "p".repeat(43);
|
||||
sqlite.query("INSERT INTO users (id, discord_id, username, timezone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run("pwa", "pwa", "PWA Test", "UTC", now, now);
|
||||
sqlite.query("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(hashToken(token), "pwa", now, now + 3600000);
|
||||
sqlite.close();
|
||||
server = Bun.spawn([process.execPath, "index.js"], { cwd: path.join(directory, "dist"), env, stdout: "pipe", stderr: "pipe" });
|
||||
let healthy = false;
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
try { if ((await fetch(`${origin}/api/health`)).ok) { healthy = true; break; } } catch { /* Starting. */ }
|
||||
if (server.exitCode !== null) throw new Error(await new Response(server.stderr as ReadableStream).text());
|
||||
await Bun.sleep(50);
|
||||
}
|
||||
assert(healthy, "Production server failed to start");
|
||||
const swResponse = await fetch(`${origin}/sw.js`);
|
||||
assert.equal(swResponse.headers.get("service-worker-allowed"), "/");
|
||||
assert.equal(swResponse.headers.get("cache-control"), "no-cache");
|
||||
assert.match(swResponse.headers.get("content-type")!, /javascript/);
|
||||
assert.equal((await fetch(`${origin}/pwa/index.js`)).status, 404);
|
||||
assert.equal((await fetch(`${origin}/pwa/missing.png`)).status, 404);
|
||||
assert.equal((await fetch(`${origin}/pwa/icon-192.png`, { method: "POST" })).status, 405);
|
||||
assert.equal((await fetch(`${origin}/api/me`)).headers.get("cache-control"), "no-store");
|
||||
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
||||
const page = await context.newPage();
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", error => pageErrors.push(error.message));
|
||||
await page.goto(origin);
|
||||
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
|
||||
await page.waitForFunction(() => Boolean(navigator.serviceWorker.controller));
|
||||
assert.equal(await page.getByRole("button", { name: "Update app", exact: true }).count(), 0, "First install must not announce an update");
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const manifestResult = await cdp.send("Page.getAppManifest");
|
||||
assert.equal(manifestResult.errors.length, 0, JSON.stringify(manifestResult.errors));
|
||||
const manifest = JSON.parse(manifestResult.data!);
|
||||
assert.equal(manifest.id, "/");
|
||||
assert.equal(manifest.display, "standalone");
|
||||
assert.equal(manifest.scope, "/");
|
||||
for (const icon of manifest.icons) {
|
||||
const response = await fetch(new URL(icon.src, origin));
|
||||
assert.match(response.headers.get("content-type")!, /image\/png/);
|
||||
const png = Buffer.from(await response.arrayBuffer());
|
||||
assert.equal(`${png.readUInt32BE(16)}x${png.readUInt32BE(20)}`, icon.sizes);
|
||||
}
|
||||
const installability = await cdp.send("Page.getInstallabilityErrors");
|
||||
assert.deepEqual(installability.installabilityErrors, []);
|
||||
console.log("PASS Chromium installability, manifest, icon sizes, MIME types and HTTP cache policy");
|
||||
await mkdir(screenshots, { recursive: true });
|
||||
await page.screenshot({ path: path.join(screenshots, "desktop.png"), fullPage: true });
|
||||
await page.getByRole("radio", { name: "Dark", exact: true }).check();
|
||||
assert.equal(await page.locator('meta[name="theme-color"]').getAttribute("content"), "#191e1b");
|
||||
await page.getByRole("radio", { name: "Light", exact: true }).check();
|
||||
|
||||
await context.setOffline(true);
|
||||
await page.goto(`${origin}/a/deep/link`);
|
||||
await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
|
||||
assert(await page.getByText("We can’t connect to Mina Habits.", { exact: false }).isVisible());
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Offline mobile layout overflows");
|
||||
await page.screenshot({ path: path.join(screenshots, "offline-mobile.png"), fullPage: true });
|
||||
await page.goto(`${origin}/design-system`);
|
||||
await page.getByRole("heading", { name: /design system/i }).first().waitFor();
|
||||
await context.setOffline(false);
|
||||
await page.goto(origin);
|
||||
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
|
||||
await context.setOffline(true);
|
||||
await page.reload();
|
||||
await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
|
||||
await context.setOffline(false);
|
||||
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Online mobile layout overflows");
|
||||
await page.screenshot({ path: path.join(screenshots, "mobile.png"), fullPage: true });
|
||||
console.log("PASS offline cold start, nested routes, lazy routes, mobile layouts, theme and automatic reconnection");
|
||||
|
||||
// Keep an unsaved draft in another open window while applying an update here.
|
||||
const other = await context.newPage();
|
||||
await other.goto(origin);
|
||||
const draft = other.getByRole("textbox", { name: "Total glasses of water", exact: true });
|
||||
await draft.fill("6");
|
||||
const workerPath = path.join(directory, "dist/pwa/sw.js");
|
||||
const original = await Bun.file(workerPath).text();
|
||||
const configMatch = original.match(/const CONFIG = JSON.parse\((.+)\);/)!;
|
||||
const config = JSON.parse(JSON.parse(configMatch[1]!));
|
||||
// A broken release must leave the active worker and existing offline assets usable.
|
||||
const broken = { ...config, version: `${config.version}-broken`, assets: [...config.assets, "/pwa/missing.png"] };
|
||||
await Bun.write(workerPath, original.replace(configMatch[0], `const CONFIG = JSON.parse(${JSON.stringify(JSON.stringify(broken))});`));
|
||||
await page.evaluate(async () => {
|
||||
const registration = (await navigator.serviceWorker.getRegistration())!;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
registration.addEventListener("updatefound", () => {
|
||||
const worker = registration.installing!;
|
||||
worker.addEventListener("statechange", () => { if (worker.state === "redundant") resolve(); });
|
||||
}, { once: true });
|
||||
void registration.update().catch(reject);
|
||||
});
|
||||
});
|
||||
assert.equal(await page.getByRole("button", { name: "Update app", exact: true }).count(), 0);
|
||||
await context.setOffline(true);
|
||||
await page.reload();
|
||||
await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
|
||||
await context.setOffline(false);
|
||||
await page.getByRole("button", { name: "Try again", exact: true }).click();
|
||||
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
|
||||
config.version += "-qa";
|
||||
await Bun.write(workerPath, original.replace(configMatch[0], `const CONFIG = JSON.parse(${JSON.stringify(JSON.stringify(config))});`));
|
||||
await page.evaluate(async () => { await (await navigator.serviceWorker.getRegistration())!.update(); });
|
||||
await page.getByRole("button", { name: "Update app", exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "Update app", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Later", exact: true }).click();
|
||||
assert.equal(await draft.inputValue(), "6");
|
||||
await page.getByRole("button", { name: "Update app", exact: true }).click();
|
||||
await Promise.all([page.waitForEvent("load"), page.getByRole("button", { name: "Reload and update", exact: true }).click()]);
|
||||
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
|
||||
assert.equal(await draft.inputValue(), "6", "Updating another window discarded its draft");
|
||||
await other.getByRole("button", { name: "Update app", exact: true }).waitFor();
|
||||
const keys = await page.evaluate(() => caches.keys());
|
||||
assert(keys.some(key => key.endsWith("-qa")) && keys.length >= 2, "Old window assets were not retained");
|
||||
console.log("PASS failed-update recovery, update waiting, deferred reload and preservation of another window’s draft");
|
||||
|
||||
await context.addCookies([{ name: "minabot_session", value: token, url: origin }]);
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: "Sign out", exact: true }).waitFor();
|
||||
const privateResponse = await page.evaluate(async () => ({ status: (await fetch("/api/me")).status }));
|
||||
assert.equal(privateResponse.status, 200);
|
||||
await context.setOffline(true);
|
||||
assert.equal(await page.evaluate(async () => { try { await fetch("/api/me"); return true; } catch { return false; } }), false, "Account response was cached");
|
||||
await context.setOffline(false);
|
||||
await page.getByRole("button", { name: "Sign out", exact: true }).click();
|
||||
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
|
||||
const cacheUrls = await page.evaluate(async () => {
|
||||
const urls: string[] = [];
|
||||
for (const key of await caches.keys()) for (const request of await (await caches.open(key)).keys()) urls.push(new URL(request.url).pathname);
|
||||
return urls;
|
||||
});
|
||||
assert(!cacheUrls.some(url => url.startsWith("/api") || url.endsWith(".map") || url === "/index.js"), "Private files entered Cache Storage");
|
||||
assert.deepEqual(pageErrors, []);
|
||||
console.log("PASS authenticated API bypass, sign-out privacy and no uncaught browser errors");
|
||||
const manualContext = await browser.newContext({ viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true });
|
||||
await manualContext.addInitScript(() => {
|
||||
window.addEventListener("beforeinstallprompt", event => { event.preventDefault(); event.stopImmediatePropagation(); });
|
||||
});
|
||||
const manual = await manualContext.newPage();
|
||||
await manual.goto(origin);
|
||||
await manual.getByRole("button", { name: "Install app", exact: true }).click();
|
||||
await manual.getByRole("dialog", { name: "Keep Mina Habits close." }).waitFor();
|
||||
assert(await manual.getByText("iPhone or iPad:", { exact: false }).isVisible());
|
||||
assert(await manual.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Install dialog overflows");
|
||||
await manual.screenshot({ path: path.join(screenshots, "install-mobile.png"), fullPage: true });
|
||||
await manual.getByRole("button", { name: "Got it", exact: true }).click();
|
||||
await manual.evaluate(() => window.dispatchEvent(new Event("appinstalled")));
|
||||
assert.equal(await manual.getByRole("button", { name: "Install app", exact: true }).count(), 0);
|
||||
await manualContext.close();
|
||||
console.log("PASS mobile installation guidance and installed-state controls");
|
||||
console.log(`Screenshots: ${screenshots}`);
|
||||
} catch (error) {
|
||||
const page = browser?.contexts()[0]?.pages()[0];
|
||||
if (page) {
|
||||
console.error("Browser at failure:", await page.locator("body").innerText());
|
||||
await page.screenshot({ path: path.join(screenshots, "failure.png"), fullPage: true });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await browser?.close();
|
||||
if (server) { server.kill(); await server.exited; }
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
18
scripts/scheduled-backup.ts
Normal file
18
scripts/scheduled-backup.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { createBackup, latestBackupTime, type BackupConfig } from '../src/ops/backups';
|
||||
|
||||
const path = process.argv[2];
|
||||
if (!path || !process.argv[3]) throw new Error('Expected database path and backup configuration');
|
||||
const config = JSON.parse(process.argv[3]) as BackupConfig;
|
||||
const latest = latestBackupTime(config);
|
||||
if (latest && Date.now() - latest < config.intervalMs) console.log(JSON.stringify({ timestamp: latest, created: false }));
|
||||
else {
|
||||
// SQLite needs writable WAL bookkeeping when the first snapshot opens a cold database.
|
||||
// Only snapshot SQL runs here; never create a missing source file.
|
||||
const db = new Database(path, { readwrite: true, create: false, strict: true });
|
||||
try {
|
||||
db.exec('PRAGMA busy_timeout=5000');
|
||||
createBackup(db, config);
|
||||
console.log(JSON.stringify({ timestamp: latestBackupTime(config), created: true }));
|
||||
} finally { db.close(); }
|
||||
}
|
||||
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,6 +18,8 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
|
||||
app.use("*", async (c, next) => {
|
||||
const requestId = crypto.randomUUID();
|
||||
c.set("requestId", requestId); c.header("X-Request-ID", requestId);
|
||||
// Accounts, calendars, exports and OAuth responses must never enter HTTP caches.
|
||||
c.header("Cache-Control", "no-store");
|
||||
await next();
|
||||
});
|
||||
const auth = createAuth(db, config, request, now);
|
||||
|
||||
BIN
src/assets/fonts/InstrumentSerif-Italic.woff2
Normal file
BIN
src/assets/fonts/InstrumentSerif-Italic.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/InstrumentSerif-Regular.woff2
Normal file
BIN
src/assets/fonts/InstrumentSerif-Regular.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/NotoSans-Medium.woff2
Normal file
BIN
src/assets/fonts/NotoSans-Medium.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/NotoSans-Regular.woff2
Normal file
BIN
src/assets/fonts/NotoSans-Regular.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/NotoSans-SemiBold.woff2
Normal file
BIN
src/assets/fonts/NotoSans-SemiBold.woff2
Normal file
Binary file not shown.
@@ -12,3 +12,24 @@ Downloaded September 7, 2026. The files are versioned in this repository and cop
|
||||
into the production image. Browser and native canvas renderers use the alias
|
||||
`Mina Sans`; no system sans-serif font is selected for card text. The native font
|
||||
loader fails startup if a required font cannot be registered.
|
||||
|
||||
Browser WOFF2 copies are lossless conversions of these exact TTF files using
|
||||
FontTools with Brotli; no subsetting is applied. All glyphs and Unicode mappings
|
||||
are retained (3,748 glyphs for each Noto Sans weight, 377 for each Instrument
|
||||
Serif style). Native canvas keeps using the original TTFs. CSS uses separately
|
||||
cacheable `/fonts/browser/*.woff2` URLs so font bytes do not block CSS parsing.
|
||||
|
||||
Regenerate with `uv run --with fonttools --with brotli python`:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from fontTools.ttLib import TTFont
|
||||
for path in Path("src/assets/fonts").glob("*.ttf"):
|
||||
font = TTFont(path)
|
||||
cmap, glyphs = font.getBestCmap(), font.getGlyphOrder()
|
||||
font.flavor = "woff2"
|
||||
font.save(path.with_suffix(".woff2"))
|
||||
restored = TTFont(path.with_suffix(".woff2"))
|
||||
assert restored.getBestCmap() == cmap
|
||||
assert restored.getGlyphOrder() == glyphs
|
||||
```
|
||||
|
||||
14
src/browser-fonts.test.ts
Normal file
14
src/browser-fonts.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { browserFont } from "./browser-fonts";
|
||||
test("browser fonts are external WOFF2 files with conditional caching and a fixed allowlist", async () => {
|
||||
const url = "http://localhost/fonts/browser/InstrumentSerif-Regular.woff2";
|
||||
const response = browserFont(new Request(url));
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("font/woff2");
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
expect(new TextDecoder().decode(bytes.slice(0, 4))).toBe("wOF2");
|
||||
expect(browserFont(new Request(url, { headers: { "If-None-Match": response.headers.get("ETag")! } })).status).toBe(304);
|
||||
expect(browserFont(new Request("http://localhost/fonts/browser/.env")).status).toBe(404);
|
||||
expect(browserFont(new Request("http://localhost/fonts/browser/%2e%2e%2f.env")).status).toBe(404);
|
||||
expect(await browserFont(new Request(url, { method: "HEAD" })).text()).toBe("");
|
||||
});
|
||||
18
src/browser-fonts.ts
Normal file
18
src/browser-fonts.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import path from "node:path";
|
||||
|
||||
const names = ["InstrumentSerif-Regular", "InstrumentSerif-Italic", "NotoSans-Regular", "NotoSans-Medium", "NotoSans-SemiBold"];
|
||||
const directory = process.env.NODE_ENV === "production" ? "fonts/browser" : "src/assets/fonts";
|
||||
const fonts: Map<string, { file: ReturnType<typeof Bun.file>; etag: string }> = new Map(names.map(name => {
|
||||
const file = Bun.file(path.resolve(directory, `${name}.woff2`));
|
||||
return [`${name}.woff2`, { file, etag: `"${file.size.toString(16)}-${file.lastModified.toString(16)}"` }] as const;
|
||||
}));
|
||||
|
||||
/** Explicit allowlist: font URLs never grant access to other filesystem paths. */
|
||||
export function browserFont(request: Request): Response {
|
||||
const name = new URL(request.url).pathname.slice("/fonts/browser/".length);
|
||||
const font = fonts.get(name);
|
||||
if (!font || !["GET", "HEAD"].includes(request.method)) return new Response(null, { status: 404 });
|
||||
const headers = { "Content-Type": "font/woff2", "Cache-Control": "public, max-age=3600", ETag: font.etag };
|
||||
if (request.headers.get("If-None-Match") === font.etag) return new Response(null, { status: 304, headers });
|
||||
return new Response(request.method === "HEAD" ? null : font.file, { headers });
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -107,3 +107,25 @@ 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; }
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function today(c: Context, data: ControlData = {}): Reply {
|
||||
const all = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due);
|
||||
const page = pageFor(data.page, all.length), visible = all.slice(page * 10, page * 10 + 10);
|
||||
return message(c, `**Today · ${c.service.today}** · ${text(c.service.user.timezone)}\n${all.filter(h => h.complete).length}/${all.length} complete\n\n${visible.map(h => `${h.complete ? '✓' : '○'} ${text(h.name ?? 'Habit')} — ${h.value}/${h.target} ${text(h.unit ?? '')}`).join('\n') || 'No habits due. Use /habits to create or manage your habits.'}`,
|
||||
[...(visible.length ? [select(c, 'Choose a habit', 'daily.select', { date: c.service.today, today: true }, visible.map(h => ({ id: h.habitId, name: h.name ?? 'Habit' })))] : []), ...pages(c, 'today', { date: c.service.today, page }, all.length), row(button(c, 'Refresh', 'today'))], 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;
|
||||
@@ -36,7 +36,7 @@ export function detail(c: Context, data: ControlData): Reply {
|
||||
controls.push(...pages(c, 'daily.detail', data, d.tasks.length));
|
||||
}
|
||||
}
|
||||
controls.push(row(button(c, 'Refresh', 'daily.detail', data), button(c, 'Today', 'today')));
|
||||
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);
|
||||
}
|
||||
function action(c: Context, action: string, data: ControlData): Reply | undefined {
|
||||
|
||||
@@ -46,7 +46,7 @@ export function preferences(c: Context, command: string, data: ControlData = {})
|
||||
...(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 })),
|
||||
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 {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { renderImage } from '../rendering/queue';
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
// Import the existing renderer's bundled font registration and data contract.
|
||||
import { type LiveImageData } from '../sharing/live-card-image';
|
||||
@@ -8,7 +9,10 @@ import { cardDate } from '../lib/progress-card';
|
||||
|
||||
export const progressImageSize = { width: 1200, height: 860 };
|
||||
/** Focused, chronological 30-day view; constant font/cell sizes at any habit count. */
|
||||
export async function renderProgressImage(data: LiveImageData, theme: Theme = 'light') {
|
||||
export function renderProgressImage(data: LiveImageData, theme: Theme = 'light', userId = 'unscoped') {
|
||||
return renderImage('progress', userId, data, theme, () => draw(data, theme));
|
||||
}
|
||||
async function draw(data: LiveImageData, theme: Theme) {
|
||||
const habit = data.habits[0];
|
||||
if (!habit || data.habits.length !== 1 || habit.days.length !== 30) throw new Error('Choose one habit and a 30-day range.');
|
||||
const canvas = createCanvas(progressImageSize.width, progressImageSize.height);
|
||||
@@ -62,5 +66,6 @@ export async function renderProgressImage(data: LiveImageData, theme: Theme = 'l
|
||||
text(item.label, x + 36, 772, 21, 230, palette.muted);
|
||||
});
|
||||
text('A little, every day.', 64, 822, 27, 1072, palette.muted, true);
|
||||
return new Uint8Array(await canvas.encode('png'));
|
||||
try { return new Uint8Array(await canvas.encode('png')); }
|
||||
finally { canvas.width = 1; canvas.height = 1; }
|
||||
}
|
||||
|
||||
@@ -112,3 +112,17 @@ test('snapshot quotas are shared with website operations and new controls cannot
|
||||
f.setDiscordTime('2026-09-04T12:16:00Z');
|
||||
expect((await click(preview, 'Choose public destination')).data.content).toContain('expired');
|
||||
});
|
||||
|
||||
test('a website edit during a deferred public share invalidates the prepared snapshot', async () => {
|
||||
const env = setup();
|
||||
const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201);
|
||||
const panel = await f.run({ data: { name: 'progress' } });
|
||||
await click(panel, 'Preview a habit', false, [habit.id]); const preview = await env.drain();
|
||||
await click(preview, 'Choose public destination'); const confirmation = await env.drain();
|
||||
await click(confirmation, 'Share snapshot publicly');
|
||||
const delivery = env.drain();
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
const result = await delivery;
|
||||
expect(result.data.content).toContain('changed');
|
||||
expect(env.sends).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { renderProgressImage } from './progress-image';
|
||||
import { ApiError } from '../habits/service';
|
||||
import { ApiError, HabitService } from '../habits/service';
|
||||
import { liveCardData } from '../sharing/live-card-image';
|
||||
import type { createSharingRoutes } from '../sharing/routes';
|
||||
import type { createLiveDiscord } from '../sharing/live-discord';
|
||||
@@ -11,12 +11,14 @@ 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 };
|
||||
export type DeferredMessage = { message: Message; image?: Uint8Array; imageDescription?: string };
|
||||
const fingerprint = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
function imageData(c: Context, data: ControlData) {
|
||||
const result = liveCardData(c.db, c.service.user.id, c.now);
|
||||
// Preparation happened before dispatch. Fresh read caches detect edits across deferred awaits.
|
||||
const snapshot = new HabitService(c.db, c.service.user, c.now);
|
||||
const result = liveCardData(c.db, c.service.user.id, c.now, snapshot);
|
||||
if (data.habitId) {
|
||||
const habits = c.service.list();
|
||||
const habits = snapshot.list();
|
||||
const index = habits.findIndex(h => h.id === data.habitId);
|
||||
if (index < 0) throw new ApiError(409, 'This habit changed. Run /progress again.');
|
||||
result.habits = [result.habits[index]!];
|
||||
@@ -24,7 +26,7 @@ function imageData(c: Context, data: ControlData) {
|
||||
return { ...result, avatarUrl: null };
|
||||
}
|
||||
async function render(c: Context, data: ControlData, source = imageData(c, data)) {
|
||||
return renderProgressImage(source, data.theme ?? 'light');
|
||||
return renderProgressImage(source, data.theme ?? 'light', c.service.user.id);
|
||||
}
|
||||
function output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; }
|
||||
function defer(c: Context, job: () => Promise<DeferredMessage>): Reply {
|
||||
@@ -87,7 +89,7 @@ export function progressAction(c: Context, action: string, data: ControlData): R
|
||||
const source = imageData(c, data);
|
||||
const image = await render(c, data, source);
|
||||
if (fingerprint(imageData(c, data)) !== data.expected) throw new ApiError(409, 'Progress changed during rendering. Open a fresh preview.');
|
||||
const delivery = await ops.sharing.send(c.service.user, data.deliveryId!, new File([image], 'mina-habits-progress.png', { type: 'image/png' }));
|
||||
const delivery = await ops.sharing.send(c.service.user, data.deliveryId!, new File([new Uint8Array(image)], 'mina-habits-progress.png', { type: 'image/png' }));
|
||||
return output(message(c, delivery.status === 'sent' ? `Snapshot shared.${delivery.messageUrl ? ` [Open snapshot](${delivery.messageUrl})` : ''}` : 'Discord could not confirm delivery. Check the destination before creating another snapshot; this action will not be retried.', [], false));
|
||||
}
|
||||
const state = action === 'progress.pause' ? await ops.live.pause(c.service.user.id) : await ops.live.start(c.service.user, data.theme ?? 'light');
|
||||
|
||||
@@ -15,6 +15,7 @@ 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; remindersAvailable?: boolean };
|
||||
export type DeferredDispatch = {
|
||||
@@ -25,7 +26,7 @@ export type DeferredDispatch = {
|
||||
/** 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.message.content?.slice(0, 1024) }] : [] };
|
||||
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) });
|
||||
@@ -53,15 +54,17 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
|
||||
// Help needs no account creation, habit synchronization, or saved UI state.
|
||||
if (i.type === 2 && i.data?.name === 'help') return c.json(help(!!i.guild_id, config.origin));
|
||||
let pending: (() => Promise<DeferredMessage>) | undefined;
|
||||
const reply = db.transaction((): Reply => {
|
||||
const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get();
|
||||
const userId = existing?.id ?? crypto.randomUUID();
|
||||
if (!existing) db.insert(users).values({ id: userId, discordId: profile.id, username: profile.username, globalName: profile.global_name, avatarHash: profile.avatar, createdAt: now(), updatedAt: now() }).run();
|
||||
// Resolve the account before queueing catch-up; no transaction is held across yields.
|
||||
const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get();
|
||||
const userId = existing?.id ?? crypto.randomUUID();
|
||||
if (!existing) db.insert(users).values({ id: userId, discordId: profile.id, username: profile.username, globalName: profile.global_name, avatarHash: profile.avatar, createdAt: now(), updatedAt: now() }).run();
|
||||
const receipt = db.select().from(discordReceipts).where(eq(discordReceipts.id, i.id)).get();
|
||||
if (receipt && receipt.expiresAt > now()) return c.json(receipt.response);
|
||||
const owner = publicUser(db.select().from(users).where(eq(users.id, userId)).get()!);
|
||||
const reply = await HabitService.withPrepared(db, owner, now, service => db.transaction((): Reply => {
|
||||
const receipt = db.select().from(discordReceipts).where(eq(discordReceipts.id, i.id)).get();
|
||||
if (receipt && receipt.expiresAt > now()) return receipt.response;
|
||||
const user = publicUser(db.select().from(users).where(eq(users.id, userId)).get()!);
|
||||
const controls = new ControlStore(db, userId, now()); controls.prune();
|
||||
const service = new HabitService(db, user, now()); service.sync();
|
||||
const ctx: Context = { db, service, controls, interaction: i, now: now(), origin: config.origin, remindersAvailable: config.remindersAvailable ?? false, changed: () => changed(userId), progress: dispatch.progress, defer: i.token ? job => { pending = job; } : undefined };
|
||||
let result: Reply;
|
||||
try {
|
||||
@@ -74,7 +77,7 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
|
||||
else if (i.type === 2 && i.data?.name === 'history') result = history(ctx);
|
||||
else if ([3, 5].includes(i.type)) {
|
||||
const state = controls.read(i.data?.custom_id ?? '');
|
||||
result = 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);
|
||||
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;
|
||||
@@ -82,7 +85,7 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
|
||||
}
|
||||
db.insert(discordReceipts).values({ id: i.id, userId, response: result, expiresAt: now() + 86400000 }).run();
|
||||
return result;
|
||||
});
|
||||
}));
|
||||
if (pending) {
|
||||
const work = pending;
|
||||
const job = async () => {
|
||||
|
||||
@@ -3,6 +3,9 @@ import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import { App } from "./App";
|
||||
import "../styles/globals.css";
|
||||
import { startPwa } from "./pwa/client";
|
||||
|
||||
if (process.env.NODE_ENV === "production") startPwa();
|
||||
|
||||
const container = document.getElementById("root")!;
|
||||
const root = import.meta.hot
|
||||
|
||||
85
src/habits/performance.test.ts
Normal file
85
src/habits/performance.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { fixture } from './test-fixture';
|
||||
import { HabitService } from './service';
|
||||
import { publicUser } from '../auth';
|
||||
import { users } from '../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
test('single-day reads load only the selected latest snapshot and its tasks', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const habit = await f.json('/habits', 'POST', { name: 'Tasks', method: 'tasks', tasks: [{name:'Read'}] }, 201);
|
||||
f.setTime('2027-09-04T12:00:00Z');
|
||||
await f.json('/today');
|
||||
const logs: string[] = [];
|
||||
(f.db as any).session.logger = { logQuery(query: string) { logs.push(query); } };
|
||||
const service = new HabitService(f.db, publicUser(f.db.select().from(users).where(eq(users.id,'alice')).get()!), Date.parse('2027-09-04T12:00:00Z'));
|
||||
const day = service.day(habit.id, '2027-09-04');
|
||||
expect(day.tasks).toHaveLength(1);
|
||||
expect((service as any).recordsCache.get(habit.id).size).toBe(1);
|
||||
expect((service as any).occurrencesCache.get(habit.id).size).toBe(1);
|
||||
expect(logs.find(query => query.includes('task_occurrences'))).toContain('"habit_days"."date" >=');
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('calendar summaries omit editor payloads and mutation versions cover historical changes', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const habit = await f.json('/habits', 'POST', { name: 'Tasks', method: 'tasks', tasks: [{name:'Read'}] }, 201);
|
||||
const first = (await f.json('/today')).habits[0];
|
||||
expect((await f.json('/today')).habits[0].calendarRevision).toBe(first.calendarRevision);
|
||||
f.setTime('2026-09-05T12:00:00Z');
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${first.tasks[0].taskId}`, 'PUT', {done:true});
|
||||
const changed = (await f.json('/today')).habits[0].calendarRevision;
|
||||
expect(changed).toBeGreaterThan(first.calendarRevision);
|
||||
await f.json(`/habits/${habit.id}/calendar-settings`, 'PUT', {mainColor:'#123456'});
|
||||
expect((await f.json('/today')).habits[0].calendarRevision).toBeGreaterThan(changed);
|
||||
const url = `/habits/${habit.id}/calendar?from=2026-09-04&to=2026-09-05`;
|
||||
const full = await f.json(url);
|
||||
const compact = await f.json(url+'&compact=true');
|
||||
const detail = full.days[0].habits[0];
|
||||
const {tasks, requirements, ...summary} = detail;
|
||||
expect(compact.days[0].habits[0]).toEqual(summary);
|
||||
expect(compact.data).toEqual(full.data);
|
||||
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).tasks[0].done).toBe(true);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('unchanged dashboard and calendar reads write no rows after synchronization', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const habit = await f.json('/habits', 'POST', {name:'Tasks', method:'tasks', tasks:[{name:'Read'}]}, 201);
|
||||
f.setTime('2027-09-04T12:00:00Z');
|
||||
await f.json('/today');
|
||||
const before = (f.sqlite.query('SELECT total_changes() AS n').get() as {n:number}).n;
|
||||
await f.json('/today');
|
||||
await f.json(`/habits/${habit.id}/calendar?from=2027-09-01&to=2027-09-04&compact=true`);
|
||||
expect((f.sqlite.query('SELECT total_changes() AS n').get() as {n:number}).n).toBe(before);
|
||||
} finally {f.close();}
|
||||
});
|
||||
|
||||
test('catch-up yields to other users and serializes same-user edits until fully ready', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const habit = await f.json('/habits', 'POST', {name:'Many tasks', method:'tasks', tasks:Array.from({length:50}, (_,i)=>({name:`Task ${i}`}))}, 201);
|
||||
f.setTime('2027-09-04T12:00:00Z');
|
||||
let complete = false;
|
||||
const dashboard = f.json('/today').then(value => {complete=true; return value;});
|
||||
await new Promise(resolve=>setTimeout(resolve,0));
|
||||
expect(complete).toBe(false);
|
||||
// Another user's request can complete while Alice is still catching up.
|
||||
expect((await f.request('/today','GET',undefined,'b')).status).toBe(200);
|
||||
expect(complete).toBe(false);
|
||||
// The actual date can advance while catch-up yields; readiness revalidates it.
|
||||
f.setTime('2027-09-05T12:00:00Z');
|
||||
const edit = f.json(`/habits/${habit.id}`, 'PATCH', {name:'Renamed'});
|
||||
const [today, changed] = await Promise.all([dashboard, edit]);
|
||||
expect(today.date).toBe('2027-09-05');
|
||||
expect(today.habits[0].tasks).toHaveLength(50);
|
||||
expect(changed.name).toBe('Renamed');
|
||||
const through = f.sqlite.query('SELECT materialized_through AS date FROM habits WHERE id=?').get(habit.id) as {date:string};
|
||||
expect(through.date).toBe('2027-09-05');
|
||||
const day = await f.json(`/habits/${habit.id}/days/2026-09-05`);
|
||||
expect(day.tasks.every((task:any)=>task.expiredAt!==null && task.closedAt!==null)).toBe(true);
|
||||
} finally {f.close();}
|
||||
});
|
||||
@@ -20,7 +20,7 @@ function date(c: Context<Env>) {
|
||||
return result.data;
|
||||
}
|
||||
function range(c: Context<Env>) {
|
||||
const result = z.object({ from: dateSchema, to: dateSchema }).strict().safeParse(c.req.query());
|
||||
const result = z.object({ from: dateSchema, to: dateSchema, compact: z.enum(['true', 'false']).optional() }).strict().safeParse(c.req.query());
|
||||
if (!result.success || result.data.from > result.data.to || dayNumber(result.data.to) - dayNumber(result.data.from) > 1829) throw new ApiError(422, 'Supply from and to dates in order, spanning at most 1830 days');
|
||||
return result.data;
|
||||
}
|
||||
@@ -41,8 +41,9 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
|
||||
try { payload.value = await c.req.json(); } catch { payload.malformed = true; }
|
||||
}
|
||||
c.set('payload', payload);
|
||||
const service = new HabitService(db, c.get('user'), now());
|
||||
service.sync(); c.set('service', service); await next();
|
||||
await HabitService.withPrepared(db, c.get('user'), now, async service => {
|
||||
c.set('service', service); await next();
|
||||
});
|
||||
}); if (response) c.res = response; };
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) {
|
||||
const response = await origin(c, proceed); if (response) c.res = response;
|
||||
@@ -80,7 +81,7 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
|
||||
app.get('/habits/:id/calendar-settings', c => c.json(service(c).settings(id(c))));
|
||||
app.put('/habits/:id/calendar-settings', c => c.json(service(c).saveSettings(id(c), body(c, calendarSettingsSchema))));
|
||||
app.get('/habits/:id/calendar', c => {
|
||||
const { from, to } = range(c); return c.json(service(c).calendar([id(c)], from, to, service(c).settings(id(c)), false));
|
||||
const { from, to, compact } = range(c); return c.json(service(c).calendar([id(c)], from, to, service(c).settings(id(c)), false, compact === 'true'));
|
||||
});
|
||||
app.get('/today', c => {
|
||||
const s = service(c); const habits = s.list().map(h => s.day(h.id, s.today));
|
||||
@@ -100,7 +101,7 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
|
||||
});
|
||||
app.delete('/charts/:id', c => { service(c).deleteChart(id(c)); return c.body(null, 204); });
|
||||
app.get('/charts/:id/calendar', c => {
|
||||
const chart = service(c).chart(id(c)); const { from, to } = range(c); return c.json(service(c).calendar(chart.habitIds, from, to, chart.settings, true));
|
||||
const chart = service(c).chart(id(c)); const { from, to, compact } = range(c); return c.json(service(c).calendar(chart.habitIds, from, to, chart.settings, true, compact === 'true'));
|
||||
});
|
||||
app.get('/charts/:id/days/:date', c => {
|
||||
const chart = service(c).chart(id(c)); const day = date(c); return c.json(service(c).calendar(chart.habitIds, day, day, chart.settings, true).days[0]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, inArray, lte, sql } from 'drizzle-orm';
|
||||
import { and, asc, desc, eq, inArray, lte, gte, gt, lt, or, isNull, sql } from 'drizzle-orm';
|
||||
import type { AppDatabase } from '../auth';
|
||||
import type { PublicUser } from '../shared/user';
|
||||
import { habits, habitRevisions, habitDays, taskOccurrences, progressEvents, habitCalendarSettings, combinedCharts, users } from '../db/schema';
|
||||
@@ -13,17 +13,59 @@ export type Habit = typeof habits.$inferSelect;
|
||||
export type Revision = typeof habitRevisions.$inferSelect;
|
||||
export type HabitDay = typeof habitDays.$inferSelect;
|
||||
|
||||
// Each database has its own per-user readiness queue. No transaction spans a yield.
|
||||
const preparationQueues = new WeakMap<AppDatabase, Map<string, Promise<void>>>();
|
||||
export class HabitService {
|
||||
static async withPrepared<T>(db: AppDatabase, user: PublicUser, now: () => number, run: (service: HabitService) => T | Promise<T>): Promise<T> {
|
||||
let queues = preparationQueues.get(db);
|
||||
if (!queues) { queues = new Map(); preparationQueues.set(db, queues); }
|
||||
const previous = queues.get(user.id);
|
||||
let release!: () => void;
|
||||
const ticket = new Promise<void>(resolve => { release = resolve; });
|
||||
queues.set(user.id, ticket);
|
||||
if (previous) await previous;
|
||||
try {
|
||||
let sliceStarted = performance.now();
|
||||
while (true) {
|
||||
// Re-read timezone, date and owned records after every scheduling yield.
|
||||
const service = new HabitService(db, user, now());
|
||||
const habit = service.pending()[0];
|
||||
if (!habit) {
|
||||
service.sync();
|
||||
return await run(service);
|
||||
}
|
||||
const start = habit.materializedThrough ? addDays(habit.materializedThrough, 1) : habit.createdDate;
|
||||
const through = [addDays(start, 7), service.today].sort()[0]!;
|
||||
db.transaction(() => service.materialize(habit, through));
|
||||
if (performance.now() - sliceStarted >= 8) {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
sliceStarted = performance.now();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
if (queues.get(user.id) === ticket) queues.delete(user.id);
|
||||
}
|
||||
}
|
||||
/** Ready read snapshot. Use withPrepared for read-modify-write handlers. */
|
||||
static prepare(db: AppDatabase, user: PublicUser, now: () => number) {
|
||||
return HabitService.withPrepared(db, user, now, service => service);
|
||||
}
|
||||
|
||||
readonly today: string;
|
||||
private ownedCache = new Map<string, Habit>();
|
||||
private revisionCache = new Map<string, Revision[]>();
|
||||
private deadlineCache = new Map<string, number>();
|
||||
private ranges = new Map<string, { from: string; to: string }>();
|
||||
private revisionRanges = new Map<string, { from: string; to: string }>();
|
||||
private recordsCache = new Map<string, Map<string, HabitDay>>();
|
||||
private occurrencesCache = new Map<string, Map<number, (typeof taskOccurrences.$inferSelect)[]>>();
|
||||
private countCache = new Map<string, Map<string, { value: number; carriedFrom: string | null }>>();
|
||||
constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) {
|
||||
// A different request may update the timezone while this request's body streams in.
|
||||
const timezone = db.select({ timezone: users.timezone }).from(users).where(eq(users.id, user.id)).get()!.timezone;
|
||||
const currentUser = db.select({ timezone: users.timezone }).from(users).where(eq(users.id, user.id)).get();
|
||||
if (!currentUser) throw missing();
|
||||
const timezone = currentUser.timezone;
|
||||
this.user = { ...user, timezone };
|
||||
// Travel west must never reopen or rewrite earlier requirements.
|
||||
const latest = db.select({ date: habits.materializedThrough }).from(habits).where(eq(habits.userId, user.id)).orderBy(desc(habits.materializedThrough)).get()?.date;
|
||||
@@ -35,10 +77,27 @@ export class HabitService {
|
||||
if (!habit) throw missing();
|
||||
this.ownedCache.set(id, habit); return habit;
|
||||
}
|
||||
private loadRevisions(id: string, from: string, to: string) {
|
||||
const first = this.db.select().from(habitRevisions).where(and(eq(habitRevisions.habitId, id), lte(habitRevisions.effectiveDate, from))).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).get();
|
||||
const later = from === to ? [] : this.db.select().from(habitRevisions).where(and(eq(habitRevisions.habitId, id), gt(habitRevisions.effectiveDate, from), lte(habitRevisions.effectiveDate, to))).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).all();
|
||||
this.revisionCache.set(id, first ? [...later, first] : later);
|
||||
this.revisionRanges.set(id, { from, to });
|
||||
}
|
||||
revision(id: string, date = this.today): Revision | undefined {
|
||||
let revisions = this.revisionCache.get(id);
|
||||
if (!revisions) { revisions = this.db.select().from(habitRevisions).where(eq(habitRevisions.habitId, id)).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).all(); this.revisionCache.set(id, revisions); }
|
||||
return revisions.find(r => r.effectiveDate <= date);
|
||||
const range = this.revisionRanges.get(id);
|
||||
if (!range || date < range.from || date > range.to) this.loadRevisions(id, date, date);
|
||||
return this.revisionCache.get(id)!.find(r => r.effectiveDate <= date);
|
||||
}
|
||||
private range(id: string, from: string, to = from) {
|
||||
const old = this.ranges.get(id);
|
||||
if (old && from >= old.from && to <= old.to) return;
|
||||
this.ranges.set(id, { from, to });
|
||||
this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.countCache.delete(id);
|
||||
this.loadRevisions(id, from, to);
|
||||
}
|
||||
private changed(id: string) {
|
||||
this.db.update(habits).set({ calendarRevision: sql`${habits.calendarRevision} + 1` }).where(eq(habits.id, id)).run();
|
||||
this.ownedCache.delete(id);
|
||||
}
|
||||
current(id: string) {
|
||||
const habit = this.owned(id); const revision = this.revision(id)!;
|
||||
@@ -47,11 +106,11 @@ export class HabitService {
|
||||
? [{ code: 'no_tasks', taskId: null, message: 'Add a task to make this habit due.' }]
|
||||
: config.tasks.filter(task => !schedulesOverlap(config.schedule, task.schedule))
|
||||
.map(task => ({ code: 'task_never_due', taskId: task.id, message: `Task \"${task.name}\" never coincides with the habit schedule.` }));
|
||||
return { id: habit.id, ...config, warnings, createdDate: habit.createdDate, createdAt: habit.createdAt, revisionId: revision.id, effectiveDate: revision.effectiveDate };
|
||||
return { id: habit.id, calendarRevision: habit.calendarRevision, ...config, warnings, createdDate: habit.createdDate, createdAt: habit.createdAt, revisionId: revision.id, effectiveDate: revision.effectiveDate };
|
||||
}
|
||||
list(includeArchived = false) {
|
||||
return this.db.select().from(habits).where(eq(habits.userId, this.user.id)).orderBy(asc(habits.createdAt), asc(habits.id)).all()
|
||||
.map(h => this.current(h.id)).filter(h => includeArchived || !h.archived);
|
||||
.map(h => { this.ownedCache.set(h.id, h); return this.current(h.id); }).filter(h => includeArchived || !h.archived);
|
||||
}
|
||||
create(input: ReturnType<typeof habitInput.parse>) {
|
||||
const id = crypto.randomUUID();
|
||||
@@ -114,7 +173,8 @@ export class HabitService {
|
||||
this.owned(id);
|
||||
this.db.transaction(() => {
|
||||
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
|
||||
this.materialize(this.owned(id));
|
||||
this.materialize(this.owned(id), this.today, true);
|
||||
this.changed(id);
|
||||
});
|
||||
return this.current(id);
|
||||
}
|
||||
@@ -137,19 +197,25 @@ export class HabitService {
|
||||
}
|
||||
sync() {
|
||||
this.db.transaction(() => {
|
||||
for (const habit of this.db.select().from(habits).where(eq(habits.userId, this.user.id)).all()) this.materialize(habit);
|
||||
// Record the original deadline even after downtime. Only active day snapshots expire.
|
||||
for (const habit of this.pending()) this.materialize(habit);
|
||||
// Start with the small partial index of open occurrences rather than
|
||||
// revisiting every historical snapshot on every unchanged request.
|
||||
this.db.run(sql`UPDATE task_occurrences SET
|
||||
expired_at = CASE WHEN done = 0 THEN COALESCE(expired_at, (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)) ELSE expired_at END,
|
||||
closed_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)
|
||||
WHERE closed_at IS NULL AND day_id IN (
|
||||
SELECT d.id FROM habit_days d JOIN habits h ON h.id = d.habit_id
|
||||
WHERE h.user_id = ${this.user.id} AND d.ends_at <= ${this.now}
|
||||
WHERE id IN (
|
||||
SELECT o.id FROM task_occurrences o INDEXED BY task_occurrences_open_idx
|
||||
CROSS JOIN habit_days d ON d.id = o.day_id
|
||||
CROSS JOIN habits h ON h.id = d.habit_id
|
||||
WHERE o.closed_at IS NULL AND h.user_id = ${this.user.id} AND d.ends_at <= ${this.now}
|
||||
AND d.id = (SELECT MAX(d2.id) FROM habit_days d2 WHERE d2.habit_id = d.habit_id AND d2.date = d.date))`);
|
||||
});
|
||||
}
|
||||
private pending() {
|
||||
return this.db.select().from(habits).where(and(eq(habits.userId, this.user.id), or(isNull(habits.materializedThrough), lt(habits.materializedThrough, this.today)))).all();
|
||||
}
|
||||
private invalidate(id: string) {
|
||||
this.countCache.delete(id); this.revisionCache.delete(id); this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.ownedCache.delete(id);
|
||||
this.ranges.delete(id); this.revisionRanges.delete(id); this.countCache.delete(id); this.revisionCache.delete(id); this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.ownedCache.delete(id);
|
||||
}
|
||||
private deadline(date: string) {
|
||||
let value = this.deadlineCache.get(date);
|
||||
@@ -160,7 +226,7 @@ export class HabitService {
|
||||
let records = this.recordsCache.get(id);
|
||||
if (!records) {
|
||||
records = new Map();
|
||||
for (const day of this.db.select().from(habitDays).where(eq(habitDays.habitId, id)).orderBy(asc(habitDays.id)).all()) records.set(day.date, day);
|
||||
for (const day of this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), gte(habitDays.date, this.ranges.get(id)!.from), lte(habitDays.date, this.ranges.get(id)!.to), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`)).all()) records.set(day.date, day);
|
||||
this.recordsCache.set(id, records);
|
||||
}
|
||||
return records;
|
||||
@@ -169,15 +235,17 @@ export class HabitService {
|
||||
let occurrences = this.occurrencesCache.get(id);
|
||||
if (!occurrences) {
|
||||
occurrences = new Map();
|
||||
const rows = this.db.select({ occurrence: taskOccurrences }).from(taskOccurrences).innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id)).where(eq(habitDays.habitId, id)).orderBy(asc(taskOccurrences.id)).all();
|
||||
const rows = this.db.select({ occurrence: taskOccurrences }).from(taskOccurrences).innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id)).where(and(eq(habitDays.habitId, id), gte(habitDays.date, this.ranges.get(id)!.from), lte(habitDays.date, this.ranges.get(id)!.to), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`)).orderBy(asc(taskOccurrences.id)).all();
|
||||
for (const { occurrence } of rows) { const entries = occurrences.get(occurrence.dayId) ?? []; entries.push(occurrence); occurrences.set(occurrence.dayId, entries); }
|
||||
this.occurrencesCache.set(id, occurrences);
|
||||
}
|
||||
return occurrences;
|
||||
}
|
||||
private materialize(habit: Habit) {
|
||||
let date = habit.materializedThrough ?? habit.createdDate;
|
||||
while (date <= this.today) {
|
||||
private materialize(habit: Habit, through = this.today, force = false) {
|
||||
let date = force ? through : habit.materializedThrough ? addDays(habit.materializedThrough, 1) : habit.createdDate;
|
||||
if (date > through) return;
|
||||
this.loadRevisions(habit.id, date, through);
|
||||
while (date <= through) {
|
||||
const revision = this.revision(habit.id, date)!;
|
||||
const existing = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get();
|
||||
if (!existing || existing.revisionId !== revision.id) {
|
||||
@@ -193,23 +261,43 @@ export class HabitService {
|
||||
.innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id))
|
||||
.where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date)))
|
||||
.orderBy(desc(habitDays.id)).all().map(row => row.occurrence) : [];
|
||||
for (const task of revision.config.tasks.filter(t => scheduled(t.schedule, date))) {
|
||||
const occurrences = revision.config.tasks.filter(t => scheduled(t.schedule, date)).map(task => {
|
||||
const old = previous.find(t => t.taskId === task.id);
|
||||
this.db.insert(taskOccurrences).values({ id: crypto.randomUUID(), dayId: day.id, taskId: task.id, name: task.name,
|
||||
done: old?.done ?? false, expiredAt: old ? old.expiredAt : day.endsAt <= this.now ? day.endsAt : null, closedAt: old?.closedAt ?? (day.endsAt <= this.now ? day.endsAt : null), updatedAt: old?.updatedAt ?? this.now }).run();
|
||||
}
|
||||
return { id: crypto.randomUUID(), dayId: day.id, taskId: task.id, name: task.name,
|
||||
done: old?.done ?? false, expiredAt: old ? old.expiredAt : day.endsAt <= this.now ? day.endsAt : null, closedAt: old?.closedAt ?? (day.endsAt <= this.now ? day.endsAt : null), updatedAt: old?.updatedAt ?? this.now };
|
||||
});
|
||||
if (occurrences.length) this.db.insert(taskOccurrences).values(occurrences).run();
|
||||
}
|
||||
}
|
||||
date = addDays(date, 1);
|
||||
}
|
||||
this.db.update(habits).set({ materializedThrough: this.today }).where(eq(habits.id, habit.id)).run();
|
||||
this.db.update(habits).set({ materializedThrough: through }).where(eq(habits.id, habit.id)).run();
|
||||
}
|
||||
private counts(id: string) {
|
||||
const cached = this.countCache.get(id); if (cached) return cached;
|
||||
const rows = this.db.select({ day: habitDays, config: habitRevisions.config }).from(habitDays)
|
||||
.innerJoin(habitRevisions, eq(habitDays.revisionId, habitRevisions.id))
|
||||
.where(and(eq(habitDays.habitId, id), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`))
|
||||
.orderBy(asc(habitDays.date)).all();
|
||||
const range = this.ranges.get(id)!;
|
||||
const latest = sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`;
|
||||
const select = () => this.db.select({ day: habitDays, config: habitRevisions.config }).from(habitDays).innerJoin(habitRevisions, eq(habitDays.revisionId, habitRevisions.id));
|
||||
const rows = select().where(and(eq(habitDays.habitId, id), gte(habitDays.date, range.from), lte(habitDays.date, range.to), latest)).orderBy(asc(habitDays.date)).all();
|
||||
// Only carry-enabled ranges need a prefix. Walk backwards in bounded pages
|
||||
// until a logged value or requirements reset establishes the starting state.
|
||||
const first = rows.find(({day, config}) => config.method !== 'count' || config.archived || scheduled(config.schedule, day.date));
|
||||
if (first?.config.method === 'count' && first.config.carryPartialProgress && !first.day.countSet) {
|
||||
let before = range.from;
|
||||
const prefix: typeof rows = [];
|
||||
let found = false;
|
||||
while (!found) {
|
||||
const page = select().where(and(eq(habitDays.habitId, id), lt(habitDays.date, before), latest)).orderBy(desc(habitDays.date)).limit(64).all();
|
||||
if (!page.length) break;
|
||||
for (const entry of page) {
|
||||
prefix.push(entry);
|
||||
const { day, config } = entry;
|
||||
if (config.method !== 'count' || config.archived || (scheduled(config.schedule, day.date) && (day.countSet || !config.carryPartialProgress))) { found = true; break; }
|
||||
}
|
||||
before = page.at(-1)!.day.date;
|
||||
}
|
||||
rows.unshift(...prefix.reverse());
|
||||
}
|
||||
const result = new Map<string, { value: number; carriedFrom: string | null }>();
|
||||
let carry = 0; let source: string | null = null;
|
||||
for (const { day, config } of rows) {
|
||||
@@ -223,6 +311,7 @@ export class HabitService {
|
||||
}
|
||||
day(id: string, date: string) {
|
||||
this.owned(id);
|
||||
this.range(id, date);
|
||||
const revision = this.revision(id, date);
|
||||
const record = this.records(id).get(date);
|
||||
const config = revision?.config;
|
||||
@@ -235,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,7 +410,10 @@ 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.
|
||||
|
||||
@@ -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,6 +17,7 @@ if (process.env.NODE_ENV !== "production") {
|
||||
}
|
||||
|
||||
const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined;
|
||||
await backups?.ready;
|
||||
if (import.meta.hot) import.meta.hot.dispose(() => backups?.stop());
|
||||
|
||||
const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin, commandsAvailable: /^[a-f\d]{64}$/i.test(process.env.DISCORD_PUBLIC_KEY ?? "") && Boolean(readAuthConfig().clientId) }) : undefined;
|
||||
@@ -28,6 +31,10 @@ const server = Bun.serve({
|
||||
hostname: process.env.HOST ?? "127.0.0.1",
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
routes: {
|
||||
"/manifest.webmanifest": pwaAsset,
|
||||
"/sw.js": pwaAsset,
|
||||
"/pwa/*": pwaAsset,
|
||||
"/fonts/browser/*": browserFont,
|
||||
"/api": req => app.fetch(req),
|
||||
"/api/*": req => app.fetch(req),
|
||||
"/*": index,
|
||||
|
||||
@@ -14,6 +14,8 @@ export async function habitRequest<T>(
|
||||
path: string,
|
||||
options?: RequestInit,
|
||||
): Promise<T> {
|
||||
if (typeof navigator !== "undefined" && navigator.onLine === false)
|
||||
throw new Error("You’re offline. Reconnect and try again. Changes have not been saved or queued.");
|
||||
const response = await fetch(`/api${path}`, options);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401)
|
||||
@@ -46,3 +48,15 @@ export function formatTrackingDate(date: string) {
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
/** Preserve unchanged row references when a background refresh returns new JSON objects. */
|
||||
export function reconcileToday(current: TodayResponse | null, next: TodayResponse): TodayResponse {
|
||||
if (!current || current.date !== next.date || current.timezone !== next.timezone) return next;
|
||||
const previous = new Map(current.habits.map(habit => [habit.habitId, habit]));
|
||||
const habits = next.habits.map(habit => {
|
||||
const old = previous.get(habit.habitId);
|
||||
return old && JSON.stringify(old) === JSON.stringify(habit) ? old : habit;
|
||||
});
|
||||
if (current.due === next.due && current.completed === next.completed && habits.length === current.habits.length && habits.every((habit, index) => habit === current.habits[index])) return current;
|
||||
return { ...next, habits };
|
||||
}
|
||||
|
||||
@@ -1,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 }[];
|
||||
};
|
||||
38
src/pwa/manifest.webmanifest
Normal file
38
src/pwa/manifest.webmanifest
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Mina Habits",
|
||||
"short_name": "Mina Habits",
|
||||
"description": "A quiet place to track your habits and see your progress.",
|
||||
"lang": "en",
|
||||
"dir": "ltr",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff",
|
||||
"categories": [
|
||||
"productivity",
|
||||
"lifestyle"
|
||||
],
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "/pwa/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/pwa/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/pwa/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
src/pwa/public/icon-192.png
Normal file
BIN
src/pwa/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
src/pwa/public/icon-512.png
Normal file
BIN
src/pwa/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
src/pwa/public/icon-maskable-512.png
Normal file
BIN
src/pwa/public/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
66
src/pwa/sw.js
Normal file
66
src/pwa/sw.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/* Build injects a content-derived version and an explicit public-asset allowlist. */
|
||||
const CONFIG = JSON.parse("__PWA_CONFIG__");
|
||||
const PREFIX = "mina-habits-static-";
|
||||
const CACHE = PREFIX + CONFIG.version;
|
||||
const ASSETS = new Set(CONFIG.assets);
|
||||
|
||||
self.addEventListener("install", event => {
|
||||
event.waitUntil((async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
// addAll is atomic: a failed download cannot activate a partial release.
|
||||
await cache.addAll(CONFIG.assets.map(url => new Request(url, { cache: "reload", credentials: "omit" })));
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("activate", event => {
|
||||
event.waitUntil((async () => {
|
||||
// A user can accept an update while other windows still hold older code.
|
||||
// Preserve their lazy chunks until a later activation with no open windows.
|
||||
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
if (windows.length === 0) {
|
||||
await Promise.all((await caches.keys()).filter(key => key.startsWith(PREFIX) && key !== CACHE).map(key => caches.delete(key)));
|
||||
}
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("message", event => {
|
||||
if (event.data?.type === "ACTIVATE_UPDATE") event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
|
||||
async function cachedAsset(request) {
|
||||
const current = await caches.open(CACHE);
|
||||
const cached = await current.match(request);
|
||||
if (cached) return cached;
|
||||
// Old windows can still request fingerprinted chunks after an accepted update.
|
||||
for (const key of await caches.keys()) {
|
||||
if (key.startsWith(PREFIX) && key !== CACHE) {
|
||||
const response = await (await caches.open(key)).match(request);
|
||||
if (response) return response;
|
||||
}
|
||||
}
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
async function navigate(request) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 4000);
|
||||
try {
|
||||
const response = await fetch(request, { signal: controller.signal });
|
||||
if (response.ok) return response;
|
||||
} catch { /* Unreachable server or offline: use the public shell. */ }
|
||||
finally { clearTimeout(timeout); }
|
||||
return (await (await caches.open(CACHE)).match(CONFIG.shell)) || Response.error();
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", event => {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
// In particular: no account data, OAuth callbacks, exports, writes or third-party responses.
|
||||
if (request.method !== "GET" || url.origin !== self.location.origin || /^\/api(?:\/|$)/.test(url.pathname)) return;
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(navigate(request));
|
||||
} else if (ASSETS.has(url.pathname) || /^\/chunk-[\w-]+\.js$/.test(url.pathname)) {
|
||||
event.respondWith(cachedAsset(request));
|
||||
}
|
||||
});
|
||||
99
src/pwa/sw.test.ts
Normal file
99
src/pwa/sw.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { runInNewContext } from "node:vm";
|
||||
|
||||
const template = await Bun.file(new URL("./sw.js", import.meta.url)).text();
|
||||
const origin = "https://mina.example";
|
||||
const cacheName = "mina-habits-static-test";
|
||||
|
||||
function worker({ offline = false, windows = 0, failInstall = false } = {}) {
|
||||
const events = new Map<string, (event: any) => void>();
|
||||
const stores = new Map<string, Map<string, Response>>([
|
||||
[cacheName, new Map([[`${origin}/pwa/shell-test.html`, new Response("offline shell")]])],
|
||||
["mina-habits-static-old", new Map([[`${origin}/chunk-old.js`, new Response("old chunk")]])],
|
||||
["unrelated-cache", new Map()],
|
||||
]);
|
||||
const deleted: string[] = [];
|
||||
const fetched: string[] = [];
|
||||
let skipped = false;
|
||||
let claimed = false;
|
||||
let installed: Request[] = [];
|
||||
const requestOptions: RequestInit[] = [];
|
||||
const url = (input: Request | string) => new URL(typeof input === "string" ? input : input.url, origin).href;
|
||||
const config = { version: "test", shell: "/pwa/shell-test.html", assets: ["/chunk-new.js", "/pwa/shell-test.html"] };
|
||||
runInNewContext(template.replace('"__PWA_CONFIG__"', JSON.stringify(JSON.stringify(config))), {
|
||||
self: {
|
||||
location: { origin }, addEventListener: (type: string, handler: (event: any) => void) => events.set(type, handler),
|
||||
skipWaiting: async () => { skipped = true; },
|
||||
clients: { matchAll: async () => Array.from({ length: windows }, () => ({})), claim: async () => { claimed = true; } },
|
||||
},
|
||||
caches: {
|
||||
open: async (name: string) => ({
|
||||
match: async (request: Request | string) => stores.get(name)?.get(url(request))?.clone(),
|
||||
addAll: async (requests: Request[]) => { if (failInstall) throw new Error("download failed"); installed = requests; },
|
||||
}),
|
||||
keys: async () => [...stores.keys()],
|
||||
delete: async (name: string) => { deleted.push(name); return stores.delete(name); },
|
||||
},
|
||||
fetch: async (request: Request) => { fetched.push(request.url); if (offline) throw new TypeError("offline"); return new Response("network"); },
|
||||
Request: class extends Request { constructor(input: string, init?: RequestInit) { super(url(input), init); requestOptions.push(init ?? {}); } },
|
||||
Response, Set, URL, AbortController, setTimeout, clearTimeout,
|
||||
});
|
||||
async function lifecycle(type: string, data?: unknown) {
|
||||
let promise: Promise<unknown> | undefined;
|
||||
events.get(type)!({ data, waitUntil: (value: Promise<unknown>) => { promise = value; } });
|
||||
await promise;
|
||||
}
|
||||
function request(pathname: string, method = "GET", mode = "cors") {
|
||||
let response: Promise<Response> | undefined;
|
||||
events.get("fetch")!({ request: { url: new URL(pathname, origin).href, method, mode }, respondWith: (value: Promise<Response>) => { response = value; } });
|
||||
return response;
|
||||
}
|
||||
return { request, lifecycle, deleted, fetched, requestOptions, get skipped() { return skipped; }, get claimed() { return claimed; }, get installed() { return installed; } };
|
||||
}
|
||||
|
||||
test("service worker excludes private API and OAuth navigations, writes and other origins", () => {
|
||||
const sw = worker();
|
||||
for (const route of ["/api", "/api/me", "/api/account/export", "/api/auth/discord/callback?code=private"]) {
|
||||
expect(sw.request(route, "GET", "navigate")).toBeUndefined();
|
||||
}
|
||||
expect(sw.request("/chunk-new.js", "POST")).toBeUndefined();
|
||||
expect(sw.request("https://discord.com/api/me")).toBeUndefined();
|
||||
expect(sw.request("/private.json")).toBeUndefined();
|
||||
expect(sw.request("/index.js")).toBeUndefined();
|
||||
expect(sw.request("/chunk-new.js.map")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("network navigation is fresh while offline navigation uses only the public shell", async () => {
|
||||
expect(await (await worker().request("/", "GET", "navigate"))!.text()).toBe("network");
|
||||
const sw = worker({ offline: true });
|
||||
expect(await (await sw.request("/a/deep/link", "GET", "navigate"))!.text()).toBe("offline shell");
|
||||
});
|
||||
|
||||
test("older windows can load their cached lazy chunks after an update", async () => {
|
||||
const sw = worker({ offline: true });
|
||||
expect(await (await sw.request("/chunk-old.js"))!.text()).toBe("old chunk");
|
||||
expect(sw.fetched).toEqual([]);
|
||||
});
|
||||
|
||||
test("install excludes credentials and waits for every public asset without forcing activation", async () => {
|
||||
const sw = worker();
|
||||
await sw.lifecycle("install");
|
||||
expect(sw.installed).toHaveLength(2);
|
||||
expect(sw.requestOptions.every(options => options.credentials === "omit" && options.cache === "reload")).toBe(true);
|
||||
expect(sw.skipped).toBe(false);
|
||||
await expect(worker({ failInstall: true }).lifecycle("install")).rejects.toThrow("download failed");
|
||||
});
|
||||
|
||||
test("activation preserves old windows and cleans only its own obsolete caches when windows close", async () => {
|
||||
const occupied = worker({ windows: 2 });
|
||||
await occupied.lifecycle("activate");
|
||||
expect(occupied.deleted).toEqual([]);
|
||||
expect(occupied.claimed).toBe(true);
|
||||
const empty = worker();
|
||||
await empty.lifecycle("activate");
|
||||
expect(empty.deleted).toEqual(["mina-habits-static-old"]);
|
||||
await empty.lifecycle("message", { type: "unrelated" });
|
||||
expect(empty.skipped).toBe(false);
|
||||
await empty.lifecycle("message", { type: "ACTIVATE_UPDATE" });
|
||||
expect(empty.skipped).toBe(true);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { expect, test, spyOn } from "bun:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { fixture } from "../habits/test-fixture";
|
||||
import { reminderDeliveries, reminderSettings } from "../db/schema";
|
||||
@@ -213,3 +213,55 @@ test("messages include names only for the applicable reminders in the delivery b
|
||||
expect(JSON.parse(sent[1]!).allowed_mentions.parse).toEqual([]);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
|
||||
test('delivered slots skip habit evaluation while later slots and retries remain eligible', async () => {
|
||||
const { f } = await setup(); const sent: string[] = [];
|
||||
const { HabitService } = await import('../habits/service');
|
||||
let sync: ReturnType<typeof spyOn> | undefined;
|
||||
try {
|
||||
f.db.update(reminderSettings).set({ quietStart: '00:00', quietEnd: '00:00', reminders: [
|
||||
{ type: 'date', date: '2026-09-04', time: '10:00' }, { type: 'date', date: '2026-09-04', time: '12:00' },
|
||||
] }).run();
|
||||
const tick = (clock: string) => sendDueReminders(f.db, config, transport(sent), () => Date.parse(`2026-09-04T${clock}:00Z`));
|
||||
await tick('08:00'); expect(sent).toHaveLength(1);
|
||||
sync = spyOn(HabitService.prototype, 'sync');
|
||||
for (let i = 0; i < 5; i++) await tick('08:30');
|
||||
expect(sync).not.toHaveBeenCalled(); expect(sent).toHaveLength(1);
|
||||
await tick('10:00'); expect(sync).toHaveBeenCalled(); expect(sent).toHaveLength(2);
|
||||
} finally { sync?.mockRestore(); f.close(); }
|
||||
});
|
||||
|
||||
test('reminder catch-up yields and rechecks opt-out before claiming or sending', async () => {
|
||||
const f = fixture(); let requests = 0;
|
||||
try {
|
||||
await f.json('/habits', 'POST', { name: 'Backlog', method: 'tasks', tasks: Array.from({ length: 50 }, (_, i) => ({ name: `Task ${i}` })) }, 201);
|
||||
f.db.insert(reminderSettings).values({ userId: 'alice', ...defaultReminder, enabled: true, updatedAt: 0 }).run();
|
||||
const work = sendDueReminders(f.db, config, async () => { requests++; throw new Error('No sends expected'); }, () => Date.parse('2027-09-04T18:00:00Z'));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
f.db.update(reminderSettings).set({ enabled: false }).run();
|
||||
await work;
|
||||
expect(requests).toBe(0);
|
||||
expect(f.db.select().from(reminderDeliveries).all()).toHaveLength(0);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('an opt-out queued at readiness completion prevents the final reminder POST', async () => {
|
||||
const { f } = await setup(); let posts = 0, evaluations = 0;
|
||||
const { HabitService } = await import('../habits/service');
|
||||
const original = HabitService.withPrepared;
|
||||
const prepare = spyOn(HabitService, 'withPrepared').mockImplementation((db, user, now, run) => original(db, user, now, service => {
|
||||
const result = run(service);
|
||||
if (++evaluations === 2) queueMicrotask(() => f.db.update(reminderSettings).set({ enabled: false }).run());
|
||||
return result;
|
||||
}));
|
||||
try {
|
||||
await sendDueReminders(f.db, config, async url => {
|
||||
if (!url.endsWith('/users/@me/channels')) posts++;
|
||||
return Response.json({ id: '223456789012345678' });
|
||||
}, () => Date.parse('2026-09-04T18:00:00Z'));
|
||||
expect(evaluations).toBe(2);
|
||||
expect(posts).toBe(0);
|
||||
expect(f.db.select().from(reminderDeliveries).get()?.status).toBe('skipped');
|
||||
} finally { prepare.mockRestore(); f.close(); }
|
||||
});
|
||||
|
||||
@@ -22,22 +22,42 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig,
|
||||
// Retain recent delivery history and deduplication records without unbounded growth.
|
||||
db.delete(reminderDeliveries).where(lt(reminderDeliveries.updatedAt, now() - 90 * 86400000)).run();
|
||||
for (const { user } of rows) {
|
||||
function due() {
|
||||
function eligible(checkDeliveries = false) {
|
||||
const currentUser = db.select().from(users).where(eq(users.id, user.id)).get();
|
||||
const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get();
|
||||
if (!currentUser || !prefs?.enabled) return null;
|
||||
const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone);
|
||||
if (inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null;
|
||||
const times = timesForDate(prefs, localDate(timestamp, currentUser.timezone)).filter(time => time <= clock);
|
||||
const date = localDate(timestamp, currentUser.timezone);
|
||||
const times = timesForDate(prefs, date).filter(time => time <= clock);
|
||||
if (!times.length) return null;
|
||||
const service = new HabitService(db, { id: currentUser.id, discordId: currentUser.discordId, username: currentUser.username, displayName: currentUser.globalName ?? currentUser.username, avatarUrl: null, timezone: currentUser.timezone }, timestamp);
|
||||
service.sync();
|
||||
// Avoid sending an artificial "today" after a backwards timezone change.
|
||||
if (service.today !== localDate(timestamp, currentUser.timezone)) return null;
|
||||
const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length;
|
||||
return remaining ? { date: service.today, remaining, timezone: currentUser.timezone, times, entries: entriesForDate(prefs, service.today) } : null;
|
||||
if (checkDeliveries) {
|
||||
const previous = db.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, date))).all();
|
||||
// This is only a cheap eligibility hint. The transaction below still owns claims.
|
||||
// Let stale pending records reach its recovery step before skipping the user.
|
||||
const stalePending = previous.some(row => row.status === 'pending' && row.updatedAt < timestamp - 60000);
|
||||
if (!stalePending) {
|
||||
const legacy = previous.find(row => row.slot === '*');
|
||||
const claimable = (prior: typeof previous[number] | undefined) => !prior || (prior.status === 'deferred' && (prior.retryAt ?? 0) <= timestamp);
|
||||
if (previous.some(row => row.status === 'pending') || (legacy ? !claimable(legacy) : !times.some(slot => claimable(previous.find(row => row.slot === slot))))) return null;
|
||||
}
|
||||
}
|
||||
return { currentUser, prefs, date, times };
|
||||
}
|
||||
const initial = due(); if (!initial) continue;
|
||||
function readDue(prepared?: HabitService, checkDeliveries = false) {
|
||||
const latest = eligible(checkDeliveries); if (!latest) return null;
|
||||
const owner = latest.currentUser;
|
||||
const service = prepared ?? new HabitService(db, { id: owner.id, discordId: owner.discordId, username: owner.username, displayName: owner.globalName ?? owner.username, avatarUrl: null, timezone: owner.timezone }, now());
|
||||
if (service.today !== latest.date || service.user.timezone !== owner.timezone) return null;
|
||||
const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length;
|
||||
return remaining ? { date: service.today, remaining, timezone: service.user.timezone, times: latest.times, entries: entriesForDate(latest.prefs, service.today) } : null;
|
||||
}
|
||||
async function due(checkDeliveries = false) {
|
||||
const initial = eligible(checkDeliveries); if (!initial) return null;
|
||||
const owner = initial.currentUser;
|
||||
return HabitService.withPrepared(db, { id: owner.id, discordId: owner.discordId, username: owner.username, displayName: owner.globalName ?? owner.username, avatarUrl: null, timezone: owner.timezone }, now, service => readDue(service, checkDeliveries));
|
||||
}
|
||||
const initial = await due(true); if (!initial) continue;
|
||||
const claims = db.transaction(tx => {
|
||||
// A crashed/aborted send must not block later times indefinitely.
|
||||
tx.update(reminderDeliveries).set({ status: "uncertain" }).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.status, "pending"), lt(reminderDeliveries.updatedAt, now() - 60000))).run();
|
||||
@@ -75,7 +95,10 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig,
|
||||
const destination = channel.ok ? await channel.json().catch(() => null) as { id?: string } | null : null;
|
||||
if (!destination?.id || !/^\d{17,20}$/.test(destination.id)) { update("failed"); continue; }
|
||||
// A user may opt out or finish a habit while Discord opens the DM channel.
|
||||
const latest = due();
|
||||
const prepared = await due();
|
||||
// Releasing readiness can run a queued edit before this continuation. Re-read
|
||||
// synchronously, with fresh caches, and invoke POST without another yield.
|
||||
const latest = prepared ? readDue() : null;
|
||||
if (!latest || latest.date !== initial.date || !claims.some(claim => claim.slot === "*" || latest.times.includes(claim.slot))) { update("skipped"); continue; }
|
||||
const names = [...new Set(latest.entries.filter(entry => claims.some(claim => claim.slot === "*" ? latest.times.includes(entry.time) : claim.slot === entry.time)).map(entry => entry.name?.trim()).filter((name): name is string => Boolean(name)))];
|
||||
// Bound the label prefix so the count and link always fit Discord's message limit.
|
||||
|
||||
27
src/rendering/queue.test.ts
Normal file
27
src/rendering/queue.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { createRenderQueue, RenderBusyError } from './queue';
|
||||
|
||||
const result = new Uint8Array([1, 2, 3]);
|
||||
test('render queue bounds global concurrency and shares only the same owner and content', async () => {
|
||||
const queue = createRenderQueue({ concurrency: 2, maxPending: 4, maxPerUser: 2 });
|
||||
let active = 0, peak = 0, calls = 0;
|
||||
const run = async () => { calls++; active++; peak = Math.max(peak, active); await Bun.sleep(5); active--; return result; };
|
||||
const first = queue('alice', 'one', run);
|
||||
expect(queue('alice', 'one', run)).toBe(first);
|
||||
const all = [first, queue('alice', 'two', run), queue('bob', 'one', run), queue('carol', 'one', run)];
|
||||
await Promise.all(all); expect(calls).toBe(4); expect(peak).toBe(2);
|
||||
expect(await queue('alice', 'one', run)).toBe(result); expect(calls).toBe(5);
|
||||
});
|
||||
|
||||
test('render admission enforces per-user and global capacity and recovers after failures', async () => {
|
||||
const queue = createRenderQueue({ concurrency: 1, maxPending: 2, maxPerUser: 1 });
|
||||
let release!: () => void;
|
||||
const first = queue('alice', 'a', async () => { await new Promise<void>(resolve => { release = resolve; }); throw new Error('render failed'); });
|
||||
const failure = first.catch(error => error);
|
||||
await expect(queue('alice', 'b', async () => result)).rejects.toBeInstanceOf(RenderBusyError);
|
||||
const second = queue('bob', 'b', async () => result);
|
||||
await expect(queue('carol', 'c', async () => result)).rejects.toBeInstanceOf(RenderBusyError);
|
||||
while (!release) await Bun.sleep(1);
|
||||
release(); expect((await failure).message).toBe('render failed'); await second;
|
||||
expect(await queue('alice', 'a', async () => result)).toBe(result);
|
||||
});
|
||||
42
src/rendering/queue.ts
Normal file
42
src/rendering/queue.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { ApiError } from '../habits/service';
|
||||
|
||||
export class RenderBusyError extends ApiError {
|
||||
constructor() { super(429, 'Image rendering is busy. Try again shortly.'); }
|
||||
}
|
||||
type Job = { run: () => Promise<Uint8Array>; resolve: (image: Uint8Array) => void; reject: (reason: unknown) => void };
|
||||
/** One process-wide admission boundary for every server canvas renderer. */
|
||||
export function createRenderQueue({ concurrency = 2, maxPending = 16, maxPerUser = 2 } = {}) {
|
||||
const pending = new Map<string, Promise<Uint8Array>>();
|
||||
const owners = new Map<string, number>();
|
||||
const waiting: Job[] = [];
|
||||
let active = 0;
|
||||
function pump() {
|
||||
while (active < concurrency && waiting.length) {
|
||||
const job = waiting.shift()!; active++;
|
||||
// Yield before drawing so a burst of callers cannot monopolize this turn.
|
||||
setTimeout(() => {
|
||||
void Promise.resolve().then(job.run).then(job.resolve, job.reject).finally(() => { active--; pump(); });
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
return (userId: string, key: string, run: Job['run']): Promise<Uint8Array> => {
|
||||
const scopedKey = JSON.stringify([userId, key]);
|
||||
const existing = pending.get(scopedKey);
|
||||
if (existing) return existing;
|
||||
if (pending.size >= maxPending || (owners.get(userId) ?? 0) >= maxPerUser) return Promise.reject(new RenderBusyError());
|
||||
owners.set(userId, (owners.get(userId) ?? 0) + 1);
|
||||
const work = new Promise<Uint8Array>((resolve, reject) => { waiting.push({ run, resolve, reject }); }).finally(() => {
|
||||
pending.delete(scopedKey);
|
||||
const count = owners.get(userId)! - 1;
|
||||
if (count) owners.set(userId, count); else owners.delete(userId);
|
||||
});
|
||||
pending.set(scopedKey, work); pump();
|
||||
return work;
|
||||
};
|
||||
}
|
||||
const schedule = createRenderQueue();
|
||||
export function renderImage(kind: string, userId: string, data: unknown, theme: string, run: Job['run']) {
|
||||
const hash = createHash('sha256').update(JSON.stringify([kind, data, theme])).digest('hex');
|
||||
return schedule(userId, hash, run);
|
||||
}
|
||||
38
src/sharing/avatar-cache.test.ts
Normal file
38
src/sharing/avatar-cache.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import { createAvatarCache } from './avatar-cache';
|
||||
|
||||
test('avatars share in-flight downloads and invalidate by URL or expiry', async () => {
|
||||
const png = await createCanvas(16, 16).encode('png');
|
||||
let calls = 0, timestamp = 0;
|
||||
const request = Object.assign(async () => { calls++; return new Response(new Uint8Array(png)); }, { preconnect: fetch.preconnect });
|
||||
const avatar = createAvatarCache({ request, now: () => timestamp, successTtlMs: 100 });
|
||||
const [one, two] = await Promise.all([avatar('avatar/hash1'), avatar('avatar/hash1')]);
|
||||
expect(one).not.toBeNull(); expect(one).toBe(two); expect(calls).toBe(1);
|
||||
expect(await avatar('avatar/hash1')).toBe(one); expect(calls).toBe(1);
|
||||
await avatar('avatar/hash2'); expect(calls).toBe(2);
|
||||
timestamp = 100; await avatar('avatar/hash1'); expect(calls).toBe(3);
|
||||
});
|
||||
|
||||
test('avatar cache evicts least recently used images and retries negative entries after their TTL', async () => {
|
||||
const png = await createCanvas(16, 16).encode('png');
|
||||
let calls = 0, timestamp = 0, fail = false;
|
||||
const request = Object.assign(async () => { calls++; return fail ? new Response(null, { status: 503 }) : new Response(new Uint8Array(png)); }, { preconnect: fetch.preconnect });
|
||||
const avatar = createAvatarCache({ request, now: () => timestamp, maxEntries: 2, failureTtlMs: 10 });
|
||||
await avatar('a'); await avatar('b'); await avatar('a'); await avatar('c');
|
||||
expect(calls).toBe(3); await avatar('b'); expect(calls).toBe(4);
|
||||
fail = true;
|
||||
expect(await avatar('bad')).toBeNull(); expect(await avatar('bad')).toBeNull(); expect(calls).toBe(5);
|
||||
timestamp = 10; fail = false;
|
||||
expect(await avatar('bad')).not.toBeNull(); expect(calls).toBe(6);
|
||||
});
|
||||
|
||||
test('avatar downloads have a concurrency bound and oversized decoded images use the fallback', async () => {
|
||||
let release!: (response: Response) => void, calls = 0;
|
||||
const request = Object.assign(async () => { calls++; return new Promise<Response>(resolve => { release = resolve; }); }, { preconnect: fetch.preconnect });
|
||||
const avatar = createAvatarCache({ request, maxPending: 1 });
|
||||
const first = avatar('one'), duplicate = avatar('one');
|
||||
expect(await avatar('two')).toBeNull(); expect(calls).toBe(1);
|
||||
release(new Response(new Uint8Array(await createCanvas(257, 1).encode('png'))));
|
||||
expect(await first).toBeNull(); expect(await duplicate).toBeNull();
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user