Compare commits

..

39 Commits

Author SHA1 Message Date
syntaxbullet
ccd998ab04 Add service worker implementation and tests for PWA caching strategy
- Implemented a service worker (sw.js) that handles installation, activation, and fetch events.
- Added caching logic for public assets and a fallback for offline navigation.
- Created unit tests (sw.test.ts) to validate service worker behavior, including asset caching, navigation handling, and activation logic.
- Ensured that private API routes and third-party requests are excluded from caching.
2026-09-08 17:19:12 +02:00
syntaxbullet
e081e9acbf Document performance changes and measured results 2026-09-08 16:35:45 +02:00
syntaxbullet
b8df2001ec Merge frontend performance improvements 2026-09-08 16:35:45 +02:00
syntaxbullet
95f3d9a2c7 Merge worker performance improvements 2026-09-08 16:35:45 +02:00
syntaxbullet
8f15419703 Merge backend performance improvements 2026-09-08 16:35:45 +02:00
syntaxbullet
7c7a048b32 Initialize cold WAL bookkeeping for subprocess snapshots 2026-09-08 16:35:37 +02:00
syntaxbullet
27beb01144 Revalidate reminders synchronously after readiness yields 2026-09-08 16:35:36 +02:00
syntaxbullet
149ec36c77 Prepare worker and Discord snapshots through bounded catch-up 2026-09-08 16:35:36 +02:00
syntaxbullet
c3b52e8fe1 Resolve backup subprocess paths with URL decoding 2026-09-08 16:35:36 +02:00
syntaxbullet
ba049efdc8 Bound and deduplicate server image rendering per account 2026-09-08 16:35:36 +02:00
syntaxbullet
e3e07404f8 Cache bounded Discord avatars and coalesce downloads 2026-09-08 16:35:36 +02:00
syntaxbullet
17190219fa Skip reminder habit evaluation for completed delivery slots 2026-09-08 16:35:36 +02:00
syntaxbullet
b0950cb4b4 Run scheduled backups in an isolated subprocess 2026-09-08 16:35:36 +02:00
syntaxbullet
82fe4a1db1 Preserve calendar presets and custom ranges across local midnight 2026-09-08 16:29:20 +02:00
syntaxbullet
c91202bbfc Keep auxiliary previews stable on unchanged background refreshes 2026-09-08 16:27:03 +02:00
syntaxbullet
03eb8d9e7a Serve lossless WOFF2 browser fonts outside blocking stylesheets 2026-09-08 16:25:38 +02:00
syntaxbullet
9f552bf608 Load earlier calendar windows when editing historical dates 2026-09-08 16:25:38 +02:00
syntaxbullet
b22c933fc4 Split optional routes and editing dialogs from initial browser code 2026-09-08 16:25:38 +02:00
syntaxbullet
9a0fc108a9 Preserve unchanged habit renders and memoize calendar geometry 2026-09-08 16:25:38 +02:00
syntaxbullet
b4619dec33 Cache versioned habit history and load visible calendar ranges 2026-09-08 16:25:37 +02:00
syntaxbullet
31e4f3b2ef Yield bounded catch-up batches behind per-user readiness queues 2026-09-08 16:18:09 +02:00
syntaxbullet
30dee55799 Skip unchanged materialization and expire only open occurrences 2026-09-08 16:16:04 +02:00
syntaxbullet
4b8c7dff05 Add compact calendar responses and persisted habit change versions 2026-09-08 16:14:58 +02:00
syntaxbullet
85376a54b3 Bound habit day and calendar reads to requested history 2026-09-08 16:13:48 +02:00
syntaxbullet
833d5673eb Refactor HabitColorPicker to use HABIT_COLOR_CHOICES, update styles and tests
- Replaced HABIT_PALETTES with HABIT_COLOR_CHOICES in HabitColorPicker component and related tests.
- Adjusted the layout and styles of the color picker for improved usability.
- Updated tests to reflect changes in color selection logic.
- Modified WorkspacePanel and SettingRow components to allow optional eyebrow and description props.
- Enhanced accessibility tests for calendar navigation.
- Updated calendar model to include a 1-month view option.
- Refined styles across various components for consistency and improved responsiveness.
2026-09-08 15:42:47 +02:00
syntaxbullet
4d719afd07 fix: move production app to mina.teppelin.com 2026-09-08 11:48:37 +02:00
syntaxbullet
838567e9b8 feat: enhance habit tracking with pending state management and history refresh 2026-09-08 09:25:40 +02:00
syntaxbullet
f79d593a04 feat: add private day, history and reminder command graphics 2026-09-08 08:58:34 +02:00
syntaxbullet
d088179c5e docs: record integrated Discord command suite and activation 2026-09-08 08:46:58 +02:00
syntaxbullet
2c135398d7 feat: render focused 30-day Discord progress cards with Canvas 2026-09-08 08:45:48 +02:00
syntaxbullet
a1ae65634b feat: add private Discord progress and explicit shared delivery controls 2026-09-08 08:45:48 +02:00
syntaxbullet
5dbec04b04 feat: add Discord reminder and timezone settings 2026-09-08 08:45:13 +02:00
syntaxbullet
3487488f7b feat: add Discord habit history and dated progress corrections 2026-09-08 08:44:50 +02:00
syntaxbullet
ab2aa74eba feat: manage routine tasks through Discord forms and recurrence controls 2026-09-08 08:44:32 +02:00
syntaxbullet
07a0f80fc0 feat: manage habits through private Discord controls 2026-09-08 08:44:16 +02:00
syntaxbullet
8b9bd75f24 feat: polish Discord daily check-ins and add reminder buttons 2026-09-08 08:44:01 +02:00
syntaxbullet
2671fe0f34 feat: add Discord help command for servers and DMs 2026-09-08 08:33:34 +02:00
syntaxbullet
0a34142b3e feat: add safe global Discord command registration 2026-09-08 08:17:26 +02:00
syntaxbullet
4f38bf1431 feat: add signed Discord command foundation and daily check-ins 2026-09-08 08:15:22 +02:00
132 changed files with 8148 additions and 479 deletions

View File

@@ -25,3 +25,6 @@ BACKUP_RETAIN=7
BACKUP_REPLICA_DIR=
# Optional public HTTPS health URL for the independent monitor.
MONITOR_URL=
# Discord Developer Portal > General Information > Public Key.
# Interactions endpoint: https://YOUR_PRODUCTION_HOST/api/discord/interactions
DISCORD_PUBLIC_KEY=

View File

@@ -1,4 +1,4 @@
mina.teppelinlabs.com {
mina.teppelin.com {
encode zstd gzip
reverse_proxy app:3000
}

View File

@@ -1,5 +1,9 @@
# Mina Habits
The production app is installable as a PWA, with an offline reconnect screen and
user-controlled updates. Habit data and changes require a connection. See
[PWA behavior and verification](docs/PWA.md).
Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The React Router
client includes a homepage for signed-out visitors and a personal habit dashboard.

View File

@@ -1,6 +1,7 @@
import tailwind from "bun-plugin-tailwind";
import { rm, mkdir, copyFile } from "node:fs/promises";
import path from "node:path";
import { buildPwa } from "./scripts/build-pwa";
const outdir = path.join(process.cwd(), "dist");
await rm(outdir, { recursive: true, force: true });
@@ -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
View File

@@ -9,6 +9,10 @@ declare module "*.svg" {
}
declare module "*.css" {}
declare module "*.webmanifest" {
const content: string;
export default content;
}
declare module "*.png" {
const path: string;
export default path;

View File

@@ -26,6 +26,7 @@
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"happy-dom": "^20.14.0",
"playwright": "^1.63.0",
"tailwindcss": "^4.1.11",
"typescript": "^7.0.2",
},
@@ -290,6 +291,10 @@
"lucide-react": ["lucide-react@1.40.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MaG+8WnOXkDWz9XeElj7TnQ890tTZUB0a36i03aRRCKGWE6e7jJpmdCvtxxuxcjjdqyN6m4sL6qlHVuSUDtYgg=="],
"playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],

View File

@@ -3,5 +3,5 @@
env = false
[serve.static]
plugins = ["bun-plugin-tailwind"]
plugins = ["bun-plugin-tailwind", "./scripts/browser-fonts-plugin.ts"]
env = "BUN_PUBLIC_*"

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,103 @@
# Progressive web app
Mina Habits has an installable production build with a public offline app shell.
Its manifest supplies a stable app identity and root scope, standalone display,
192px and 512px icons, a separate maskable icon, app colors and descriptions.
The HTML includes an Apple touch icon, mobile metadata, theme color and safe-area
support. Browser chrome follows the selected light or dark theme.
Install from **Install app** in the footer. Supporting browsers show their native
install prompt. Other browsers receive instructions for Add to Home Screen or
Add to Dock. Installed windows hide the install action.
## Offline behavior
After one successful online visit and service-worker installation, the application
shell, fonts, icons and lazy browser chunks are available offline. Opening or
reloading the app offline shows a reconnect screen instead of the browser error
page. Nested routes also resolve correctly. Accounts retry on reconnection and
periodically while a failed connection is visible, including when the browser
incorrectly reports that it is online. An account request has a 10-second timeout;
page navigations fall back to the cached shell after four seconds.
**Account access, habit changes, sign-in, exports and Discord actions require a
connection.** Existing dashboard content can remain visible in an open window,
but no account responses are persisted for offline use and no mutations are
queued or replayed. Failed saves must be retried by the user. This is an offline
fallback, not offline habit editing or background synchronization.
All `/api` traffic, including OAuth callback navigations, bypasses the service
worker. API responses have `Cache-Control: no-store`. Only explicitly public
build assets enter Cache Storage; server JavaScript, source maps, account data,
exports and third-party responses are excluded. Clearing site data removes the
public offline files; it does not remove account records from the server.
## Build and updates
`bun run build` runs `scripts/build-pwa.ts` after Bun emits the browser and server
bundles. It derives a release identifier from the worker source and public asset
contents, copies a version-specific public HTML shell, and emits `dist/pwa/sw.js`
with an explicit precache list. A failed precache download prevents installation
of that release. The stable `/sw.js` route is served as JavaScript with
`Cache-Control: no-cache` and root service-worker scope.
Updates wait until existing windows close or the user chooses **Update app**,
then **Reload and update**. The confirmation tells the user to save unfinished
changes. Only the accepting window reloads; other windows retain their drafts
and receive their own update action. Old static caches remain available for their
lazy chunks. A subsequent activation with no open app windows removes obsolete
Mina Habits caches, leaving unrelated caches untouched. Checking for updates on
return to the app, reconnection and hourly supports long-lived installed windows.
The source manifest is `src/pwa/manifest.webmanifest`. Bun fingerprints its HTML
link; an explicit `/manifest.webmanifest` endpoint serves the same document. The
manifest's stable `id` keeps app identity independent of its asset filename.
Install icons reuse `docs/design/mina-habits-icon.png`; regenerate their exact
sizes with `bun scripts/pwa-icons.ts`. The maskable derivative adds opaque padding
to keep the monogram inside the central safe zone. Existing favicon and Apple
icons are preserved.
## Serving requirements
Use the normal production build/start or Docker workflow at the root of an HTTPS
origin. Local testing may use `http://localhost` or `http://127.0.0.1`. The current
production configuration already requires an HTTPS origin outside loopback and
uses Caddy for TLS. No new deployment environment variables are required.
Service-worker registration is deliberately disabled in development/HMR mode;
run the production smoke check to verify caching. Deploy the entire build
atomically, including `dist/pwa`, rather than uploading individual browser files.
Do not apply immutable HTTP caching to `/sw.js` or add a proxy cache for `/api`.
PWA installation support and its interface vary by browser. Push notifications,
background sync and app-store packaging are optional capabilities, not
installation requirements. This implementation follows the current
[MDN installability requirements](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable)
and the documented
[service-worker lifecycle](https://web.dev/articles/service-worker-lifecycle).
## Verification
```sh
bun run typecheck
bun test
bun x playwright install chromium
bun run test:pwa
bun run test:smoke
```
`test:pwa` copies the production build into a temporary directory, starts it with
a disposable SQLite database and no Discord credentials, then runs Chromium.
It verifies manifest parsing and Chrome installability errors, icon dimensions,
MIME/cache headers, first installation, offline startup and nested/lazy routes,
reconnection, responsive layouts, theme color, failed-update recovery, a two-window
update with an unsaved draft, API-cache exclusion, sign-out and mobile install
instructions. Screenshots are written to `.cache/pwa/`. Temporary data and the
server are removed when the check finishes. Worker unit tests cover routing,
installation failure and cache cleanup.
Before a public release, also exercise installation and standalone relaunch on a
physical iPhone/iPad and Android device over the production HTTPS origin. Desktop
Chromium checks and mobile emulation do not establish native OS installation or
Safari behavior. The implementation does not claim a universal PWA certification.

66
docs/performance.md Normal file
View 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.

View File

@@ -0,0 +1,20 @@
CREATE TABLE `discord_controls` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`action` text NOT NULL,
`payload` text NOT NULL,
`expires_at` integer NOT NULL,
`used` integer DEFAULT false NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `discord_controls_expiry_idx` ON `discord_controls` (`expires_at`);--> statement-breakpoint
CREATE TABLE `discord_receipts` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`response` text NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `discord_receipts_expiry_idx` ON `discord_receipts` (`expires_at`);

View File

@@ -0,0 +1 @@
ALTER TABLE habits ADD COLUMN calendar_revision integer NOT NULL DEFAULT 0;

View File

@@ -0,0 +1 @@
CREATE INDEX task_occurrences_open_idx ON task_occurrences (day_id) WHERE closed_at IS NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,27 @@
"when": 1788845281801,
"tag": "0011_multiple_reminders",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"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
}
]
}
}

View File

@@ -14,6 +14,7 @@
"db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts",
"test:coverage": "bun test --coverage",
"test:smoke": "bun run build && bun scripts/api-smoke.ts",
"test:pwa": "bun run build && bun scripts/pwa-smoke.ts",
"db:backup": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/backup.ts",
"db:restore": "bun scripts/restore.ts",
"monitor": "bun --env-file=.env --env-file=.env.production scripts/monitor.ts"
@@ -40,6 +41,7 @@
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"happy-dom": "^20.14.0",
"playwright": "^1.63.0",
"tailwindcss": "^4.1.11",
"typescript": "^7.0.2"
}

View File

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

View 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
View File

@@ -0,0 +1,27 @@
import { createHash } from "node:crypto";
import { copyFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { manifest } from "../src/pwa/manifest";
export async function buildPwa(outdir: string, outputs: readonly { path: string }[]) {
const directory = path.join(outdir, "pwa");
await mkdir(directory, { recursive: true });
for (const icon of manifest.icons) await copyFile(`src/pwa/public/${path.basename(icon.src)}`, path.join(directory, path.basename(icon.src)));
await Bun.write(path.join(outdir, "manifest.webmanifest"), JSON.stringify(manifest, null, 2));
// Only browser assets emitted by Bun; index.js is the private server bundle.
const files = outputs.map(output => path.relative(outdir, output.path)).filter(file =>
file !== "index.js" && /\.(js|css|png|svg|woff2|webmanifest)$/.test(file));
files.push(...manifest.icons.map(icon => icon.src.slice(1)), "manifest.webmanifest");
for await (const file of new Bun.Glob("fonts/browser/*.woff2").scan(outdir)) files.push(file);
files.sort();
const template = await Bun.file("src/pwa/sw.js").text();
const html = await Bun.file(path.join(outdir, "index.html")).text();
const hash = createHash("sha256").update(template).update(html);
for (const file of files) hash.update(file).update(await Bun.file(path.join(outdir, file)).arrayBuffer().then(Buffer.from));
const version = hash.digest("hex").slice(0, 16);
const shell = `/pwa/shell-${version}.html`;
await Bun.write(path.join(outdir, shell.slice(1)), html);
const config = { version, shell, assets: [...files.map(file => `/${file}`), shell] };
await Bun.write(path.join(directory, "sw.js"), template.replace('"__PWA_CONFIG__"', JSON.stringify(JSON.stringify(config))));
console.log(` PWA ${version}: ${config.assets.length} public assets`);
}

View File

@@ -1,3 +1,4 @@
import { browserFont } from "../src/browser-fonts";
// Isolated browser QA server. Never opens or changes the user's database.
import { sendDueReminders } from "../src/reminders/worker";
import type { DiscordFetch } from "../src/sharing/routes";
@@ -48,13 +49,14 @@ const app = createApi(
let reminderRunning = false;
if (sharingPreview) setInterval(async () => {
if (reminderRunning) return; reminderRunning = true;
try { await sendDueReminders(f.db, { token: "preview-bot-token", origin }, previewDiscord, () => Date.parse("2026-09-04T12:00:00Z")); }
try { await sendDueReminders(f.db, { token: "preview-bot-token", origin, commandsAvailable: false }, previewDiscord, () => Date.parse("2026-09-04T12:00:00Z")); }
finally { reminderRunning = false; }
}, 1000).unref();
const server = Bun.serve({
hostname: "127.0.0.1",
port,
routes: {
"/fonts/browser/*": browserFont,
"/__preview/reminder": () => new Response(reminderMessage ?? "No reminder sent"),
"/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }),
"/__preview/login": () =>

17
scripts/pwa-icons.ts Normal file
View File

@@ -0,0 +1,17 @@
import { createCanvas, loadImage } from "@napi-rs/canvas";
import { mkdir } from "node:fs/promises";
// Package the approved master at exact platform sizes; do not regenerate the artwork.
const master = await loadImage("docs/design/mina-habits-icon.png");
await mkdir("src/pwa/public", { recursive: true });
for (const [name, size, scale] of [
["icon-192", 192, 1], ["icon-512", 512, 1], ["icon-maskable-512", 512, 0.7],
] as const) {
const canvas = createCanvas(size, size);
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, size, size);
const inset = size * (1 - scale) / 2;
ctx.drawImage(master, inset, inset, size * scale, size * scale);
await Bun.write(`src/pwa/public/${name}.png`, canvas.toBuffer("image/png"));
}

194
scripts/pwa-smoke.ts Normal file
View File

@@ -0,0 +1,194 @@
import assert from "node:assert/strict";
import { cp, mkdtemp, mkdir, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { chromium } from "playwright";
import { Database } from "bun:sqlite";
import { hashToken } from "../src/auth";
// Exercise a copied production bundle and disposable account. No live data or Discord calls.
const directory = await mkdtemp(path.join(tmpdir(), "minabot-pwa-"));
const project = process.cwd();
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
const port = probe.port!;
probe.stop(true);
const origin = `http://127.0.0.1:${port}`;
const databasePath = path.join(directory, "test.sqlite");
const env = { ...process.env, NODE_ENV: "production", HOST: "127.0.0.1", PORT: String(port), APP_ORIGIN: origin,
DATABASE_PATH: databasePath, BACKUP_ENABLED: "false", DISCORD_CLIENT_ID: "", DISCORD_CLIENT_SECRET: "", DISCORD_PUBLIC_KEY: "",
DISCORD_BOT_TOKEN: "", DISCORD_SHARING_CHANNEL_ID: "", AUTH_COOKIE_SECRET: "pwa-test-only-secret-at-least-32-characters" };
let server: ReturnType<typeof Bun.spawn> | undefined;
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
const screenshots = path.resolve(".cache/pwa");
try {
await cp("dist", path.join(directory, "dist"), { recursive: true });
await symlink(path.join(project, "node_modules"), path.join(directory, "node_modules"), "dir");
const migration = Bun.spawn([process.execPath, "scripts/migrate.ts"], { env, stdout: "pipe", stderr: "pipe" });
assert.equal(await migration.exited, 0, "Production migrations failed");
const sqlite = new Database(databasePath);
const now = Date.now();
const token = "p".repeat(43);
sqlite.query("INSERT INTO users (id, discord_id, username, timezone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run("pwa", "pwa", "PWA Test", "UTC", now, now);
sqlite.query("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(hashToken(token), "pwa", now, now + 3600000);
sqlite.close();
server = Bun.spawn([process.execPath, "index.js"], { cwd: path.join(directory, "dist"), env, stdout: "pipe", stderr: "pipe" });
let healthy = false;
for (let attempt = 0; attempt < 100; attempt++) {
try { if ((await fetch(`${origin}/api/health`)).ok) { healthy = true; break; } } catch { /* Starting. */ }
if (server.exitCode !== null) throw new Error(await new Response(server.stderr as ReadableStream).text());
await Bun.sleep(50);
}
assert(healthy, "Production server failed to start");
const swResponse = await fetch(`${origin}/sw.js`);
assert.equal(swResponse.headers.get("service-worker-allowed"), "/");
assert.equal(swResponse.headers.get("cache-control"), "no-cache");
assert.match(swResponse.headers.get("content-type")!, /javascript/);
assert.equal((await fetch(`${origin}/pwa/index.js`)).status, 404);
assert.equal((await fetch(`${origin}/pwa/missing.png`)).status, 404);
assert.equal((await fetch(`${origin}/pwa/icon-192.png`, { method: "POST" })).status, 405);
assert.equal((await fetch(`${origin}/api/me`)).headers.get("cache-control"), "no-store");
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const page = await context.newPage();
const pageErrors: string[] = [];
page.on("pageerror", error => pageErrors.push(error.message));
await page.goto(origin);
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
await page.waitForFunction(() => Boolean(navigator.serviceWorker.controller));
assert.equal(await page.getByRole("button", { name: "Update app", exact: true }).count(), 0, "First install must not announce an update");
const cdp = await context.newCDPSession(page);
const manifestResult = await cdp.send("Page.getAppManifest");
assert.equal(manifestResult.errors.length, 0, JSON.stringify(manifestResult.errors));
const manifest = JSON.parse(manifestResult.data!);
assert.equal(manifest.id, "/");
assert.equal(manifest.display, "standalone");
assert.equal(manifest.scope, "/");
for (const icon of manifest.icons) {
const response = await fetch(new URL(icon.src, origin));
assert.match(response.headers.get("content-type")!, /image\/png/);
const png = Buffer.from(await response.arrayBuffer());
assert.equal(`${png.readUInt32BE(16)}x${png.readUInt32BE(20)}`, icon.sizes);
}
const installability = await cdp.send("Page.getInstallabilityErrors");
assert.deepEqual(installability.installabilityErrors, []);
console.log("PASS Chromium installability, manifest, icon sizes, MIME types and HTTP cache policy");
await mkdir(screenshots, { recursive: true });
await page.screenshot({ path: path.join(screenshots, "desktop.png"), fullPage: true });
await page.getByRole("radio", { name: "Dark", exact: true }).check();
assert.equal(await page.locator('meta[name="theme-color"]').getAttribute("content"), "#191e1b");
await page.getByRole("radio", { name: "Light", exact: true }).check();
await context.setOffline(true);
await page.goto(`${origin}/a/deep/link`);
await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
assert(await page.getByText("We can’t connect to Mina Habits.", { exact: false }).isVisible());
await page.setViewportSize({ width: 375, height: 812 });
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Offline mobile layout overflows");
await page.screenshot({ path: path.join(screenshots, "offline-mobile.png"), fullPage: true });
await page.goto(`${origin}/design-system`);
await page.getByRole("heading", { name: /design system/i }).first().waitFor();
await context.setOffline(false);
await page.goto(origin);
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
await context.setOffline(true);
await page.reload();
await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
await context.setOffline(false);
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Online mobile layout overflows");
await page.screenshot({ path: path.join(screenshots, "mobile.png"), fullPage: true });
console.log("PASS offline cold start, nested routes, lazy routes, mobile layouts, theme and automatic reconnection");
// Keep an unsaved draft in another open window while applying an update here.
const other = await context.newPage();
await other.goto(origin);
const draft = other.getByRole("textbox", { name: "Total glasses of water", exact: true });
await draft.fill("6");
const workerPath = path.join(directory, "dist/pwa/sw.js");
const original = await Bun.file(workerPath).text();
const configMatch = original.match(/const CONFIG = JSON.parse\((.+)\);/)!;
const config = JSON.parse(JSON.parse(configMatch[1]!));
// A broken release must leave the active worker and existing offline assets usable.
const broken = { ...config, version: `${config.version}-broken`, assets: [...config.assets, "/pwa/missing.png"] };
await Bun.write(workerPath, original.replace(configMatch[0], `const CONFIG = JSON.parse(${JSON.stringify(JSON.stringify(broken))});`));
await page.evaluate(async () => {
const registration = (await navigator.serviceWorker.getRegistration())!;
await new Promise<void>((resolve, reject) => {
registration.addEventListener("updatefound", () => {
const worker = registration.installing!;
worker.addEventListener("statechange", () => { if (worker.state === "redundant") resolve(); });
}, { once: true });
void registration.update().catch(reject);
});
});
assert.equal(await page.getByRole("button", { name: "Update app", exact: true }).count(), 0);
await context.setOffline(true);
await page.reload();
await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
await context.setOffline(false);
await page.getByRole("button", { name: "Try again", exact: true }).click();
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
config.version += "-qa";
await Bun.write(workerPath, original.replace(configMatch[0], `const CONFIG = JSON.parse(${JSON.stringify(JSON.stringify(config))});`));
await page.evaluate(async () => { await (await navigator.serviceWorker.getRegistration())!.update(); });
await page.getByRole("button", { name: "Update app", exact: true }).waitFor();
await page.getByRole("button", { name: "Update app", exact: true }).click();
await page.getByRole("button", { name: "Later", exact: true }).click();
assert.equal(await draft.inputValue(), "6");
await page.getByRole("button", { name: "Update app", exact: true }).click();
await Promise.all([page.waitForEvent("load"), page.getByRole("button", { name: "Reload and update", exact: true }).click()]);
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
assert.equal(await draft.inputValue(), "6", "Updating another window discarded its draft");
await other.getByRole("button", { name: "Update app", exact: true }).waitFor();
const keys = await page.evaluate(() => caches.keys());
assert(keys.some(key => key.endsWith("-qa")) && keys.length >= 2, "Old window assets were not retained");
console.log("PASS failed-update recovery, update waiting, deferred reload and preservation of another window’s draft");
await context.addCookies([{ name: "minabot_session", value: token, url: origin }]);
await page.reload();
await page.getByRole("button", { name: "Sign out", exact: true }).waitFor();
const privateResponse = await page.evaluate(async () => ({ status: (await fetch("/api/me")).status }));
assert.equal(privateResponse.status, 200);
await context.setOffline(true);
assert.equal(await page.evaluate(async () => { try { await fetch("/api/me"); return true; } catch { return false; } }), false, "Account response was cached");
await context.setOffline(false);
await page.getByRole("button", { name: "Sign out", exact: true }).click();
await page.getByRole("heading", { name: /Small steps/ }).waitFor();
const cacheUrls = await page.evaluate(async () => {
const urls: string[] = [];
for (const key of await caches.keys()) for (const request of await (await caches.open(key)).keys()) urls.push(new URL(request.url).pathname);
return urls;
});
assert(!cacheUrls.some(url => url.startsWith("/api") || url.endsWith(".map") || url === "/index.js"), "Private files entered Cache Storage");
assert.deepEqual(pageErrors, []);
console.log("PASS authenticated API bypass, sign-out privacy and no uncaught browser errors");
const manualContext = await browser.newContext({ viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true });
await manualContext.addInitScript(() => {
window.addEventListener("beforeinstallprompt", event => { event.preventDefault(); event.stopImmediatePropagation(); });
});
const manual = await manualContext.newPage();
await manual.goto(origin);
await manual.getByRole("button", { name: "Install app", exact: true }).click();
await manual.getByRole("dialog", { name: "Keep Mina Habits close." }).waitFor();
assert(await manual.getByText("iPhone or iPad:", { exact: false }).isVisible());
assert(await manual.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Install dialog overflows");
await manual.screenshot({ path: path.join(screenshots, "install-mobile.png"), fullPage: true });
await manual.getByRole("button", { name: "Got it", exact: true }).click();
await manual.evaluate(() => window.dispatchEvent(new Event("appinstalled")));
assert.equal(await manual.getByRole("button", { name: "Install app", exact: true }).count(), 0);
await manualContext.close();
console.log("PASS mobile installation guidance and installed-state controls");
console.log(`Screenshots: ${screenshots}`);
} catch (error) {
const page = browser?.contexts()[0]?.pages()[0];
if (page) {
console.error("Browser at failure:", await page.locator("body").innerText());
await page.screenshot({ path: path.join(screenshots, "failure.png"), fullPage: true });
}
throw error;
} finally {
await browser?.close();
if (server) { server.kill(); await server.exited; }
await rm(directory, { recursive: true, force: true });
}

View File

@@ -0,0 +1,10 @@
import { commands } from '../src/discord/commands';
import { registerCommands } from '../src/discord/registration';
if (process.argv.includes('--apply')) {
const registered = await registerCommands(process.env.DISCORD_CLIENT_ID ?? '', process.env.DISCORD_BOT_TOKEN ?? '', commands);
console.log(`Registered global commands for servers and bot DMs: ${registered.map(name => `/${name}`).join(', ')}`);
} else {
console.log(JSON.stringify(commands, null, 2));
console.log('Preview only. Add --apply to register these commands with Discord.');
}

View 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(); }
}

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { createDiscordRoutes, type CommandConfig } from "./discord/routes";
import { createLiveDiscord } from "./sharing/live-discord";
import { createReminderRoutes } from "./reminders/routes";
import { errorDetails } from "./ops/monitor";
@@ -12,15 +13,18 @@ import type { FetchDiscord } from "./auth/discord";
import { createSharingRoutes, type DiscordFetch } from "./sharing/routes";
import type { DiscordSharingConfig } from "./sharing/config";
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }, operations: { healthy?: () => boolean; log?: (event: Record<string, unknown>) => void } = {}) {
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }, operations: { healthy?: () => boolean; log?: (event: Record<string, unknown>) => void; discord?: CommandConfig } = {}) {
const app = new Hono<AuthEnv>();
app.use("*", async (c, next) => {
const requestId = crypto.randomUUID();
c.set("requestId", requestId); c.header("X-Request-ID", requestId);
// Accounts, calendars, exports and OAuth responses must never enter HTTP caches.
c.header("Cache-Control", "no-store");
await next();
});
const auth = createAuth(db, config, request, now);
const liveSharing = createLiveDiscord(db, auth, sharingConfig, now ?? Date.now, discordRequest);
const sharing = createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest);
app.use("*", async (c, next) => {
await next();
if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method) && /^\/api\/(habits|me|account)(\/|$)/.test(c.req.path) && c.res.status < 400 && c.get("user"))
@@ -32,13 +36,14 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
if (operations.healthy && !operations.healthy()) return c.json({ status: "degraded" }, 503);
return c.json({ status: "ok" });
});
app.route("/api/discord/interactions", createDiscordRoutes(db, { ...(operations.discord ?? { publicKey: "", applicationId: config.clientId, origin: config.origin }), remindersAvailable: Boolean(sharingConfig.token) }, now ?? Date.now, liveSharing.changed, { progress: { sharing, live: liveSharing } }));
app.route("/api/sharing/live", liveSharing.routes);
app.route("/api/auth", auth.routes);
app.route("/api/reminders", createReminderRoutes(db, auth, Boolean(sharingConfig.token), now ?? Date.now));
app.route("/api/account", createAccountRoutes(db, auth, now ?? Date.now));
app.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
app.route("/api/sharing", createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest));
app.route("/api/sharing", sharing);
app.notFound(c => c.json({ error: "Not found" }, 404));
app.onError((_error, c) => {
c.header("Cache-Control", "no-store");

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -26,7 +26,7 @@ export function normalizeTimezone(value: unknown): string {
}
}
function publicUser(user: typeof users.$inferSelect): PublicUser {
export function publicUser(user: typeof users.$inferSelect): PublicUser {
return {
id: user.id,
discordId: user.discordId,

14
src/browser-fonts.test.ts Normal file
View 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
View 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 });
}

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ const authErrors: Record<string, string> = {
};
function signIn() {
if (navigator.onLine === false) return;
let timezone = "UTC";
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
@@ -21,6 +22,7 @@ type AuthState = {
loading: boolean;
busy: boolean;
accountError: string;
connectionError: boolean;
error: string;
signIn: () => void;
signOut: () => Promise<void>;
@@ -34,6 +36,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [accountError, setAccountError] = useState("");
const [connectionError, setConnectionError] = useState(false);
const [actionError, setActionError] = useState("");
const [attempt, setAttempt] = useState(0);
const { search } = useLocation();
@@ -42,15 +45,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
let reachedServer = false;
try {
const response = await fetch("/api/me", { signal: controller.signal });
setAccountError("");
setConnectionError(false);
const response = await fetch("/api/me", { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]) });
reachedServer = true;
if (controller.signal.aborted) return;
if (response.status === 401) { setUser(null); return; }
if (!response.ok) throw new Error("Could not load your account. Please try again.");
const account: PublicUser = await response.json();
if (!controller.signal.aborted) setUser(account);
} catch (error) {
if (!controller.signal.aborted) setAccountError(error instanceof Error ? error.message : "Could not load your account.");
if (!controller.signal.aborted) {
setConnectionError(!reachedServer);
setAccountError(!reachedServer ? "Could not connect to Mina Habits. Check your connection and try again." : error instanceof Error ? error.message : "Could not load your account.");
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
@@ -59,6 +69,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => controller.abort();
}, [attempt]);
useEffect(() => {
const reconnect = () => { if (accountError) retry(); };
const check = () => { if (connectionError && navigator.onLine !== false && document.visibilityState === "visible") retry(); };
const timer = window.setInterval(check, 15_000);
window.addEventListener("online", reconnect);
window.addEventListener("focus", check);
return () => { window.clearInterval(timer); window.removeEventListener("online", reconnect); window.removeEventListener("focus", check); };
}, [accountError, connectionError]);
function retry() {
setAccountError("");
setLoading(true);
@@ -69,6 +88,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setBusy(true);
setActionError("");
try {
if (navigator.onLine === false) throw new Error("Reconnect to sign out.");
const response = await fetch("/api/auth/logout", { method: "POST" });
if (!response.ok) throw new Error("Could not sign out. Please try again.");
setUser(null);
@@ -78,7 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
return (
<AuthContext.Provider value={{ user, loading, busy, accountError, error: accountError || actionError || signInError || "", signIn, signOut, retry }}>
<AuthContext.Provider value={{ user, loading, busy, accountError, connectionError, error: accountError || actionError || signInError || "", signIn, signOut, retry }}>
{children}
</AuthContext.Provider>
);

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>}
</>;
}

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
document.documentElement.dataset.theme = theme;
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", theme === "dark" ? "#191e1b" : "#ffffff");
}, [theme]);
useEffect(() => {
const media = window.matchMedia?.("(prefers-color-scheme: dark)");
@@ -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>

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { Link } from "react-router";
import { ThemeControl } from "../ThemeProvider";
import { Button } from "./primitives";
import { InstallApp, PwaStatus } from "../PwaControls";
/** The existing design-system page shell, shared without page-specific styling. */
export function PageLayout({
@@ -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" })}

View File

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

View File

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

View File

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

View File

@@ -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. */

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

View File

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

View File

@@ -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(),
@@ -101,3 +103,15 @@ export const liveDiscordCards = sqliteTable('live_discord_cards', {
retryAt: integer('retry_at'),
updatedAt: integer('updated_at').notNull(),
});
// Short-lived UI state and replay receipts; no Discord interaction tokens are persisted.
export const discordControls = sqliteTable('discord_controls', {
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
action: text('action').notNull(), payload: text('payload', { mode: 'json' }).$type<import('../discord/types').ControlData>().notNull(),
expiresAt: integer('expires_at').notNull(), used: integer('used', { mode: 'boolean' }).notNull().default(false),
}, t => [index('discord_controls_expiry_idx').on(t.expiresAt)]);
export const discordReceipts = sqliteTable('discord_receipts', {
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
response: text('response', { mode: 'json' }).$type<import('../discord/types').Reply>().notNull(),
expiresAt: integer('expires_at').notNull(),
}, t => [index('discord_receipts_expiry_idx').on(t.expiresAt)]);

131
src/discord/README.md Normal file
View File

@@ -0,0 +1,131 @@
# Discord interface
Signed HTTP interactions enter `/api/discord/interactions`. The raw request body
is verified with the application's Ed25519 public key and a five-minute timestamp
window. `DISCORD_PUBLIC_KEY` enables the endpoint; `DISCORD_CLIENT_ID` identifies
the application. Commands are global with contexts `[0, 1]` (server and bot DM).
`HabitService` remains the shared domain implementation. Verified Discord user IDs
resolve the same account as website OAuth; new accounts start in UTC. Server
responses are ephemeral. Buttons/forms carry opaque persisted owner-bound state,
expire after 15 minutes, and mutation controls are consumed after successful use.
Interaction IDs retain replay receipts for 24 hours. Neither table stores tokens.
`daily.ts` implements the `/today` workflow. `types.ts`, `ui.ts`, and
`state.ts` provide the shared feature building blocks. Feature tasks extend command
registration and route dispatch while keeping their behavior in separate modules.
Tests use real Ed25519 signatures and disposable migrated SQLite databases. The
foundation tests cover server/DM privacy, replay after restart, stale progress,
cross-account controls, and matching website state. All feature modules are now
composed in the shared API. End-to-end user interaction in Discord remains a
manual acceptance check after deployment and registration.
`/help` lists the current command definitions automatically, explains check-in
controls and privacy, and links to the website. It works before account setup and
does not create an account or synchronize habits. Re-run the registration script
with `--apply` after deployment to expose newly added commands in Discord.
Reminder delivery accepts `commandsAvailable`; production enables it when
`DISCORD_PUBLIC_KEY` is a 64-digit hexadecimal key and `DISCORD_CLIENT_ID` is set.
The existing bot token still controls delivery. Configured reminders include an
owner-bound `today` control with `mode: 'reminder'`: it opens a separate private
flow, leaving the reminder unchanged, and expires at the sending account's next
local midnight (including DST). Delivery claims, nonce, opt-out, and retry rules
are unchanged. The dashboard preview explicitly disables buttons because it has
no signed interaction endpoint configured. Global command registration and the
Discord interaction endpoint must be configured separately before production use.
## Preferences
`preferences.ts` implements global `/settings` and `/reminders`. Reminder menus
paginate ten named schedules at a time; entries with matching name and schedule
are edited together as multiple comma-separated 24-hour times. Forms accept
weekday names (or `every day`) or one ISO calendar date. Delivery is toggled for
all schedules. Removing the last schedule disables delivery and stores an empty
list; the shared contract permits empty lists only while disabled. Invalid forms
provide a prefilled retry button. UTC accounts receive a timezone setup hint.
`ReminderService` is shared with the website routes. Every save advances its
`updatedAt` monotonically; Discord controls compare the saved preference snapshot
and the user's timezone/update version before editing. `HabitService.setTimezone`
also advances the user timestamp monotonically, including repeated writes in the
same millisecond. This rejects old Discord forms after edits from either interface,
including change-and-revert sequences. Website PUT retains its existing request
contract. Delivery receipts do not invalidate preference controls.
Integration: retain the `preferences` command dispatch and `preferencesAction`
component dispatch in `routes.ts`, the two definitions in `commands.ts`, and the
preference fields in `ControlData`. `CommandConfig.remindersAvailable` supplies
availability; `createApi` derives it from the existing sharing bot token, matching
the website and reminder worker. Direct route fixtures default it to false.
No new environment variables, migrations, outbound requests, or remote command
registration are needed by this module. Global command registration remains an
integration step. Successful mutations invoke the supplied live-card dirty hook.
`progress.ts` implements `/progress`: six-habit text pages, focused private
previews, explicit destination confirmation for snapshots, and live-image
start/status/pause/resume. API composition injects the same `sharing.send` and
`live.start/pause/state` operations used by website routes. Keep these shared
instances when reconciling dispatch; do not construct a second live worker.
Rendering and network actions return ephemeral type-5 acknowledgements and edit
the original interaction response once. `DeferredDispatch.complete` and
`schedule` are injectable for tests. Interaction tokens live only in memory,
never receipts or controls. A process interruption after acknowledgement can
leave the private response unfinished; restarting never replays the public send.
Run `/progress` again to inspect status. Public snapshot and live deliveries
retain their existing persistent quotas, nonce and uncertain-delivery guards.
`progress-image.ts` renders the focused 30-day snapshot at 1200×860 with bundled
Mina Sans / Instrument Serif, shared theme colors, and existing habit shades.
The 30 cells retain fixed readable sizes; symbols are Canvas paths. Text summaries
and buttons accompany the PNG. To produce review images without sending messages:
`PROGRESS_IMAGE_REVIEW_DIR=/tmp/minabot-progress-review bun test src/discord/progress-image.test.ts`.
## Integrated commands
All commands use global registration with server and bot-DM contexts. `/help`
reads this same registration metadata, so its command list stays in sync.
| Command | Function |
| --- | --- |
| `/help` | Command guide and website link |
| `/today` | Daily manual/count/task check-ins |
| `/habits` | Create, edit, schedule, archive, restore, permanently delete |
| `/tasks` | Add, rename, schedule, delete routine tasks |
| `/history` | Inspect and correct dated progress, including archived habits |
| `/reminders` | Named schedules, multiple times, quiet hours, enable/disable |
| `/settings` | Timezone and reminder delivery status |
| `/progress` | Private graphics, confirmed snapshot sharing, live-image controls |
After the deployment controller installs a release, register its definitions:
```sh
cd /opt/minabot
sudo docker compose exec -T app bun scripts/register-discord-commands.ts --apply
```
Registration upserts individual global commands and preserves unrelated commands.
The public key and interactions URL are one-time setup; adding commands requires
rerunning registration after their handlers have deployed.
## Additional command graphics
`/today` includes **View day card**, summarizing all due habits in pages of six.
The selected daily check-in and `/history` editor include **View check-in card**,
showing the selected date's requirements, task completion, carryover where
applicable, and a 14-day progress strip ending on that date. `/reminders` includes
**View schedule card**, showing each time slot, weekday/date schedule, quiet-hour
conflicts, and delivery status. Schedule cards retain disabled/unavailable states.
Management forms and `/help` remain text-based because their controls already
communicate the relevant choices.
These are private snapshots: **Refresh card**, **Toggle theme**, and pagination
request a fresh owned snapshot. Regular check-ins keep their existing immediate
text responses. Graphic actions acknowledge before rendering and attach a PNG to
a new private reply; they never publish to the sharing channel or write progress.
Each image has a text equivalent and attachment description. A render failure
retains the text and navigation. No new command registration or configuration is
needed after deploying this change.
Generate local visual review artifacts with
`COMMAND_IMAGE_REVIEW_DIR=/tmp/minabot-command-review bun test src/discord/command-graphics.test.ts`.

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

View 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 } : {} };
}

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

11
src/discord/commands.ts Normal file
View File

@@ -0,0 +1,11 @@
// Global commands are required for bot DMs; guild-only registrations cannot run there.
export const commands = [
{ name: 'help', description: 'Show commands and how to use Minabot' },
{ name: 'tasks', description: 'Add, rename, schedule and delete habit tasks' },
{ name: 'habits', description: 'Create, edit, archive and restore your habits' },
{ name: 'reminders', description: 'Manage named reminders, schedules, and quiet hours' },
{ name: 'settings', description: 'Set your timezone and view delivery settings' },
{ name: 'progress', description: 'Preview progress and manage public snapshot or live sharing' },
{ name: 'today', description: 'View and complete today’s habits' },
{ name: 'history', description: 'View and correct habit progress for a calendar date' },
].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] }));

112
src/discord/daily.test.ts Normal file
View File

@@ -0,0 +1,112 @@
import { afterEach, expect, test } from 'bun:test';
import { eq } from 'drizzle-orm';
import { discordFixture, control, submit } from './test-fixture';
import { discordControls, reminderSettings } from '../db/schema';
import { defaultReminder } from '../reminders/contracts';
import { sendDueReminders } from '../reminders/worker';
import { reminderCheckIn } from './daily';
import { ControlStore } from './state';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
const click = (id: string, values?: string[]) => ({ type: 3, message: { id: '123' }, data: { custom_id: id, ...(values ? { values } : {}) } });
async function open(habitId: string, guild = false) {
const t = await f.run({ data: { name: 'today' } }, guild);
return f.run(click(control(t, 'Choose a habit'), [habitId]), guild);
}
for (const guild of [false, true]) {
test(`daily count carry, above-target, reversal and recoverable modal saves (${guild ? 'server' : 'DM'})`, async () => {
f = discordFixture();
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'count', target: 10, carryPartialProgress: true }, 201);
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 });
f.setDiscordTime('2026-09-05T12:00:00Z');
let d = await open(h.id, guild); expect(d.data.content).toContain('Carried from 2026-09-04');
d = await f.run(click(control(d, '+1')), guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(8);
let m = await f.run(click(control(d, 'Enter amount')), guild);
const invalid = await f.run(submit(m.data.custom_id, { amount: '-1' }), guild);
expect(invalid.data.content).toContain('whole number'); expect(invalid.data.flags).toBe(guild ? 64 : undefined);
m = await f.run(click(control(invalid, 'Try again')), guild);
d = await f.run(submit(m.data.custom_id, { amount: '12' }), guild);
expect(d.data.content).toContain('12/10');
m = await f.run(click(control(d, 'Enter amount')), guild);
d = await f.run(submit(m.data.custom_id, { amount: '0' }), guild);
expect(d.data.content).toContain('Incomplete');
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(0);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(7);
expect(f.changed).toHaveLength(3);
});
test(`manual and paginated task completion reverse and reflect website edits (${guild ? 'server' : 'DM'})`, async () => {
f = discordFixture();
const manual = await f.json('/habits', 'POST', { name: 'Walk', method: 'manual' }, 201);
let d = await open(manual.id, guild);
d = await f.run(click(control(d, 'Mark complete')), guild);
expect((await f.json(`/habits/${manual.id}/days/2026-09-04`)).complete).toBe(true);
d = await f.run(click(control(d, 'Mark incomplete')), guild);
expect((await f.json(`/habits/${manual.id}/days/2026-09-04`)).complete).toBe(false);
const h = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: Array.from({ length: 11 }, (_, n) => ({ name: `Task ${n + 1}` })) }, 201);
d = await open(h.id, guild);
d = await f.run(click(control(d, 'Next')), guild);
expect(d.data.content).toContain('Task 11'); expect(d.data.content).not.toContain('Task 10');
const day = await f.json(`/habits/${h.id}/days/2026-09-04`);
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(1);
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
// Website task changes invalidate an already-open Discord selection.
for (const task of day.tasks) await f.json(`/habits/${h.id}/days/2026-09-04/tasks/${task.taskId}`, 'PUT', { done: true });
const stale = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
expect(stale.data.content).toContain('Progress changed');
d = await f.run(click(control(stale, 'Refresh')), guild);
expect(d.data.content).toContain('11/11 tasks · Complete');
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).complete).toBe(false);
await f.json(`/habits/${manual.id}/days/2026-09-04/progress`, 'PUT', { done: true });
expect((await open(manual.id, guild)).data.content).toContain('Complete');
});
}
test('habit pagination clamps after website archives', async () => {
f = discordFixture(); const habits = [];
for (let n = 0; n < 11; n++) habits.push(await f.json('/habits', 'POST', { name: `Habit ${n}`, method: 'manual' }, 201));
const t = await f.run({ data: { name: 'today' } });
const next = control(t, 'Next');
for (const h of habits.slice(1)) await f.json(`/habits/${h.id}`, 'PATCH', { archived: true });
const refreshed = await f.run(click(next));
expect(refreshed.data.content).toContain('Habit 0');
});
test('daily controls reject midnight rollover even within their fifteen minute lifetime', async () => {
f = discordFixture(); f.setDiscordTime('2026-09-04T21:59:00Z');
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
const d = await open(h.id);
f.setDiscordTime('2026-09-04T22:00:00Z');
const expired = await f.run(click(control(d, 'Mark complete')));
expect(expired.data.content).toContain('day changed');
const fresh = await f.run(click(control(expired, 'Today')));
expect(fresh.data.content).toContain('2026-09-05');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
});
test('reminder buttons are owner bound, open a new flow, expire at local midnight, and preserve deduplication', async () => {
f = discordFixture(); f.setDiscordTime('2026-09-04T18:00:00Z');
await f.json('/habits', 'POST', { name: 'Walk', method: 'manual' }, 201);
f.db.insert(reminderSettings).values({ userId: 'alice', ...defaultReminder, enabled: true, updatedAt: 0 }).run();
const sent: any[] = []; const now = Date.parse('2026-09-04T18:00:00Z');
const send = () => sendDueReminders(f.db, { token: 'fake', origin: f.origin, commandsAvailable: true }, async (url, init) => {
if (url.endsWith('/users/@me/channels')) return Response.json({ id: '223456789012345678' });
sent.push(JSON.parse(String(init?.body))); return Response.json({ id: '323456789012345678' });
}, () => now);
await Promise.all([send(), send()]); await send(); expect(sent).toHaveLength(1);
const id = control({ data: sent[0] }, 'Check in');
expect(f.db.select().from(discordControls).where(eq(discordControls.id, id.slice(5))).get()?.expiresAt).toBe(Date.parse('2026-09-04T22:00:00Z'));
expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another user');
for (const guild of [false, true]) {
const flow = await f.run(click(id), guild); expect(flow.type).toBe(4); expect(flow.data.flags).toBe(guild ? 64 : undefined);
expect(flow.data.content).toContain('Walk');
}
f.setDiscordTime('2026-09-04T22:00:00Z'); expect((await f.run(click(id))).data.content).toContain('expired');
});
test('reminder expiry follows DST boundary', () => {
f = discordFixture();
const now = Date.parse('2026-10-25T00:30:00Z');
const components = reminderCheckIn(f.db, 'alice', 'Europe/Belgrade', now);
const id = control({ data: { components } }, 'Check in');
expect(new ControlStore(f.db, 'alice', now).read(id).expiresAt).toBe(Date.parse('2026-10-25T23:00:00Z'));
});

73
src/discord/daily.ts Normal file
View File

@@ -0,0 +1,73 @@
import { ZodError } from 'zod';
import { ApiError } from '../habits/service';
import { endOfDay, localDate } from '../habits/calendar';
import { ControlStore } from './state';
import type { AppDatabase } from '../auth';
import { progressInput } from '../habits/contracts';
import type { Context, ControlData, Reply } from './types';
import { fields } from './types';
import { button, message, modal, pages, row, select, text } from './ui';
const pageFor = (page: number | undefined, total: number) => Math.min(Math.max(0, Math.trunc(page ?? 0)), Math.max(0, Math.ceil(total / 10) - 1));
const snapshot = (d: ReturnType<Context['service']['day']>) => JSON.stringify([d.value, d.revisionId, d.loggedCount, d.carriedFrom, d.tasks.map(t => [t.taskId, t.done])]);
export function reminderCheckIn(db: AppDatabase, userId: string, timezone: string, now: number) {
const date = localDate(now, timezone);
return [row({ type: 2, style: 1, label: 'Check in', custom_id: new ControlStore(db, userId, now).create('today', { date, mode: 'reminder' }, endOfDay(date, timezone) - now) })];
}
export function today(c: Context, data: ControlData = {}): Reply {
if (data.date && data.date !== c.service.today) throw new ApiError(409, 'That reminder is from an earlier day. Run /today for a fresh check-in.');
const all = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due);
const page = pageFor(data.page, all.length), visible = all.slice(page * 10, page * 10 + 10);
return message(c, `**Today · ${c.service.today}** · ${text(c.service.user.timezone)}\n${all.filter(h => h.complete).length}/${all.length} complete\n\n${visible.map(h => `${h.complete ? '✓' : '○'} ${text(h.name ?? 'Habit')} — ${h.value}/${h.target} ${text(h.unit ?? '')}`).join('\n') || 'No habits due. Use /habits to create or manage your habits.'}`,
[...(visible.length ? [select(c, 'Choose a habit', 'daily.select', { date: c.service.today, today: true }, visible.map(h => ({ id: h.habitId, name: h.name ?? 'Habit' })))] : []), ...pages(c, 'today', { date: c.service.today, page }, all.length), row(button(c, 'Refresh', 'today'), button(c, 'View day card', 'graphic.today', { date: c.service.today }))], c.interaction.data?.custom_id?.startsWith('mina:') && data.mode !== 'reminder');
}
export function detail(c: Context, data: ControlData): Reply {
const date = data.date ?? c.service.today;
if (data.today && date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today to check in.');
const d = c.service.day(data.habitId!, date);
data = { ...data, date, page: pageFor(data.page, d.tasks.length) };
const state: ControlData = { ...data, expected: snapshot(d) };
const controls = [];
if (d.due && !d.future) {
if (d.method === 'manual') controls.push(row(button(c, d.complete ? 'Mark incomplete' : 'Mark complete', 'daily.save', { ...state, done: !d.complete }, 1)));
if (d.method === 'count') controls.push(row(...(d.value < 1_000_000_000 ? [button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1)] : []), ...(d.value > 0 ? [button(c, '−1', 'daily.save', { ...state, value: d.value - 1 })] : []), button(c, 'Enter amount', 'daily.amount', state)));
if (d.method === 'tasks') {
const visible = d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10);
controls.push(select(c, 'Choose a task to change its completion', 'daily.task', state, visible.map(t => ({ id: t.taskId, name: `${t.done ? '✓ ' : '○ '}${t.name}` }))));
controls.push(...pages(c, 'daily.detail', data, d.tasks.length));
}
}
controls.push(row(button(c, 'Refresh', 'daily.detail', data), button(c, 'Today', 'today'), 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 {
if (action === 'today') return today(c, data);
if (action === 'daily.select') return detail(c, { ...data, page: 0, mode: undefined, habitId: c.interaction.data?.values?.[0] });
if (action === 'daily.detail') return detail(c, data);
if (action === 'daily.amount' && data.today && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today again.');
if (action === 'daily.amount') return modal(c, 'Record amount', 'daily.save', data, [{ id: 'amount', label: 'Total for this date', value: String(c.service.day(data.habitId!, data.date!).value), max: 10 }]);
if (action === 'daily.save' || action === 'daily.task') {
if (data.today && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today again.');
const d = c.service.day(data.habitId!, data.date!);
if (data.expected !== snapshot(d)) throw new ApiError(409, 'Progress changed since this message was opened. Refresh it before saving.');
if (action === 'daily.task') {
const task = d.tasks.find(t => t.taskId === c.interaction.data?.values?.[0]);
if (!task) throw new ApiError(404, 'Task not found.');
c.service.completeTask(data.habitId!, data.date!, task.taskId, !task.done);
} else {
const amount = fields(c.interaction).amount;
c.service.progress(data.habitId!, data.date!, progressInput.parse(data.done !== undefined ? { done: data.done } : { count: amount === undefined ? data.value : /^\d+$/.test(amount) ? Number(amount) : NaN }));
}
c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return detail(c, data);
}
}
export function dailyAction(c: Context, name: string, data: ControlData): Reply | undefined {
try { return action(c, name, data); }
catch (error) {
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;
const invalid = error instanceof ZodError;
const recover = invalid ? button(c, 'Try again', 'daily.amount', data) : button(c, 'Refresh', data.habitId ? 'daily.detail' : 'today', data);
return message(c, invalid ? 'Enter a whole number from 0 to 1,000,000,000. This is the total for the date, including any carried progress. Nothing was saved.' : error.message,
[row(recover, button(c, 'Today', 'today'))], false);
}
}

33
src/discord/help.test.ts Normal file
View File

@@ -0,0 +1,33 @@
import { afterEach, expect, test } from 'bun:test';
import { discordFixture } from './test-fixture';
import { commands } from './commands';
import { help } from './help';
import { discordControls, discordReceipts, users } from '../db/schema';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
for (const guild of [false, true]) test(`/help works in ${guild ? 'servers privately' : 'bot DMs'} without creating an account`, async () => {
f = discordFixture();
const before = f.db.select().from(users).all();
const response = await f.run({ data: { name: 'help' } }, guild, '555555555555555555');
expect(response.type).toBe(4);
expect(response.data.flags).toBe(guild ? 64 : undefined);
expect(response.data.allowed_mentions).toEqual({ parse: [] });
for (const command of commands) expect(response.data.content).toContain(`**/${command.name}** — ${command.description}`);
expect(response.data.components[0].components[0].url).toBe(`${f.origin}/`);
expect(f.db.select().from(users).all()).toEqual(before);
expect(f.db.select().from(discordControls).all()).toEqual([]);
expect(f.db.select().from(discordReceipts).all()).toEqual([]);
expect(f.changed).toEqual([]);
});
test('help stays within Discord message limits as command definitions grow', () => {
const response = help(true, 'https://example.com');
expect('content' in response.data! && response.data.content!.length).toBeLessThanOrEqual(2000);
expect(commands.find(c => c.name === 'help')?.contexts).toEqual([0, 1]);
});
test('unknown commands point users to help', async () => {
f = discordFixture();
expect((await f.run({ data: { name: 'unknown' } }, true)).data.content).toContain('/help');
});

22
src/discord/help.ts Normal file
View File

@@ -0,0 +1,22 @@
import { commands } from './commands';
import type { Reply } from './types';
/** Use registration metadata so newly integrated commands appear automatically. */
export function help(guild: boolean, origin: string): Reply {
return { type: 4, data: {
content: [
'**Minabot help**',
'Manage the same habits here and on the website. Commands work in servers and in DMs with the bot.',
'',
...commands.map(command => `**/${command.name}** — ${command.description}`),
'',
'Start with /today, then choose a habit and use its buttons to record progress. Use the forms for amounts and other details.',
'If a control expires or progress changes elsewhere, run the command again to get fresh controls.',
'',
'Personal command replies in servers are visible only to you. Sharing progress requires an explicit sharing action.',
].join('\n'),
...(guild ? { flags: 64 } : {}),
allowed_mentions: { parse: [] },
components: [{ type: 1, components: [{ type: 2, style: 5, label: 'Open website', url: `${origin}/` }] }],
} };
}

199
src/discord/history.test.ts Normal file
View File

@@ -0,0 +1,199 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { control, discordFixture, submit } from './test-fixture';
import { commands } from './commands';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
const items = (reply: any): any[] => reply.data.components.flatMap((r: any) => r.components ?? []);
async function click(reply: any, label: string, guild = false, values?: string[], user?: string) {
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), ...(values ? { values } : {}) } }, guild, user);
}
async function open(habitId: string, date: string, guild = false) {
const list = await f.run({ data: { name: 'history' } }, guild);
const form = await click(list, 'Choose a habit', guild, [habitId]);
expect(form.type).toBe(9);
return f.run(submit(form.data.custom_id, { date }), guild);
}
async function changeDate(reply: any, date: string, guild = false) {
const form = await click(reply, 'Change date', guild);
return f.run(submit(form.data.custom_id, { date }), guild);
}
function readOnly(reply: any) {
expect(reply.data.content).toContain('Read-only');
expect(items(reply).some(i => ['+1', 'Enter amount', 'Mark complete', 'Mark incomplete'].includes(i.label) || i.type === 3)).toBe(false);
}
describe('/history', () => {
test('is global and available in both interaction contexts', () => {
expect(commands.find(c => c.name === 'history')).toMatchObject({ type: 1, contexts: [0, 1], integration_types: [0] });
});
for (const guild of [false, true]) test(`corrects archived count/manual/task history from ${guild ? 'server' : 'DM'} using dated website requirements`, async () => {
f = discordFixture();
const h = await f.json('/habits', 'POST', { name: 'Practice', method: 'count', target: 5, unit: 'pages' }, 201);
f.setDiscordTime('2026-09-05T12:00:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { target: 20, unit: 'chapters' });
f.setDiscordTime('2026-09-06T12:00:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { method: 'manual' });
f.setDiscordTime('2026-09-07T12:00:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { method: 'tasks' });
const task = await f.json(`/habits/${h.id}/tasks`, 'POST', { name: 'Original task' }, 201);
f.setDiscordTime('2026-09-08T12:00:00Z');
await f.json(`/habits/${h.id}/tasks/${task.id}`, 'PATCH', { name: 'New task', schedule: { type: 'weekdays', days: [1] } });
f.setDiscordTime('2026-09-09T12:00:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { archived: true });
const list = await f.run({ data: { name: 'history' } }, guild);
expect(list.data.flags).toBe(guild ? 64 : undefined);
expect(items(list)[0].options[0].description).toContain('Archived');
let d = await open(h.id, '2026-09-04', guild);
expect(d.data.content).toContain('0/5 pages');
const amount = await click(d, 'Enter amount', guild);
d = await f.run(submit(amount.data.custom_id, { amount: '4' }), guild);
expect(d.data.content).toContain('4/5 pages');
expect(control(d, 'Change date')).toBeTruthy();
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(4);
d = await click(d, 'Next date', guild);
expect(d.data.content).toContain('0/20 chapters');
d = await click(d, 'Next date', guild);
expect(d.data.content).toContain('Checkbox');
d = await click(d, 'Mark complete', guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-06`)).complete).toBe(true);
d = await click(d, 'Mark incomplete', guild);
expect((await f.json(`/habits/${h.id}/days/2026-09-06`)).complete).toBe(false);
d = await click(d, 'Next date', guild);
expect(d.data.content).toContain('Original task');
expect(d.data.content).not.toContain('New task');
d = await click(d, 'Choose a task to change its completion', guild, [task.id]);
expect((await f.json(`/habits/${h.id}/days/2026-09-07`)).tasks[0].done).toBe(true);
d = await click(d, 'Next date', guild);
readOnly(d);
d = await click(d, 'Next date', guild);
readOnly(d);
expect(d.data.content).toContain('archived on this date');
expect(f.changed).toEqual(['alice', 'alice', 'alice', 'alice']);
expect((await f.json(`/habits/${h.id}`)).archived).toBe(true);
});
test('validates actual calendar dates and navigates read-only days and date boundaries', async () => {
f = discordFixture();
const h = await f.json('/habits', 'POST', { name: 'Friday', method: 'manual', schedule: { type: 'weekdays', days: [5] } }, 201);
let d = await open(h.id, '2026-02-30');
expect(d.data.content).toContain('real calendar date');
for (const date of ['2025-02-29', '09/04/2026', '1969-12-31', '9999-01-01']) {
d = await changeDate(d, date);
expect(d.data.content).toContain('real calendar date');
}
d = await changeDate(d, '2024-02-29');
readOnly(d);
expect(d.data.content).toContain('not tracked yet');
d = await changeDate(d, '2026-09-05');
expect(d.data.content).toContain('Future dates are read-only');
expect(items(d).some(i => i.label === 'Mark complete')).toBe(false);
f.setDiscordTime('2026-09-06T12:00:00Z');
d = await open(h.id, '2026-09-05');
readOnly(d);
expect(d.data.content).toContain('Friday');
d = await changeDate(d, '1970-01-01');
expect(items(d).some(i => i.label === 'Previous date')).toBe(false);
d = await changeDate(d, '9998-12-31');
expect(items(d).some(i => i.label === 'Next date')).toBe(false);
expect(f.changed).toEqual([]);
});
test('paginates habits and dated tasks, preserving history through task pagination and refresh', async () => {
f = discordFixture();
for (let i = 0; i < 12; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201);
const h = await f.json('/habits', 'POST', { name: 'Checklist', method: 'tasks', tasks: Array.from({ length: 23 }, (_, i) => ({ name: `Task ${i}` })) }, 201);
let list = await f.run({ data: { name: 'history' } });
expect(items(list)[0].options).toHaveLength(10);
list = await click(list, 'Next');
expect(items(list)[0].options).toHaveLength(3);
expect((await click(list, 'Previous')).data.content).toContain('Page 1 of 2');
const form = await click(list, 'Choose a habit', false, [h.id]);
let d = await f.run(submit(form.data.custom_id, { date: '2026-09-04' }));
d = await click(d, 'Next');
expect(items(d)[0].options[0].label).toBe('○ Task 10');
expect(d.data.components.length).toBeLessThanOrEqual(5);
d = await click(d, 'Choose a task to change its completion', false, [h.tasks[10].id]);
expect(d.data.content).toContain('✓ Task 10');
d = await click(d, 'Refresh');
expect(d.data.content).toContain('✓ Task 10');
expect(control(d, 'Change date')).toBeTruthy();
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).tasks[10].done).toBe(true);
});
for (const guild of [false, true]) test(`rejects cross-account controls and forged selections in ${guild ? 'server' : 'DM'}`, async () => {
f = discordFixture();
const h = await f.json('/habits', 'POST', { name: 'Secret', method: 'manual' }, 201);
await f.json('/habits', 'POST', { name: 'Bobs habit', method: 'manual' }, 201, 'b');
const a = await f.run({ data: { name: 'history' } }, guild);
const rejected = await click(a, 'Choose a habit', guild, [h.id], f.bob);
expect(rejected.data.content).toContain('belongs to another user');
expect(rejected.data.flags).toBe(guild ? 64 : undefined);
const b = await f.run({ data: { name: 'history' } }, guild, f.bob);
expect(JSON.stringify(b)).not.toContain('Secret');
expect((await click(b, 'Choose a habit', guild, [h.id], f.bob)).data.content).toBe('Not found');
const d = await open(h.id, '2026-09-04', guild);
expect((await click(d, 'Mark complete', guild, undefined, f.bob)).data.content).toContain('belongs to another user');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
expect(f.changed).toEqual([]);
});
test('keeps stale controls, mutation consumption, replay receipts and website edits consistent', async () => {
f = discordFixture();
const h = await f.json('/habits', 'POST', { name: 'Count', method: 'count', target: 10 }, 201);
f.setDiscordTime('2026-09-05T12:00:00Z');
let d = await open(h.id, '2026-09-04');
const oldAmount = await click(d, 'Enter amount');
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 8 });
expect((await f.run(submit(oldAmount.data.custom_id, { amount: '2' }))).data.content).toContain('Progress changed');
d = await click(d, 'Refresh');
expect(d.data.content).toContain('8/10');
const interaction = f.payload({ type: 3, message: { id: '123' }, data: { custom_id: control(d, '+1') } });
const saved = await (await f.requestDiscord(interaction)).json();
f.restart();
expect(await (await f.requestDiscord(interaction)).json()).toEqual(saved);
expect((await click(d, '+1')).data.content).toContain('expired');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(9);
expect((await f.json(`/habits/${h.id}/days/2026-09-04/audit`)).records[0].events).toHaveLength(2);
expect(f.changed).toEqual(['alice']);
d = await open(h.id, '2026-09-04');
f.setDiscordTime('2026-09-05T12:16:00Z');
expect((await click(d, '+1')).data.content).toContain('expired');
});
test('uses account-local dates at midnight and retains historical record timezone', async () => {
f = discordFixture();
const h = await f.json('/habits', 'POST', { name: 'Local day', method: 'manual' }, 201);
f.setDiscordTime('2026-09-04T22:05:00Z');
const list = await f.run({ data: { name: 'history' } });
const form = await click(list, 'Choose a habit', false, [h.id]);
expect(form.data.components[0].components[0].value).toBe('2026-09-05');
await f.json('/me', 'PATCH', { timezone: 'America/New_York' });
const d = await open(h.id, '2026-09-04');
expect(d.data.content).toContain('Europe/Belgrade');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).timezone).toBe('Europe/Belgrade');
});
test('keeps earlier schedule requirements editable after a schedule change', async () => {
f = discordFixture();
const empty = await f.run({ data: { name: 'history' } });
expect(empty.data.content).toContain('No habits yet');
expect(items(empty)).toEqual([]);
const h = await f.json('/habits', 'POST', { name: 'Practice', method: 'manual' }, 201);
f.setDiscordTime('2026-09-05T12:00:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
let d = await open(h.id, '2026-09-04');
expect(d.data.content).toContain('Every day');
d = await click(d, 'Mark complete');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).complete).toBe(true);
d = await click(d, 'Next date');
readOnly(d);
expect(d.data.content).toContain('Monday');
d = await changeDate(d, '2026-09-07');
expect(d.data.content).toContain('Future dates are read-only');
expect(items(d).some(i => i.label === 'Mark complete')).toBe(false);
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).due).toBe(false);
});
});

64
src/discord/history.ts Normal file
View File

@@ -0,0 +1,64 @@
import { addDays } from '../habits/calendar';
import { dateSchema, type Schedule } from '../habits/contracts';
import { dailyAction, detail } from './daily';
import { fields, type Context, type ControlData, type Message, type Reply } from './types';
import { button, message, modal, pages, row, select, text } from './ui';
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
function scheduleLabel(schedule: Schedule): string {
if (schedule.type === 'daily') return 'Every day';
if (schedule.type === 'weekdays') return schedule.days.map(day => weekdays[day]).join(', ');
if (schedule.type === 'interval') return `Every ${schedule.every} days from ${schedule.anchor}`;
return `Every ${schedule.every} weeks on ${weekdays[schedule.weekday]} from ${schedule.anchor}`;
}
export function history(c: Context, data: ControlData = {}): Reply {
const all = c.service.list(true);
const page = Math.max(0, Math.min(data.page ?? 0, Math.ceil(all.length / 10) - 1));
const state: ControlData = { date: data.date ?? c.service.today, page, mode: 'history', today: false };
const visible = all.slice(page * 10, page * 10 + 10);
return message(c, `**History** · ${text(c.service.user.timezone)}\nChoose a habit, then enter a calendar date. Archived habits are included.${all.length ? `\nPage ${page + 1} of ${Math.ceil(all.length / 10)}` : '\nNo habits yet. Use /habits to create one.'}`,
[...(visible.length ? [select(c, 'Choose a habit', 'history.select', state, visible.map(h => ({ id: h.id, name: h.name, description: `${h.archived ? 'Archived · ' : ''}Tracked since ${h.createdDate}` })))] : []), ...pages(c, 'history', state, all.length)]);
}
function dateForm(c: Context, data: ControlData): Reply {
c.service.owned(data.habitId!);
return modal(c, 'Choose history date', 'history.date', data, [{ id: 'date', label: 'Calendar date (YYYY-MM-DD)', value: data.date ?? c.service.today, max: 10 }]);
}
// Keep the dated editor and all mutation checks in daily.ts. Its opaque payloads
// preserve mode/today, so subsequent saves, forms and task pages return here.
function withNavigation(c: Context, data: ControlData, reply: Reply): Reply {
if (![4, 7].includes(reply.type)) return reply;
const d = c.service.day(data.habitId!, data.date!);
const state: ControlData = { habitId: data.habitId, date: data.date, mode: 'history', today: false, page: 0 };
const navigation = [
...(data.date! > '1970-01-01' ? [button(c, 'Previous date', 'history.detail', { ...state, date: addDays(data.date!, -1) })] : []),
...(data.date! < '9998-12-31' ? [button(c, 'Next date', 'history.detail', { ...state, date: addDays(data.date!, 1) })] : []),
button(c, 'Change date', 'history.form', state),
button(c, 'Choose habit', 'history', { date: data.date }),
];
const explanation = !d.requirements ? 'Read-only · This habit was not tracked yet.'
: d.requirements.archived ? 'Read-only · This habit was archived on this date.'
: !d.due ? 'Read-only · Day off; nothing is scheduled for this date.' : '';
const body = reply.data as Message;
const requirements = d.requirements ? `${d.method === 'manual' ? 'Checkbox' : d.method === 'count' ? 'Count' : 'Tasks'} · ${scheduleLabel(d.requirements.schedule)}\n` : '';
return { ...reply, data: { ...body, content: `${requirements}${text(d.timezone)}\n${explanation ? explanation + '\n' : ''}${body.content ?? ''}`.slice(0, 2000), components: [...(body.components ?? []), row(...navigation)] } };
}
export function historyAction(c: Context, action: string, data: ControlData): Reply | undefined {
if (action === 'history') return history(c, data);
if (action === 'history.select') return dateForm(c, { ...data, habitId: c.interaction.data?.values?.[0], page: 0 });
if (action === 'history.form') return dateForm(c, data);
if (action === 'history.date') {
const parsed = dateSchema.safeParse(fields(c.interaction).date?.trim());
if (!parsed.success) return message(c, 'Enter a real calendar date as YYYY-MM-DD, from 1970 through 9998.', [row(button(c, 'Change date', 'history.form', data))]);
data = { ...data, date: parsed.data, page: 0 };
action = 'history.detail';
}
if (action === 'history.detail' || (data.mode === 'history' && action.startsWith('daily.'))) {
const state = { ...data, date: dateSchema.parse(data.date), mode: 'history', today: false };
const reply = action === 'history.detail' ? detail(c, state) : dailyAction(c, action, state);
return reply && withNavigation(c, state, reply);
}
}

View File

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

View File

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

187
src/discord/management.ts Normal file
View File

@@ -0,0 +1,187 @@
import { habitInput, habitPatch, taskInput, taskPatch, scheduleSchema, type Schedule } from '../habits/contracts';
import { ApiError } from '../habits/service';
import { fields, type Context, type ControlData, type Reply } from './types';
import { button, message, modal, pages, row, select, text } from './ui';
const prefix = 'manage:';
const methods = [{ id: 'manual', name: 'Manual check-in' }, { id: 'count', name: 'Count toward a target' }, { id: 'tasks', name: 'Checklist of tasks' }];
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const recurrence = [{ id: 'daily', name: 'Every day' }, { id: 'weekdays', name: 'Selected weekdays' }, { id: 'interval', name: 'Every N days' }, { id: 'weekly', name: 'Every N weeks' }];
function stamp(c: Context, id: string) {
const h = c.service.current(id), day = c.service.day(id, c.service.today);
return JSON.stringify([h.revisionId, c.service.settings(id), c.service.today, c.service.user.timezone, day.value, day.tasks.map(t => [t.taskId, t.done])]);
}
function snapshot(c: Context, id: string): ControlData { return { habitId: id, expected: stamp(c, id) }; }
function check(c: Context, d: ControlData) {
if (!d.habitId || d.expected !== stamp(c, d.habitId)) throw new ApiError(409, 'This habit changed. Run /habits or /tasks again before saving.');
return c.service.current(d.habitId);
}
function consume(c: Context) { c.controls.consume(c.interaction.data!.custom_id!); c.changed(); }
function chosen(c: Context, allowed: string[]) {
const value = c.interaction.data?.values?.[0];
if (!value || !allowed.includes(value)) throw new ApiError(422, 'Choose one of the available options.');
return value;
}
export function scheduleLabel(s: Schedule): string {
if (s.type === 'daily') return 'Every day';
if (s.type === 'weekdays') return s.days.map(d => weekdays[d]).join(', ');
if (s.type === 'interval') return `Every ${s.every} days from ${s.anchor}`;
return `Every ${s.every} weeks on ${weekdays[s.weekday]} from ${s.anchor}`;
}
export function manageHabits(c: Context, d: ControlData = {}): Reply {
const archived = d.mode === 'archived';
const habits = c.service.list(true).filter(h => h.archived === archived);
const page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(habits.length / 10) - 1)));
const state = { mode: archived ? 'archived' : 'active', page };
return message(c, `**${archived ? 'Archived' : 'Active'} habits** · Page ${page + 1}\n${habits.length ? 'Choose a habit to manage.' : 'No habits here yet.'}`, [
...(habits.length ? [select(c, 'Choose a habit to manage', prefix + 'open', state, habits.slice(page * 10, page * 10 + 10).map(h => ({ id: h.id, name: h.name, description: scheduleLabel(h.schedule) })))] : []),
row(button(c, 'Create habit', prefix + 'create'), button(c, archived ? 'Browse active' : 'Browse archived', prefix + 'list', { mode: archived ? 'active' : 'archived' })),
...pages(c, prefix + 'list', state, habits.length),
]);
}
function detail(c: Context, id: string): Reply {
const h = c.service.current(id), d = snapshot(c, id);
return message(c, `**${text(h.name)}**${h.archived ? ' · Archived' : ''}\n${h.method === 'count' ? `${h.target} ${text(h.unit)} · Carry partial progress: ${h.carryPartialProgress ? 'on' : 'off'}` : h.method === 'tasks' ? `${h.tasks.length} tasks` : 'Manual check-in'}\n${scheduleLabel(h.schedule)}\nColor: ${c.service.settings(id).mainColor}\nChanges apply from ${c.service.today} (${text(c.service.user.timezone)}); earlier requirements remain unchanged.`, [
row(button(c, 'Edit details', prefix + 'edit', d), button(c, 'Change schedule', prefix + 'schedule', d), button(c, 'Change method', prefix + 'method', d)),
...(h.method === 'tasks' && !h.archived ? [row(button(c, 'Manage tasks', prefix + 'tasks-list', d))] : []),
...(h.method === 'count' ? [row(button(c, h.carryPartialProgress ? 'Disable carryover' : 'Enable carryover', prefix + 'carry', d))] : []),
row(button(c, h.archived ? 'Restore habit' : 'Archive habit', prefix + 'confirm', { ...d, mode: h.archived ? 'restore' : 'archive' }), ...(h.archived ? [button(c, 'Permanently delete', prefix + 'confirm', { ...d, mode: 'delete' }, 4)] : []), button(c, 'Back to habits', prefix + 'list')),
]);
}
function detailsForm(c: Context, d: ControlData, creating = false): Reply {
const h = creating ? undefined : check(c, d), method = d.method ?? h!.method;
return modal(c, creating ? 'Create habit' : 'Edit habit', prefix + (creating ? 'save-create' : 'save-details'), { ...d, method }, [
{ id: 'name', label: 'Habit name', value: h?.name },
...(method === 'count' ? [{ id: 'target', label: 'Target (1–10000)', value: h?.method === 'count' ? String(h.target) : '1' }, { id: 'unit', label: 'Unit (pages, steps, minutes…)', max: 80, value: h?.method === 'count' ? h.unit : 'times' }] : []),
{ id: 'color', label: 'Color (#RRGGBB)', value: h ? c.service.settings(h.id).mainColor : '#196127', max: 7 },
]);
}
function schedulePicker(c: Context, d: ControlData): Reply {
check(c, d);
return message(c, 'Choose a recurrence. Dates use your account timezone.', [select(c, 'Recurrence', prefix + 'schedule-kind', d, recurrence), row(button(c, 'Cancel', prefix + (d.taskId ? 'task-detail' : 'detail'), d))]);
}
function saveSchedule(c: Context, d: ControlData, schedule: Schedule): Reply {
check(c, d);
if (d.taskId) c.service.changeTask(d.habitId!, d.taskId, taskPatch.parse({ schedule }));
else c.service.edit(d.habitId!, habitPatch.parse({ schedule }));
consume(c); return d.taskId ? taskDetail(c, d) : detail(c, d.habitId!);
}
export function managementAction(c: Context, action: string, d: ControlData): Reply | undefined {
if (!action.startsWith(prefix)) return;
const a = action.slice(prefix.length);
const taskReply = taskAction(c, a, d);
if (taskReply) return taskReply;
if (a === 'list') return manageHabits(c, d);
if (a === 'open') return detail(c, chosen(c, c.service.list(true).filter(h => h.archived === (d.mode === 'archived')).map(h => h.id)));
if (a === 'detail') return detail(c, d.habitId!);
if (a === 'create') return message(c, 'Choose how to track your habit. New habits start with a daily schedule; you can change it after creating.', [select(c, 'Tracking method', prefix + 'create-method', {}, methods)]);
if (a === 'create-method') return detailsForm(c, { method: chosen(c, methods.map(m => m.id)) }, true);
if (a === 'save-create') {
const f = fields(c.interaction);
const h = c.service.create(habitInput.parse({ name: f.name, color: f.color, method: d.method, ...(d.method === 'count' ? { target: Number(f.target), unit: f.unit } : {}) }));
consume(c); return detail(c, h.id);
}
if (a === 'edit') return detailsForm(c, d);
if (a === 'save-details') {
check(c, d); const f = fields(c.interaction);
c.service.edit(d.habitId!, habitPatch.parse({ name: f.name, color: f.color, method: d.method, ...(d.method === 'count' ? { target: Number(f.target), unit: f.unit } : {}) }));
consume(c); return detail(c, d.habitId!);
}
if (a === 'carry') { const h = check(c, d); if (h.method !== 'count') throw new ApiError(409, 'This habit no longer uses counts.'); c.service.edit(h.id, { carryPartialProgress: !h.carryPartialProgress }); consume(c); return detail(c, h.id); }
if (a === 'method') { const h = check(c, d); return message(c, 'Choose a new tracking method.', [select(c, 'New tracking method', prefix + 'method-confirm', d, methods.filter(m => m.id !== h.method))]); }
if (a === 'method-confirm') {
const h = check(c, d), method = chosen(c, methods.filter(m => m.id !== h.method).map(m => m.id));
return message(c, 'Changing method resets today’s progress. Switching away from tasks removes the current checklist; switching to tasks starts an empty checklist. Earlier dated history stays available. Count carryover starts off when switching to count. Continue?', [row(button(c, 'Continue conversion', prefix + 'convert', { ...d, method }, 4), button(c, 'Cancel', prefix + 'detail', d))]);
}
if (a === 'convert') {
check(c, d);
if (d.method === 'count') return detailsForm(c, d);
c.service.edit(d.habitId!, habitPatch.parse({ method: d.method })); consume(c); return detail(c, d.habitId!);
}
if (a === 'confirm') {
const h = check(c, d);
return message(c, d.mode === 'delete' ? `Permanently delete ${text(h.name)} and all its history? This cannot be undone and removes it from combined charts.` : `${d.mode === 'restore' ? 'Restore' : 'Archive'} ${text(h.name)} from today? Earlier history is preserved.`, [row(button(c, 'Confirm', prefix + 'apply-status', d, d.mode === 'delete' ? 4 : 1), button(c, 'Cancel', prefix + 'detail', d))]);
}
if (a === 'apply-status') { check(c, d); if (d.mode === 'delete') c.service.permanentlyDelete(d.habitId!); else c.service.edit(d.habitId!, { archived: d.mode === 'archive' }); consume(c); return d.mode === 'delete' ? manageHabits(c, { mode: 'archived' }) : detail(c, d.habitId!); }
if (a === 'schedule') return schedulePicker(c, d);
if (a === 'schedule-kind') {
const h = check(c, d), kind = chosen(c, recurrence.map(r => r.id));
if (kind === 'daily') return saveSchedule(c, d, { type: 'daily' });
if (kind === 'weekdays') {
const menu = select(c, 'Select weekdays', prefix + 'save-weekdays', d, weekdays.map((name, i) => ({ id: String(i), name })));
Object.assign((menu.components as Record<string, unknown>[])[0]!, { min_values: 1, max_values: 7 });
return message(c, 'Select all days on which this repeats.', [menu]);
}
const s = d.taskId ? c.service.tasks(h.id).find(t => t.id === d.taskId)?.schedule : h.schedule;
if (!s) throw new ApiError(404, 'Task not found. Run /tasks again.');
return modal(c, kind === 'weekly' ? 'Repeat every N weeks' : 'Repeat every N days', prefix + 'save-interval', { ...d, mode: kind }, [
{ id: 'every', label: kind === 'weekly' ? 'Number of weeks (1–520)' : 'Number of days (1–3650)', value: 'every' in s ? String(s.every) : '1' },
{ id: 'anchor', label: 'Anchor date (YYYY-MM-DD)', value: 'anchor' in s ? s.anchor : c.service.today, max: 10 },
...(kind === 'weekly' ? [{ id: 'weekday', label: 'Weekday (Monday, Tuesday…)', value: s.type === 'weekly' ? weekdays[s.weekday] : 'Monday' }] : []),
]);
}
if (a === 'save-weekdays') return saveSchedule(c, d, scheduleSchema.parse({ type: 'weekdays', days: (c.interaction.data?.values ?? []).map(Number) }));
if (a === 'save-interval') {
const f = fields(c.interaction), weekday = weekdays.findIndex(day => day.toLowerCase() === f.weekday?.trim().toLowerCase());
return saveSchedule(c, d, scheduleSchema.parse({ type: d.mode, every: Number(f.every), anchor: f.anchor, ...(d.mode === 'weekly' ? { weekday } : {}) }));
}
throw new ApiError(422, 'Unknown management action. Run /habits again.');
}
export function manageTasks(c: Context, d: ControlData = {}): Reply {
const habits = c.service.list().filter(h => h.method === 'tasks');
const page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(habits.length / 10) - 1)));
return message(c, `**Task habits** · Page ${page + 1}\n${habits.length ? 'Choose a habit to manage its checklist.' : 'Create a habit with the checklist method to add tasks.'}`, [
...(habits.length ? [select(c, 'Choose a task habit', prefix + 'tasks-open', {}, habits.slice(page * 10, page * 10 + 10).map(h => ({ id: h.id, name: h.name })))] : []),
row(button(c, 'Create habit', prefix + 'create'), button(c, 'All habits', prefix + 'list')),
...pages(c, prefix + 'tasks-home', { page }, habits.length),
]);
}
function taskList(c: Context, d: ControlData): Reply {
const h = c.service.current(d.habitId!);
if (h.archived) throw new ApiError(409, 'Restore the habit in /habits before editing tasks.');
const tasks = c.service.tasks(h.id), page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(tasks.length / 10) - 1)));
const state = { ...snapshot(c, h.id), page };
return message(c, `**${text(h.name)} · Tasks** · Page ${page + 1}\nHabit recurrence: ${scheduleLabel(h.schedule)}\nTasks are due only when their own and the habit’s schedules coincide. ${tasks.length ? '' : 'Add your first task.'}`, [
...(tasks.length ? [select(c, 'Choose a task', prefix + 'task-open', state, tasks.slice(page * 10, page * 10 + 10).map(t => ({ id: t.id, name: t.name, description: scheduleLabel(t.schedule) })))] : []),
row(button(c, 'Add task', prefix + 'task-add', state), button(c, 'Habit details', prefix + 'detail', state), button(c, 'Other task habits', prefix + 'tasks-home')),
...pages(c, prefix + 'tasks-list', state, tasks.length),
]);
}
function taskDetail(c: Context, d: ControlData): Reply {
const h = c.service.current(d.habitId!), task = c.service.tasks(h.id).find(t => t.id === d.taskId);
if (!task) throw new ApiError(404, 'Task not found. Run /tasks again.');
if (h.archived) throw new ApiError(409, 'Restore the habit in /habits before editing tasks.');
const state = { ...snapshot(c, h.id), taskId: task.id };
const warning = h.warnings.find(w => w.taskId === task.id);
return message(c, `**${text(task.name)}** · ${text(h.name)}\n${scheduleLabel(task.schedule)}\n${warning ? text(warning.message) : 'Due when the task and habit schedules coincide.'}`, [
row(button(c, 'Rename task', prefix + 'task-rename', state), button(c, 'Change task schedule', prefix + 'schedule', state), button(c, 'Delete task', prefix + 'task-delete', state, 4)),
row(button(c, 'Back to tasks', prefix + 'tasks-list', state)),
]);
}
function taskAction(c: Context, a: string, d: ControlData): Reply | undefined {
if (a === 'tasks-home') return manageTasks(c, d);
if (a === 'tasks-open') return taskList(c, { habitId: chosen(c, c.service.list().filter(h => h.method === 'tasks').map(h => h.id)) });
if (a === 'tasks-list') return taskList(c, d);
if (a === 'task-open') { check(c, d); return taskDetail(c, { ...d, taskId: chosen(c, c.service.tasks(d.habitId!).map(t => t.id)) }); }
if (a === 'task-detail') return taskDetail(c, d);
if (a === 'task-add' || a === 'task-rename') {
check(c, d);
const task = a === 'task-rename' ? c.service.tasks(d.habitId!).find(t => t.id === d.taskId) : undefined;
if (a === 'task-rename' && !task) throw new ApiError(404, 'Task not found.');
return modal(c, task ? 'Rename task' : 'Add task (daily recurrence)', prefix + 'task-save', { ...d, taskId: task?.id }, [{ id: 'name', label: 'Task name', value: task?.name }]);
}
if (a === 'task-save') {
check(c, d);
const input = { name: fields(c.interaction).name };
const task = c.service.changeTask(d.habitId!, d.taskId, d.taskId ? taskPatch.parse(input) : taskInput.parse(input))!;
consume(c); return taskDetail(c, { ...d, taskId: task.id });
}
if (a === 'task-delete') {
check(c, d); const task = c.service.tasks(d.habitId!).find(t => t.id === d.taskId);
if (!task) throw new ApiError(404, 'Task not found.');
return message(c, `Delete ${text(task.name)} from this checklist starting today? Today’s completion total may change. Earlier dated history remains. Adding it again creates a new task without its old completion state.`, [row(button(c, 'Confirm task deletion', prefix + 'task-delete-confirm', d, 4), button(c, 'Cancel', prefix + 'task-detail', d))]);
}
if (a === 'task-delete-confirm') { check(c, d); c.service.changeTask(d.habitId!, d.taskId, null); consume(c); return taskList(c, { habitId: d.habitId }); }
}

View File

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

113
src/discord/preferences.ts Normal file
View File

@@ -0,0 +1,113 @@
import { eq } from 'drizzle-orm';
import { users } from '../db/schema';
import { timezoneInput } from '../habits/contracts';
import { ApiError } from '../habits/service';
import { ReminderService } from '../reminders/service';
import type { ReminderEntry } from '../reminders/contracts';
import type { Context, ControlData, Reply } from './types';
import { fields } from './types';
import { button, message, modal, pages, row, select, text } from './ui';
const deliveryLabels: Record<string, string> = {
sent: 'Delivered', deferred: 'Waiting to retry', uncertain: 'Delivery unconfirmed; check Discord. This attempt will not be retried.',
failed: 'Could not deliver. Check your DM permissions and that you share a server with the bot.', skipped: 'Skipped because check-ins or settings changed',
};
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const service = (c: Context) => new ReminderService(c.db, c.service.user.id, c.remindersAvailable ?? false, c.now);
const user = (c: Context) => c.db.select().from(users).where(eq(users.id, c.service.user.id)).get()!;
const timezoneVersion = (c: Context) => { const u = user(c); return JSON.stringify([u.timezone, u.updatedAt]); };
const snapshot = (c: Context, data: ControlData = {}): ControlData => ({ ...data, preferenceVersion: service(c).version(), timezoneVersion: timezoneVersion(c) });
function current(c: Context, data: ControlData, timezoneOnly = false) {
if (data.timezoneVersion !== timezoneVersion(c) || (!timezoneOnly && data.preferenceVersion !== service(c).version()))
throw new ApiError(409, 'Settings changed since this control was opened. Run /settings or /reminders to refresh.');
}
function groups(c: Context) {
const result: { name: string; entry: ReminderEntry; indexes: number[]; times: string[] }[] = [];
service(c).read().reminders.forEach((entry, index) => {
const key = (e: ReminderEntry) => JSON.stringify([e.name ?? '', e.type, e.type === 'date' ? e.date : [...e.days].sort()]);
let group = result.find(g => key(g.entry) === key(entry));
if (!group) { group = { name: entry.name || 'Unnamed reminder', entry, indexes: [], times: [] }; result.push(group); }
group.indexes.push(index); group.times.push(entry.time);
});
return result;
}
function group(c: Context, data: ControlData) {
const found = groups(c)[data.index ?? -1];
if (!found) throw new ApiError(404, 'Reminder not found. Run /reminders to refresh.');
return found;
}
const schedule = (e: ReminderEntry) => e.type === 'date' ? e.date : e.days.length === 7 ? 'Every day' : e.days.map(d => weekdays[d]).join(', ');
export function preferences(c: Context, command: string, data: ControlData = {}): Reply {
const prefs = service(c).read(), tz = user(c).timezone, state = snapshot(c, data);
const status = `Delivery: ${prefs.available ? prefs.enabled ? 'enabled' : 'disabled' : 'not configured on this server'}\nLast delivery: ${prefs.lastDelivery ? `${prefs.lastDelivery.date} · ${deliveryLabels[prefs.lastDelivery.status] ?? 'Unknown status'}` : 'none yet'}\nQuiet hours: ${prefs.quietStart}–${prefs.quietEnd}${prefs.quietStart === prefs.quietEnd ? ' (off)' : ''}`;
if (command === 'settings') return message(c, `**Settings**\nTimezone: ${text(tz)}${tz === 'UTC' ? '\nNew accounts start in UTC. Set your timezone for local habit days and reminder times.' : ''}\n${status}`, [row(button(c, 'Change timezone', 'prefs.timezone', state), button(c, 'Manage reminders', 'prefs.list'))]);
const all = groups(c), page = Math.max(0, Math.min(data.page ?? 0, Math.ceil(all.length / 10) - 1)), visible = all.slice(page * 10, page * 10 + 10);
return message(c, `**Reminders · ${text(tz)}**\n${status}\nReminders arrive by DM only when habits remain unfinished. Times use your timezone.\n${tz === 'UTC' ? 'Use Settings to choose your local timezone.\n' : ''}\n${visible.map(g => `• ${text(g.name)}: ${g.times.join(', ')} · ${schedule(g.entry)}`).join('\n') || 'No reminders. Add one to get started.'}\n${prefs.reminders.length}/20 time slots`, [
...(visible.length ? [select(c, 'Choose a reminder', 'prefs.select', state, visible.map((g, i) => ({ id: String(page * 10 + i), name: g.name, description: `${g.times.join(', ')} · ${schedule(g.entry)}` })))] : []),
...pages(c, 'prefs.list', { page }, all.length),
row(button(c, 'Add reminder', 'prefs.edit', { ...state, index: undefined }), button(c, prefs.enabled ? 'Disable delivery' : 'Enable delivery', 'prefs.toggle', { ...state, done: !prefs.enabled }), button(c, 'Quiet hours', 'prefs.quiet', state)),
row(button(c, 'Settings', 'prefs.settings'), button(c, 'Refresh', 'prefs.list', { page }), button(c, 'View schedule card', 'graphic.reminders')),
]);
}
function edit(c: Context, data: ControlData): Reply {
const g = data.index === undefined ? undefined : group(c, data), draft = data.draft;
return modal(c, g ? 'Edit reminder' : 'Add reminder', 'prefs.save', data, [
{ id: 'name', label: 'Reminder name', value: draft?.name ?? g?.entry.name ?? '', max: 80 },
{ id: 'times', label: 'Times (24-hour, separated by commas)', value: draft?.times ?? g?.times.join(', ') ?? '', placeholder: '09:00, 18:30', max: 160 },
{ id: 'days', label: 'Weekdays (leave blank for a specific date)', value: draft?.days ?? (g?.entry.type === 'weekly' ? g.entry.days.map(d => weekdays[d]).join(', ') : ''), placeholder: 'Mon, Wed, Fri or every day', required: false, max: 100 },
{ id: 'date', label: 'Specific date (leave blank for weekdays)', value: draft?.date ?? (g?.entry.type === 'date' ? g.entry.date : ''), placeholder: '2026-09-30', required: false, max: 10 },
]);
}
function parseReminder(input: Record<string, string>): ReminderEntry[] {
const name = input.name?.trim(), times = input.times?.split(',').map(t => t.trim()) ?? [], daysText = input.days?.trim() ?? '', date = input.date?.trim() ?? '';
if (!name || name.length > 80) throw new ApiError(422, 'Enter a reminder name (up to 80 characters).');
if (!times.length || times.some(t => !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(t)) || new Set(times).size !== times.length)
throw new ApiError(422, 'Use distinct 24-hour times separated by commas, for example 09:00, 18:30.');
if (!!daysText === !!date) throw new ApiError(422, 'Enter either weekdays or a specific date, leaving the other field blank.');
if (date) return times.map(time => ({ type: 'date', name, time, date }));
const days = /^(every day|daily)$/i.test(daysText) ? [0, 1, 2, 3, 4, 5, 6] : daysText.toLowerCase().split(/[\s,]+/).map(day => ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].findIndex(full => day === full || day === full.slice(0, 3)));
if (days.some(d => d < 0) || new Set(days).size !== days.length) throw new ApiError(422, 'Choose distinct weekdays such as Mon, Wed, Fri, or enter every day.');
return times.map(time => ({ type: 'weekly', name, time, days }));
}
function save(c: Context, data: ControlData, changes: Record<string, unknown>) {
const prefs = service(c).read();
service(c).save({ enabled: prefs.enabled, reminders: prefs.reminders, quietStart: prefs.quietStart, quietEnd: prefs.quietEnd, ...changes }, data.preferenceVersion);
c.controls.consume(c.interaction.data!.custom_id!); c.changed();
return preferences(c, 'reminders');
}
export function preferencesAction(c: Context, action: string, data: ControlData): Reply | undefined {
if (!action.startsWith('prefs.')) return;
if (action === 'prefs.list') return preferences(c, 'reminders', data);
if (action === 'prefs.settings') return preferences(c, 'settings');
current(c, data, action === 'prefs.timezone' || action === 'prefs.timezoneSave');
if (action === 'prefs.timezone') return modal(c, 'Change timezone', 'prefs.timezoneSave', data, [{ id: 'timezone', label: 'Timezone (region/city)', value: data.draft?.timezone ?? user(c).timezone, placeholder: 'Europe/Belgrade, America/New_York, UTC', max: 100 }]);
if (action === 'prefs.quiet') { const p = service(c).read(); return modal(c, 'Quiet hours (same times = off)', 'prefs.quietSave', data, [{ id: 'start', label: 'Start (24-hour HH:MM)', value: data.draft?.start ?? p.quietStart, max: 5 }, { id: 'end', label: 'End (24-hour HH:MM)', value: data.draft?.end ?? p.quietEnd, max: 5 }]); }
if (action === 'prefs.edit') return edit(c, data);
if (action === 'prefs.select') {
const selected = c.interaction.data?.values?.[0] ?? '';
if (!/^\d+$/.test(selected)) throw new ApiError(404, 'Reminder not found.');
const state = { ...data, index: Number(selected) }, g = group(c, state);
return message(c, `**${text(g.name)}**\n${g.times.join(', ')} · ${schedule(g.entry)}\nTimezone: ${text(user(c).timezone)}`, [row(button(c, 'Edit reminder', 'prefs.edit', state), button(c, 'Remove reminder', 'prefs.remove', state, 4), button(c, 'Back', 'prefs.list'))]);
}
if (action === 'prefs.remove') return message(c, `Remove **${text(group(c, data).name)}** and all its times?`, [row(button(c, 'Confirm removal', 'prefs.removeSave', data, 4), button(c, 'Cancel', 'prefs.list'))]);
try {
if (action === 'prefs.timezoneSave') {
const parsed = timezoneInput.safeParse({ timezone: fields(c.interaction).timezone?.trim() });
if (!parsed.success) throw new ApiError(422, 'Use a timezone such as Europe/Belgrade, America/New_York, or UTC.');
c.service.setTimezone(parsed.data.timezone); c.controls.consume(c.interaction.data!.custom_id!); c.changed();
return preferences(c, 'settings');
}
if (action === 'prefs.quietSave') { const input = fields(c.interaction); return save(c, data, { quietStart: input.start?.trim(), quietEnd: input.end?.trim() }); }
if (action === 'prefs.toggle') return save(c, data, { enabled: data.done });
if (action === 'prefs.save' || action === 'prefs.removeSave') {
const removed = data.index === undefined ? [] : group(c, data).indexes;
const remaining = service(c).read().reminders.filter((_, i) => !removed.includes(i));
const reminders = [...remaining, ...(action === 'prefs.save' ? parseReminder(fields(c.interaction)) : [])];
return save(c, data, { reminders, ...(!reminders.length ? { enabled: false } : {}) });
}
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 422) throw error;
const retry = { 'prefs.save': 'prefs.edit', 'prefs.quietSave': 'prefs.quiet', 'prefs.timezoneSave': 'prefs.timezone' }[action];
return message(c, error.message, [row(...(retry ? [button(c, 'Try again', retry, { ...data, draft: fields(c.interaction) })] : []), button(c, 'Reminders', 'prefs.list'), button(c, 'Settings', 'prefs.settings'))], false);
}
}

View File

@@ -0,0 +1,34 @@
import { expect, test } from 'bun:test';
import { createCanvas, loadImage } from '@napi-rs/canvas';
import { renderProgressImage } from './progress-image';
import { addDays } from '../habits/calendar';
import type { LiveImageData } from '../sharing/live-card-image';
import { themePalette } from '../lib/theme';
const sample: LiveImageData = {
name: 'Mina', avatarUrl: null, from: '2026-08-06', to: '2026-09-04',
habits: [{ name: 'Read a little, every day', color: '#285d49', completed: 7, scheduled: 23,
days: Array.from({ length: 30 }, (_, i) => ({ date: addDays('2026-08-06', i), color: ['#eeeeee', '#a9beb5', '#285d49'][i % 3]!, due: i % 4 !== 0, ratio: i % 4 === 0 ? null : [0, .5, 1][i % 3]! })) }],
};
for (const theme of ['light', 'dark'] as const) test(`focused 30-day ${theme} PNG fits upload limits, has all cells and deterministic pixels`, async () => {
const png = await renderProgressImage(sample, theme);
const bytes = Buffer.from(png);
expect(bytes.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a');
expect(bytes.readUInt32BE(16)).toBe(1200); expect(bytes.readUInt32BE(20)).toBe(860);
expect(bytes.length).toBeLessThan(4 * 1024 * 1024);
expect(await renderProgressImage(sample, theme)).toEqual(png);
const image = await loadImage(png), canvas = createCanvas(1200, 860), ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0);
const rgb = (hex: string) => [1,3,5].map(i => parseInt(hex.slice(i, i + 2), 16));
for (let i = 0; i < 30; i++) {
const pixel = [...ctx.getImageData(70 + (i % 10) * 108, 360 + Math.floor(i / 10) * 130, 1, 1).data];
expect(pixel[3]).toBe(255);
expect(pixel.slice(0, 3)).not.toEqual(rgb(themePalette[theme].paper));
}
// Opt-in artifact generation only; normal tests never write output files.
if (process.env.PROGRESS_IMAGE_REVIEW_DIR) await Bun.write(`${process.env.PROGRESS_IMAGE_REVIEW_DIR}/progress-${theme}.png`, png);
});
test('long unicode names render safely and invalid ranges are rejected', async () => {
const long = { ...sample, name: '🌸'.repeat(100), habits: [{ ...sample.habits[0]!, name: '読む 📚 '.repeat(100) }] };
expect((await renderProgressImage(long)).length).toBeLessThan(4 * 1024 * 1024);
expect(renderProgressImage({ ...sample, habits: [] })).rejects.toThrow('Choose one habit');
});

View File

@@ -0,0 +1,71 @@
import { renderImage } from '../rendering/queue';
import { createCanvas } from '@napi-rs/canvas';
// Import the existing renderer's bundled font registration and data contract.
import { type LiveImageData } from '../sharing/live-card-image';
import '../sharing/live-card-image';
import { themePalette, type Theme } from '../lib/theme';
import { themedHabitShade, habitProgressColor } from '../lib/habit-palette';
import { cardDate } from '../lib/progress-card';
export const progressImageSize = { width: 1200, height: 860 };
/** Focused, chronological 30-day view; constant font/cell sizes at any habit count. */
export function renderProgressImage(data: LiveImageData, theme: Theme = 'light', userId = 'unscoped') {
return renderImage('progress', userId, data, theme, () => draw(data, theme));
}
async function draw(data: LiveImageData, theme: Theme) {
const habit = data.habits[0];
if (!habit || data.habits.length !== 1 || habit.days.length !== 30) throw new Error('Choose one habit and a 30-day range.');
const canvas = createCanvas(progressImageSize.width, progressImageSize.height);
const ctx = canvas.getContext('2d'), palette = themePalette[theme];
const rect = (x: number, y: number, width: number, height: number, color: string, radius = 0) => {
ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, width, height, radius); ctx.fill();
};
const text = (value: string, x: number, y: number, size: number, width: number, color: string = palette.ink, serif = false) => {
ctx.font = `400 ${size}px "${serif ? 'Instrument Serif' : 'Mina Sans'}"`; ctx.fillStyle = color;
const chars = Array.from(value); let fitted = value;
while (chars.length && ctx.measureText(fitted).width > width) { chars.pop(); fitted = `${chars.join('')}…`; }
ctx.fillText(fitted, x, y);
};
const color = (ratio: number | null, due: boolean, serverShade?: string) => !due ? palette.avatar : !ratio ? palette.empty : serverShade ? themedHabitShade(serverShade, habit.color, theme) : habitProgressColor(habit.color, ratio, theme);
const contrast = (hex: string) => {
const [r, g, b] = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
return .299 * r! + .587 * g! + .114 * b! > 150 ? '#202a26' : '#ffffff';
};
rect(0, 0, 1200, 860, palette.paper);
rect(0, 0, 1200, 8, habit.color);
text(data.name, 64, 80, 28, 720);
text('Mina Habits.', 940, 82, 40, 200, palette.ink, true);
text(habit.name, 64, 175, 64, 1060, palette.ink, true);
text(`${cardDate(data.from)} – ${cardDate(data.to)}`, 64, 228, 26, 720, palette.muted);
text(`${habit.completed}/${habit.scheduled} days complete`, 800, 228, 26, 350);
rect(64, 261, 1072, 1, palette.rule);
habit.days.forEach((day, index) => {
const x = 64 + (index % 10) * 108, y = 325 + Math.floor(index / 10) * 130;
const label = new Date(`${day.date}T12:00:00Z`).toLocaleDateString('en', { month: 'short', day: 'numeric', timeZone: 'UTC' });
text(label, x, y, 20, 96, palette.muted);
rect(x, y + 15, 96, 70, color(day.ratio, day.due, day.color), 10);
// Draw symbols as paths: bundled text fonts need not contain checkmark glyphs.
const cx = x + 48, cy = y + 50;
ctx.strokeStyle = contrast(color(day.ratio, day.due, day.color));
ctx.fillStyle = ctx.strokeStyle; ctx.lineWidth = 3; ctx.lineCap = 'round';
ctx.beginPath();
if (!day.due) { ctx.moveTo(cx - 7, cy); ctx.lineTo(cx + 7, cy); ctx.stroke(); }
else if ((day.ratio ?? 0) >= 1) { ctx.moveTo(cx - 9, cy); ctx.lineTo(cx - 2, cy + 7); ctx.lineTo(cx + 11, cy - 8); ctx.stroke(); }
else { ctx.arc(cx, cy, day.ratio ? 4 : 8, 0, Math.PI * 2); if (day.ratio) ctx.fill(); else ctx.stroke(); }
});
rect(64, 716, 1072, 1, palette.rule);
const legend = [
{ label: 'Not scheduled', ratio: null, due: false },
{ label: 'No progress', ratio: 0, due: true },
{ label: 'Partial', ratio: 0.5, due: true },
{ label: 'Complete', ratio: 1, due: true },
];
legend.forEach((item, index) => {
const x = 64 + index * 276;
rect(x, 750, 24, 24, color(item.ratio, item.due), 5);
text(item.label, x + 36, 772, 21, 230, palette.muted);
});
text('A little, every day.', 64, 822, 27, 1072, palette.muted, true);
try { return new Uint8Array(await canvas.encode('png')); }
finally { canvas.width = 1; canvas.height = 1; }
}

View File

@@ -0,0 +1,128 @@
import { afterEach, expect, test } from 'bun:test';
import { createAuth } from '../auth';
import { createSharingRoutes } from '../sharing/routes';
import { createLiveDiscord } from '../sharing/live-discord';
import { discordFixture, control } from './test-fixture';
import type { DeferredMessage } from './progress';
let f: ReturnType<typeof discordFixture>;
let stop = () => {};
afterEach(() => { stop(); f?.close(); });
function setup(settings: { allowed?: string[]; fail?: boolean; writes?: boolean } = {}) {
const jobs: (() => Promise<void>)[] = [], replies: DeferredMessage[] = [], sends: RequestInit[] = [];
f = discordFixture((base, now) => {
const config = { token: 'test-bot-token', channelId: '555555555555555555', allowedUserIds: settings.allowed, writesEnabled: settings.writes ?? true };
const request = async (_url: string, init?: RequestInit) => {
if (init?.method === 'POST' || init?.method === 'PATCH') { sends.push(init); if (settings.fail) throw new Error('uncertain'); return Response.json({ id: '777777777777777777' }); }
return Response.json({ id: config.channelId, guild_id: '666666666666666666', type: 0, name: 'habit-sharing' });
};
const auth = createAuth(base.db, { origin: base.origin, clientId: '', clientSecret: '', cookieSecret: 'test-only-secret-at-least-32-characters' }, undefined, now);
const sharing = createSharingRoutes(base.db, auth, config, now, request), live = createLiveDiscord(base.db, auth, config, now, request);
stop = live.stop;
return { progress: { sharing, live }, schedule: job => jobs.push(job), complete: async (_i, result) => { replies.push(result); } };
});
return { jobs, replies, sends, drain: async () => { while (jobs.length) await jobs.shift()!(); return { data: replies.at(-1)!.message }; } };
}
async function click(reply: any, label: string, guild = false, values?: string[]) {
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), values } }, guild);
}
for (const guild of [false, true]) test(`progress preview, explicit snapshot and live lifecycle in ${guild ? 'server' : 'DM'}`, async () => {
const env = setup();
const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201);
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
const panel = await f.run({ data: { name: 'progress' } }, guild);
expect(panel.data.content).toContain('Reading — 1/1');
expect(panel.data.flags).toBe(guild ? 64 : undefined);
expect(await click(panel, 'Preview a habit', guild, [habit.id])).toEqual({ type: 5, data: { flags: 64 } });
expect(env.sends).toHaveLength(0);
const preview = await env.drain();
expect(env.replies.at(-1)!.image!.slice(0, 8)).toEqual(new Uint8Array([137,80,78,71,13,10,26,10]));
expect(preview.data.content).toContain('1/1');
await click(preview, 'Choose public destination', guild);
const confirmation = await env.drain();
expect(confirmation.data.content).toContain('habit-sharing');
expect(confirmation.data.content).toContain('https://discord.com/channels/666666666666666666/555555555555555555');
expect(env.sends).toHaveLength(0);
const i = f.payload({ type: 3, data: { custom_id: control(confirmation, 'Share snapshot publicly') } }, guild);
await f.requestDiscord(i); await env.drain();
f.restart(); await f.requestDiscord(i); await env.drain();
expect(env.sends).toHaveLength(1);
await click(panel, 'Live image controls', guild); const live = await env.drain();
await click(live, 'Start live image publicly', guild); await env.drain();
expect((await f.json('/sharing/live')).status).toBe('active');
expect(env.sends).toHaveLength(2);
await click(panel, 'Live image controls', guild); const active = await env.drain();
await click(active, 'Pause live image', guild); await env.drain();
expect((await f.json('/sharing/live')).status).toBe('paused');
await click(panel, 'Live image controls', guild); const paused = await env.drain();
await click(paused, 'Resume live image publicly', guild); await env.drain();
expect((await f.json('/sharing/live')).status).toBe('active');
expect(env.sends).toHaveLength(2); // Unchanged image keeps the original message.
});
test('pagination, cross-account controls, stale snapshot and uncertain delivery never resend', async () => {
const env = setup({ fail: true });
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
for (let n = 0; n < 6; n++) await f.json('/habits', 'POST', { name: `Habit ${n}`, method: 'manual' }, 201);
const panel = await f.run({ data: { name: 'progress' } });
expect(control(panel, 'Next')).toBeString();
const stolen = await f.run({ type: 3, data: { custom_id: control(panel, 'Preview a habit'), values: [h.id] } }, true, f.bob);
expect(stolen.data.content).toContain('belongs to another user');
await click(panel, 'Preview a habit', false, [h.id]); const preview = await env.drain();
await click(preview, 'Choose public destination'); const confirm = await env.drain();
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true });
expect((await click(confirm, 'Share snapshot publicly')).data.content).toContain('changed');
expect(env.sends).toHaveLength(0);
expect((await click(preview, 'Choose public destination')).data.content).toContain('changed');
await click(panel, 'Preview a habit', false, [h.id]); const newPreview = await env.drain();
await click(newPreview, 'Choose public destination'); const fresh = await env.drain();
await click(fresh, 'Share snapshot publicly'); const result = await env.drain();
expect(result.data.content).toContain('could not confirm');
expect((await click(fresh, 'Share snapshot publicly')).data.content).toContain('expired');
expect(env.sends).toHaveLength(1);
});
test('allowlist and disabled writes apply to command shares', async () => {
const env = setup({ allowed: ['222222222222222222'] });
const panel = await f.run({ data: { name: 'progress' } });
await click(panel, 'Live image controls'); const result = await env.drain();
expect(result.data.content).toContain('approved community');
expect(env.sends).toHaveLength(0);
});
test('disabled writes, shared snapshot quotas, and stale live confirmations', async () => {
const env = setup({ writes: false });
const panel = await f.run({ data: { name: 'progress' } });
await click(panel, 'Live image controls'); const live = await env.drain();
await click(live, 'Start live image publicly'); const denied = await env.drain();
expect(denied.data.content).toContain('disabled in development');
expect(env.sends).toHaveLength(0);
});
test('snapshot quotas are shared with website operations and new controls cannot bypass them', async () => {
const env = setup();
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
const panel = await f.run({ data: { name: 'progress' } });
await click(panel, 'Preview a habit', false, [h.id]); const preview = await env.drain();
await click(preview, 'Choose public destination'); const first = await env.drain();
await click(first, 'Share snapshot publicly'); await env.drain();
await click(preview, 'Choose public destination'); const next = await env.drain();
await click(next, 'Share snapshot publicly'); const limited = await env.drain();
expect(limited.data.content).toContain('Sharing limit reached');
expect(env.sends).toHaveLength(1);
await click(panel, 'Live image controls'); const live = await env.drain();
await f.json('/habits', 'POST', { name: 'New private habit', method: 'manual' }, 201);
expect((await click(live, 'Start live image publicly')).data.content).toContain('changed');
expect(env.sends).toHaveLength(1);
f.setDiscordTime('2026-09-04T12:16:00Z');
expect((await click(preview, 'Choose public destination')).data.content).toContain('expired');
});
test('a website edit during a deferred public share invalidates the prepared snapshot', async () => {
const env = setup();
const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201);
const panel = await f.run({ data: { name: 'progress' } });
await click(panel, 'Preview a habit', false, [habit.id]); const preview = await env.drain();
await click(preview, 'Choose public destination'); const confirmation = await env.drain();
await click(confirmation, 'Share snapshot publicly');
const delivery = env.drain();
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
const result = await delivery;
expect(result.data.content).toContain('changed');
expect(env.sends).toHaveLength(0);
});

99
src/discord/progress.ts Normal file
View File

@@ -0,0 +1,99 @@
import { createHash } from 'node:crypto';
import { renderProgressImage } from './progress-image';
import { ApiError, HabitService } from '../habits/service';
import { liveCardData } from '../sharing/live-card-image';
import type { createSharingRoutes } from '../sharing/routes';
import type { createLiveDiscord } from '../sharing/live-discord';
import type { Context, ControlData, Message, Reply } from './types';
import { button, message, pages, row, select, text } from './ui';
export type ProgressOperations = {
sharing: Pick<ReturnType<typeof createSharingRoutes>, 'send' | 'destination'>;
live: Pick<ReturnType<typeof createLiveDiscord>, 'state' | 'start' | 'pause'>;
};
export type DeferredMessage = { message: Message; image?: Uint8Array; imageDescription?: string };
const fingerprint = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
function imageData(c: Context, data: ControlData) {
// Preparation happened before dispatch. Fresh read caches detect edits across deferred awaits.
const snapshot = new HabitService(c.db, c.service.user, c.now);
const result = liveCardData(c.db, c.service.user.id, c.now, snapshot);
if (data.habitId) {
const habits = snapshot.list();
const index = habits.findIndex(h => h.id === data.habitId);
if (index < 0) throw new ApiError(409, 'This habit changed. Run /progress again.');
result.habits = [result.habits[index]!];
}
return { ...result, avatarUrl: null };
}
async function render(c: Context, data: ControlData, source = imageData(c, data)) {
return renderProgressImage(source, data.theme ?? 'light', c.service.user.id);
}
function output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; }
function defer(c: Context, job: () => Promise<DeferredMessage>): Reply {
if (!c.defer) throw new ApiError(503, 'Progress rendering is unavailable. Try again later.');
c.defer(job);
return { type: 5, data: { flags: 64 } };
}
export function progress(c: Context, data: ControlData = {}): Reply {
const habits = c.service.list(), page = data.page ?? 0;
const visible = habits.slice(page * 6, page * 6 + 6);
const result = imageData(c, {});
const state = c.progress?.live.state(c.service.user.id);
return message(c, `**Progress · ${result.from} to ${result.to}**\n${text(c.service.user.timezone)} · Live image: ${state?.status ?? 'unavailable'}\n\n${visible.map(h => {
const stats = result.habits[habits.findIndex(v => v.id === h.id)]!;
return `${text(h.name)} — ${stats.completed}/${stats.scheduled} scheduled days complete`;
}).join('\n') || 'No active habits. Create a habit on the website or with /habits.'}\n\nPreviews are private. A public share requires confirmation of the configured destination.`, [
...(visible.length ? [select(c, 'Preview a habit', 'progress.preview', {}, visible.map(h => ({ id: h.id, name: h.name })))] : []),
...pages(c, 'progress', data, habits.length, 6),
row(button(c, 'Live image controls', 'progress.live'), button(c, 'Refresh progress', 'progress')),
], false);
}
export function progressAction(c: Context, action: string, data: ControlData): Reply | undefined {
if (action === 'progress') return progress(c, data);
if (!action.startsWith('progress.')) return;
const ops = c.progress;
if (!ops) throw new ApiError(503, 'Progress sharing is unavailable.');
if (action === 'progress.preview') {
const habitId = c.interaction.data?.values?.[0] ?? data.habitId;
c.service.current(habitId!);
const selection = { habitId, theme: data.theme ?? 'light' as const };
return defer(c, async () => {
const content = imageData(c, selection);
const image = await render(c, selection, content);
const confirmed = { ...selection, expected: fingerprint(content) };
const stats = content.habits[0]!;
return output(message(c, `**Private preview · ${text(stats.name)}**\n${content.from} to ${content.to} · ${text(c.service.user.timezone)}\n${stats.completed}/${stats.scheduled} scheduled days complete.\nThe snapshot includes your display name and this habit's name and progress.`, [row(button(c, 'Choose public destination', 'progress.confirm', confirmed), button(c, 'Toggle theme', 'progress.preview', { ...selection, theme: selection.theme === 'light' ? 'dark' : 'light' }))], false), image);
});
}
if (action === 'progress.confirm' && data.expected !== fingerprint(imageData(c, data))) throw new ApiError(409, 'Progress changed. Open a fresh preview before sharing.');
const liveSnapshot = () => ({ state: ops.live.state(c.service.user.id), content: imageData(c, {}) });
if (action === 'progress.confirm' || action === 'progress.live') return defer(c, async () => {
const destination = await ops.sharing.destination(c.service.user);
const state = ops.live.state(c.service.user.id);
const selection = { ...data, expected: action === 'progress.confirm' ? data.expected : fingerprint(liveSnapshot()), deliveryId: crypto.randomUUID(), mode: destination.channelUrl };
return output(message(c, `${action === 'progress.confirm' ? '**Share snapshot?**' : `**Live image · ${state.status}**`}\nPublic destination: [${text(destination.name)}](${destination.channelUrl})\n${action === 'progress.confirm' ? 'Shares your display name and the selected habit’s progress.' : 'The live image includes your display name and all active habit names and progress. It updates in the same message as progress changes.'}${state.messageUrl ? `\n[Open live image](${state.messageUrl})` : ''}${state.error ? `\n${text(state.error)}` : ''}`, [row(...(action === 'progress.confirm' ? [button(c, 'Share snapshot publicly', 'progress.send', selection, 1)] : [button(c, state.status === 'off' ? 'Start live image publicly' : 'Resume live image publicly', 'progress.start', selection, 1), button(c, 'Pause live image', 'progress.pause', selection)])), row(button(c, 'Back to private progress', 'progress'))], false));
});
if (['progress.send', 'progress.start', 'progress.pause'].includes(action)) {
const current = action === 'progress.send' ? imageData(c, data) : liveSnapshot();
if (fingerprint(current) !== data.expected) throw new ApiError(409, 'Progress or live status changed. Open a fresh preview before sharing.');
// Reserve the action in the same transaction as the interaction receipt, before outbound work.
if (!c.defer) throw new ApiError(503, 'Progress rendering is unavailable. Try again later.');
c.controls.consume(c.interaction.data!.custom_id!);
return defer(c, async () => {
if (action !== 'progress.pause') {
const destination = await ops.sharing.destination(c.service.user);
if (destination.channelUrl !== data.mode) throw new ApiError(409, 'The sharing destination changed. Confirm the new destination first.');
}
if (fingerprint(action === 'progress.send' ? imageData(c, data) : liveSnapshot()) !== data.expected) throw new ApiError(409, 'Progress or live status changed. Open a fresh preview before sharing.');
if (action === 'progress.send') {
const source = imageData(c, data);
const image = await render(c, data, source);
if (fingerprint(imageData(c, data)) !== data.expected) throw new ApiError(409, 'Progress changed during rendering. Open a fresh preview.');
const delivery = await ops.sharing.send(c.service.user, data.deliveryId!, new File([new Uint8Array(image)], 'mina-habits-progress.png', { type: 'image/png' }));
return output(message(c, delivery.status === 'sent' ? `Snapshot shared.${delivery.messageUrl ? ` [Open snapshot](${delivery.messageUrl})` : ''}` : 'Discord could not confirm delivery. Check the destination before creating another snapshot; this action will not be retried.', [], false));
}
const state = action === 'progress.pause' ? await ops.live.pause(c.service.user.id) : await ops.live.start(c.service.user, data.theme ?? 'light');
return output(message(c, `Live image: **${state.status}**.${state.messageUrl ? ` [Open live image](${state.messageUrl})` : ''}${state.error ? `\n${text(state.error)}` : ''}`, [row(button(c, 'Live image status', 'progress.live'), button(c, 'Private progress', 'progress'))], false));
});
}
}

View File

@@ -0,0 +1,18 @@
import { expect, test } from 'bun:test';
import { registerCommands } from './registration';
const command = { name: 'today', description: 'Today', type: 1, contexts: [0, 1], integration_types: [0] };
test('registers global commands individually without removing other application commands', async () => {
const calls: { url: string; init?: RequestInit }[] = [];
expect(await registerCommands('111111111111111111', 'fake-token', [command], async (url, init) => { calls.push({ url, init }); return Response.json({}); })).toEqual(['today']);
expect(calls[0]!.url).toBe('https://discord.com/api/v10/applications/111111111111111111/commands');
expect(calls[0]!.init!.method).toBe('POST');
expect(JSON.parse(calls[0]!.init!.body as string).contexts).toEqual([0, 1]);
});
test('rejects missing DM support before sending any request', async () => {
let called = false;
await expect(registerCommands('111111111111111111', 'fake-token', [{ ...command, contexts: [0] }], async () => { called = true; return Response.json({}); })).rejects.toThrow('bot DMs');
expect(called).toBe(false);
});
test('stops on rate limits and does not expose response bodies', async () => {
await expect(registerCommands('111111111111111111', 'fake-token', [command], async () => new Response('private provider data', { status: 429, headers: { 'Retry-After': '5' } }))).rejects.toThrow('HTTP 429');
});

View File

@@ -0,0 +1,26 @@
export type CommandDefinition = { name: string; description: string; type?: number; contexts: number[]; integration_types: number[]; options?: unknown[] };
type Transport = (url: string, init?: RequestInit) => Promise<Response>;
/** Upsert only Minabot's commands. Never bulk-overwrite unrelated application commands. */
export async function registerCommands(applicationId: string, token: string, definitions: CommandDefinition[], request: Transport = fetch) {
if (!/^\d{17,20}$/.test(applicationId) || !token.trim()) throw new Error('Set DISCORD_CLIENT_ID and DISCORD_BOT_TOKEN.');
const names = new Set<string>();
for (const command of definitions) {
if (names.has(command.name)) throw new Error(`Duplicate command: ${command.name}`);
names.add(command.name);
if (!command.contexts.includes(0) || !command.contexts.includes(1)) throw new Error(`${command.name} must support servers and bot DMs.`);
}
const registered: string[] = [];
for (const command of definitions) {
const response = await request(`https://discord.com/api/v10/applications/${applicationId}/commands`, {
method: 'POST', headers: { Authorization: `Bot ${token.trim().replace(/^Bot\s+/i, '')}`, 'Content-Type': 'application/json' },
body: JSON.stringify(command), redirect: 'error', signal: AbortSignal.timeout(15000),
});
if (!response.ok) {
// Provider payloads may contain sensitive data; report status only.
const retry = response.status === 429 ? ` Retry after ${response.headers.get('Retry-After') ?? 'a few'} seconds.` : '';
throw new Error(`Registration of /${command.name} failed (HTTP ${response.status}).${retry} Already registered: ${registered.join(', ') || 'none'}. Rerunning safely upserts commands.`);
}
registered.push(command.name);
}
return registered;
}

View File

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

103
src/discord/routes.ts Normal file
View File

@@ -0,0 +1,103 @@
import { createPublicKey, verify } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { ZodError } from 'zod';
import { publicUser, type AppDatabase } from '../auth';
import { users, discordReceipts } from '../db/schema';
import { ApiError, HabitService } from '../habits/service';
import { ControlStore } from './state';
import { interactionSchema, type Context, type Reply } from './types';
import { dailyAction, today } from './daily';
import { manageHabits, manageTasks, managementAction } from './management';
import { history, historyAction } from './history';
import { preferences, preferencesAction } from './preferences';
import { progress, progressAction, type DeferredMessage, type ProgressOperations } from './progress';
import { message } from './ui';
import { help } from './help';
import { commandGraphicAction } from './command-graphics';
export type CommandConfig = { publicKey: string; applicationId: string; origin: string; remindersAvailable?: boolean };
export type DeferredDispatch = {
progress?: ProgressOperations;
complete?: (interaction: import('./types').Interaction, result: DeferredMessage) => Promise<void>;
schedule?: (job: () => Promise<void>) => void;
};
/** Edit the deferred original exactly once. Never retry an uncertain network result. */
export async function completeDeferred(i: import('./types').Interaction, result: DeferredMessage) {
const body = new FormData();
const payload = { ...result.message, flags: undefined, allowed_mentions: { parse: [] }, attachments: result.image ? [{ id: 0, filename: 'mina-progress.png', description: (result.imageDescription ?? result.message.content)?.slice(0, 1024) }] : [] };
body.set('payload_json', JSON.stringify(payload));
if (result.image) body.set('files[0]', new Blob([new Uint8Array(result.image)], { type: 'image/png' }), 'mina-progress.png');
const response = await fetch(`https://discord.com/api/v10/webhooks/${i.application_id}/${encodeURIComponent(i.token!)}/messages/@original`, { method: 'PATCH', body, redirect: 'error', signal: AbortSignal.timeout(15000) });
if (!response.ok) throw new Error('Deferred Discord response was rejected.');
}
export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now = Date.now, changed: (userId: string) => void = () => {}, dispatch: DeferredDispatch = {}) {
const app = new Hono();
const key = /^[a-f\d]{64}$/i.test(config.publicKey) ? createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(config.publicKey, 'hex')]), format: 'der', type: 'spki' }) : null;
app.use('*', bodyLimit({ maxSize: 64 * 1024 }));
app.post('/', async c => {
c.header('Cache-Control', 'no-store');
if (!key) return c.json({ error: 'Discord commands are not configured.' }, 503);
const signature = c.req.header('X-Signature-Ed25519') ?? '', timestamp = c.req.header('X-Signature-Timestamp') ?? '';
const body = await c.req.text();
if (!/^\d{10,12}$/.test(timestamp) || Math.abs(now() - Number(timestamp) * 1000) > 300000 || !/^[a-f\d]{128}$/i.test(signature) || !verify(null, Buffer.from(timestamp + body), key, Buffer.from(signature, 'hex')))
return c.json({ error: 'Invalid request signature.' }, 401);
let value: unknown;
try { value = JSON.parse(body); } catch { return c.json({ error: "Malformed JSON." }, 400); }
const parsed = interactionSchema.safeParse(value);
if (!parsed.success || parsed.data.application_id !== config.applicationId) return c.json({ error: 'Invalid interaction.' }, 400);
const i = parsed.data;
if (i.type === 1) return c.json({ type: 1 });
const profile = i.guild_id ? i.member?.user : i.user;
if (!profile || ![2, 3, 4, 5].includes(i.type)) return c.json({ error: 'Unsupported interaction.' }, 400);
// Help needs no account creation, habit synchronization, or saved UI state.
if (i.type === 2 && i.data?.name === 'help') return c.json(help(!!i.guild_id, config.origin));
let pending: (() => Promise<DeferredMessage>) | undefined;
// Resolve the account before queueing catch-up; no transaction is held across yields.
const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get();
const userId = existing?.id ?? crypto.randomUUID();
if (!existing) db.insert(users).values({ id: userId, discordId: profile.id, username: profile.username, globalName: profile.global_name, avatarHash: profile.avatar, createdAt: now(), updatedAt: now() }).run();
const receipt = db.select().from(discordReceipts).where(eq(discordReceipts.id, i.id)).get();
if (receipt && receipt.expiresAt > now()) return c.json(receipt.response);
const owner = publicUser(db.select().from(users).where(eq(users.id, userId)).get()!);
const reply = await HabitService.withPrepared(db, owner, now, service => db.transaction((): Reply => {
const receipt = db.select().from(discordReceipts).where(eq(discordReceipts.id, i.id)).get();
if (receipt && receipt.expiresAt > now()) return receipt.response;
const controls = new ControlStore(db, userId, now()); controls.prune();
const ctx: Context = { db, service, controls, interaction: i, now: now(), origin: config.origin, remindersAvailable: config.remindersAvailable ?? false, changed: () => changed(userId), progress: dispatch.progress, defer: i.token ? job => { pending = job; } : undefined };
let result: Reply;
try {
if (i.type === 4) result = { type: 8, data: { choices: [] } };
else if (i.type === 2 && i.data?.name === 'tasks') result = manageTasks(ctx);
else if (i.type === 2 && i.data?.name === 'habits') result = manageHabits(ctx);
else if (i.type === 2 && i.data?.name === 'progress') result = progress(ctx);
else if (i.type === 2 && i.data?.name === 'today') result = today(ctx);
else if (i.type === 2 && ['settings', 'reminders'].includes(i.data?.name ?? '')) result = preferences(ctx, i.data!.name!);
else if (i.type === 2 && i.data?.name === 'history') result = history(ctx);
else if ([3, 5].includes(i.type)) {
const state = controls.read(i.data?.custom_id ?? '');
result = commandGraphicAction(ctx, state.action, state.payload) ?? progressAction(ctx, state.action, state.payload) ?? preferencesAction(ctx, state.action, state.payload) ?? historyAction(ctx, state.action, state.payload) ?? dailyAction(ctx, state.action, state.payload) ?? managementAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false);
} else result = message(ctx, 'Run /help to see available commands.', [], false);
} catch (error) {
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;
result = message(ctx, error instanceof ApiError ? error.message : 'Check the entered values and try again.', [], false);
}
db.insert(discordReceipts).values({ id: i.id, userId, response: result, expiresAt: now() + 86400000 }).run();
return result;
}));
if (pending) {
const work = pending;
const job = async () => {
let result: DeferredMessage;
try { result = await work(); }
catch (error) { result = { message: { content: error instanceof ApiError ? error.message : 'Could not complete this action. Check the destination before trying another share.', components: [], allowed_mentions: { parse: [] } } }; }
try { await (dispatch.complete ?? completeDeferred)(i, result); }
catch { console.error('Discord deferred reply failed; it will not be retried.'); }
};
(dispatch.schedule ?? (job => { setTimeout(() => { void job(); }, 0); }))(job);
}
return c.json(reply);
});
return app;
}

29
src/discord/state.ts Normal file
View File

@@ -0,0 +1,29 @@
import { randomBytes } from 'node:crypto';
import { eq, lt } from 'drizzle-orm';
import type { AppDatabase } from '../auth';
import { discordControls, discordReceipts } from '../db/schema';
import { ApiError } from '../habits/service';
import type { ControlData } from './types';
export class ControlStore {
constructor(readonly db: AppDatabase, readonly userId: string, readonly now: number) {}
create(action: string, payload: ControlData = {}, lifetime = 15 * 60000) {
const id = randomBytes(18).toString('base64url');
this.db.insert(discordControls).values({ id, userId: this.userId, action, payload, expiresAt: this.now + lifetime }).run();
return `mina:${id}`;
}
read(customId: string) {
const row = this.db.select().from(discordControls).where(eq(discordControls.id, customId.replace(/^mina:/, ''))).get();
if (!customId.startsWith('mina:') || !row || row.userId !== this.userId || row.expiresAt <= this.now || row.used)
throw new ApiError(409, 'This control expired or belongs to another user. Run the command again.');
return row;
}
consume(customId: string) {
const row = this.read(customId);
this.db.update(discordControls).set({ used: true }).where(eq(discordControls.id, row.id)).run();
}
prune() {
this.db.delete(discordControls).where(lt(discordControls.expiresAt, this.now)).run();
this.db.delete(discordReceipts).where(lt(discordReceipts.expiresAt, this.now)).run();
}
}

View File

@@ -0,0 +1,37 @@
import { generateKeyPairSync, sign } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { fixture } from '../habits/test-fixture';
import { users } from '../db/schema';
import { createDiscordRoutes, type DeferredDispatch } from './routes';
import type { Interaction } from './types';
export function discordFixture(setup?: boolean | ((f: ReturnType<typeof fixture>, now: () => number) => DeferredDispatch)) {
const f = fixture();
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const key = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex');
let time = Date.parse('2026-09-04T12:00:00Z'), sequence = 10000000000000000n;
const alice = '111111111111111111', bob = '222222222222222222', applicationId = '333333333333333333';
f.db.update(users).set({ discordId: alice }).where(eq(users.id, 'alice')).run();
f.db.update(users).set({ discordId: bob }).where(eq(users.id, 'bob')).run();
const changed: string[] = [];
const dispatch = typeof setup === "function" ? setup(f, () => time) : undefined;
const remindersAvailable = setup === true;
let app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin, remindersAvailable }, () => time, id => changed.push(id), dispatch);
function payload(data: Partial<Interaction>, guild = false, user = alice): Interaction {
const profile = { id: user, username: user === alice ? 'alice' : 'bob' };
return { id: String(++sequence), application_id: applicationId, type: 2, token: 'test-interaction-token', ...(guild ? { guild_id: '444444444444444444', member: { user: profile } } : { user: profile }), ...data };
}
async function request(i: Interaction, headers: Record<string, string> = {}) {
const body = JSON.stringify(i), timestamp = String(time / 1000);
return app.request('http://localhost/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Signature-Timestamp': timestamp, 'X-Signature-Ed25519': sign(null, Buffer.from(timestamp + body), privateKey).toString('hex'), ...headers }, body });
}
async function run(data: Partial<Interaction>, guild = false, user = alice): Promise<any> { return (await request(payload(data, guild, user))).json(); }
return { ...f, alice, bob, changed, payload, requestDiscord: request, run, restart: () => { app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin, remindersAvailable }, () => time, id => changed.push(id), dispatch); }, setDiscordTime: (value: string) => { time = Date.parse(value); f.setTime(value); } };
}
export function control(reply: any, label: string): string {
const item = reply.data.components.flatMap((r: any) => r.components ?? []).find((c: any) => c.label === label || c.placeholder === label);
if (!item) throw new Error(`Missing control ${label}: ${JSON.stringify(reply)}`);
return item.custom_id;
}
export function submit(custom_id: string, values: Record<string, string>) {
return { type: 5, message: { id: '123' }, data: { custom_id, components: Object.entries(values).map(([custom_id, value]) => ({ type: 1, components: [{ type: 4, custom_id, value }] })) } };
}

24
src/discord/types.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { DeferredMessage, ProgressOperations } from './progress';
import { z } from 'zod';
import type { AppDatabase } from '../auth';
import type { HabitService } from '../habits/service';
import type { ControlStore } from './state';
const option: z.ZodType<Option> = z.lazy(() => z.object({ name: z.string(), type: z.number(), value: z.union([z.string(), z.number(), z.boolean()]).optional(), focused: z.boolean().optional(), options: z.array(option).optional() }));
export type Option = { name: string; type: number; value?: string | number | boolean; focused?: boolean; options?: Option[] };
const profile = z.object({ id: z.string().regex(/^\d{17,20}$/), username: z.string().max(100), global_name: z.string().max(100).nullish(), avatar: z.string().max(100).nullish() });
const component: z.ZodType<ComponentInput> = z.lazy(() => z.object({ type: z.number(), custom_id: z.string().optional(), value: z.string().optional(), values: z.array(z.string()).optional(), components: z.array(component).optional() }));
type ComponentInput = { type: number; custom_id?: string; value?: string; values?: string[]; components?: ComponentInput[] };
export const interactionSchema = z.object({ id: z.string().regex(/^\d{17,20}$/), application_id: z.string(), type: z.number(), token: z.string().max(2048).optional(), guild_id: z.string().optional(), user: profile.optional(), member: z.object({ user: profile }).optional(), message: z.object({ id: z.string() }).optional(), data: z.object({ name: z.string().optional(), custom_id: z.string().max(100).optional(), values: z.array(z.string()).optional(), options: z.array(option).optional(), components: z.array(component).optional() }).optional() });
export type Interaction = z.infer<typeof interactionSchema>;
export type DiscordComponent = { type: number; [key: string]: unknown };
export type Message = { content?: string; components?: DiscordComponent[]; flags?: number; allowed_mentions?: { parse: string[] }; attachments?: unknown[]; embeds?: unknown[] };
export type Reply = { type: number; data?: Message | { custom_id: string; title: string; components: DiscordComponent[] } | { choices: { name: string; value: string }[] } };
export type ControlData = { habitId?: string; taskId?: string; date?: string; today?: boolean; page?: number; mode?: string; method?: string; expected?: string; value?: number; done?: boolean; revision?: number; index?: number; theme?: 'light' | 'dark'; deliveryId?: string; preferenceVersion?: string; timezoneVersion?: string; draft?: Record<string, string> };
export type Context = { db: AppDatabase; service: HabitService; controls: ControlStore; interaction: Interaction; now: number; origin: string; remindersAvailable?: boolean; changed: () => void; progress?: ProgressOperations; defer?: (job: () => Promise<DeferredMessage>) => void };
export function fields(i: Interaction) {
const result: Record<string, string> = {};
const walk = (parts: ComponentInput[]) => parts.forEach(c => { if (c.custom_id && c.value !== undefined) result[c.custom_id] = c.value; if (c.components) walk(c.components); });
walk(i.data?.components ?? []); return result;
}
export const options = (i: Interaction) => Object.fromEntries((i.data?.options ?? []).map(o => [o.name, o.value]));

21
src/discord/ui.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { Context, ControlData, DiscordComponent, Reply } from './types';
export const text = (s: string) => s.replace(/([\\`*_~|>])/g, '\\$1').replace(/@/g, '@\u200b');
export const row = (...components: DiscordComponent[]): DiscordComponent => ({ type: 1, components });
export const button = (c: Context, label: string, action: string, payload: ControlData = {}, style = 2): DiscordComponent => ({ type: 2, label, style, custom_id: c.controls.create(action, payload) });
export function message(c: Context, content: string, components: DiscordComponent[] = [], update = true): Reply {
const edit = update && !!c.interaction.message && [3, 5].includes(c.interaction.type);
return { type: edit ? 7 : 4, data: { content: content.slice(0, 2000), components, allowed_mentions: { parse: [] }, ...(!edit && c.interaction.guild_id ? { flags: 64 } : {}) } };
}
export type Field = { id: string; label: string; value?: string; placeholder?: string; required?: boolean; max?: number };
export function modal(c: Context, title: string, action: string, data: ControlData, fields: Field[]): Reply {
return { type: 9, data: { custom_id: c.controls.create(action, data), title: title.slice(0, 45), components: fields.map(f => row({ type: 4, custom_id: f.id, label: f.label.slice(0, 45), style: 1, required: f.required ?? true, max_length: f.max ?? 200, ...(f.value !== undefined ? { value: f.value } : {}), ...(f.placeholder ? { placeholder: f.placeholder.slice(0, 100) } : {}) })) } };
}
export function select(c: Context, placeholder: string, action: string, data: ControlData, items: { id: string; name: string; description?: string }[]) {
return row({ type: 3, custom_id: c.controls.create(action, data), placeholder, options: items.slice(0, 25).map(i => ({ label: i.name.slice(0, 100) || 'Untitled', value: i.id, ...(i.description ? { description: i.description.slice(0, 100) } : {}) })) });
}
export function pages(c: Context, action: string, data: ControlData, total: number, size = 10) {
const page = data.page ?? 0, buttons: DiscordComponent[] = [];
if (page > 0) buttons.push(button(c, 'Previous', action, { ...data, page: page - 1 }));
if ((page + 1) * size < total) buttons.push(button(c, 'Next', action, { ...data, page: page + 1 }));
return buttons.length ? [row(...buttons)] : [];
}

View File

@@ -3,6 +3,9 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { App } from "./App";
import "../styles/globals.css";
import { startPwa } from "./pwa/client";
if (process.env.NODE_ENV === "production") startPwa();
const container = document.getElementById("root")!;
const root = import.meta.hot

View File

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

Some files were not shown because too many files have changed in this diff Show More