Compare commits

...

11 Commits

Author SHA1 Message Date
syntaxbullet
892487eb4c Use shared modals and combine selected-day actions 2026-09-04 19:21:24 +02:00
syntaxbullet
392deb9cbe Apply pending migrations during development reloads 2026-09-04 19:01:16 +02:00
syntaxbullet
865bdd1328 Refine settings, archive, and check-in styling 2026-09-04 18:56:05 +02:00
syntaxbullet
1208e93953 feat: add opt-in daily Discord reminders with quiet hours 2026-09-04 18:45:42 +02:00
syntaxbullet
16d92f171c test: verify request references on database failures 2026-09-04 18:38:58 +02:00
syntaxbullet
19a9aa6862 feat: browse archived habit history and restore habits 2026-09-04 18:33:18 +02:00
syntaxbullet
9c8a96ba03 feat: correlate server errors and monitor application health 2026-09-04 18:30:34 +02:00
syntaxbullet
661bc6c346 feat: automate verified SQLite backups and safe recovery 2026-09-04 18:28:28 +02:00
syntaxbullet
32002151e0 feat: limit Discord sharing and support community allowlists 2026-09-04 18:25:09 +02:00
syntaxbullet
d624ee6c3c feat: add timezone settings data export and account deletion 2026-09-04 18:23:12 +02:00
syntaxbullet
e88ffceb22 feat: edit historical check-ins from habit calendars 2026-09-04 18:20:38 +02:00
53 changed files with 4346 additions and 109 deletions

View File

@@ -14,3 +14,14 @@ AUTH_COOKIE_SECRET=
# View Channel, Send Messages, and Attach Files in this channel.
DISCORD_BOT_TOKEN=
DISCORD_SHARING_CHANNEL_ID=
# Optional comma-separated Discord user IDs permitted to post. Empty permits all accounts.
DISCORD_SHARING_ALLOWED_USER_IDS=
# Production snapshots, plus a snapshot before migrations. Paths resolve beside DATABASE_PATH.
BACKUP_ENABLED=true
BACKUP_DIR=backups
BACKUP_INTERVAL_HOURS=24
BACKUP_RETAIN=7
# Existing mounted off-machine backup directory (absolute path recommended).
BACKUP_REPLICA_DIR=
# Optional public HTTPS health URL for the independent monitor.
MONITOR_URL=

View File

@@ -3,6 +3,8 @@
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.
See [deployment operations](docs/OPERATIONS.md) for backup, recovery, and monitoring.
See [the REST API reference](docs/API.md) for habits, task recurrence, dated progress,
combined calendars, historical corrections, daily resets, and optional count carryover.
@@ -58,18 +60,26 @@ right. It stacks on narrow screens and shows an initial if the avatar is unavail
Signed-in users can create manual, count, or task habits, choose a recurrence and
color, log today’s progress, and inspect their real calendars. Completed-versus-due
counts exclude days off. Inline controls beside habit headings edit the name,
counts exclude days off. Controls beside habit headings open a modal to edit the name,
tracking method, schedule, color, count target/unit, and unfinished-count carryover.
Each task can be renamed, rescheduled, or deleted in place; Add task also supports
Each task can be renamed, rescheduled, or deleted in a modal; Add task also supports
its own recurrence. Both habit and task schedules support daily, selected weekdays,
day intervals, and week intervals with an editable start date and weekday.
Delete asks for confirmation in place and preserves earlier history. Tasks
Archive asks for confirmation in a modal and preserves earlier history.
**Archived habits** opens the archive modal, with history inspection and restoration
starting today. Task deletion uses the same modal confirmation. Tasks
remain manageable on days off, while their checkboxes stay disabled.
Settings, sharing, new habits, and historical check-ins also open in native dialogs.
Dialogs keep the background inactive, contain keyboard focus, support Escape and
backdrop dismissal, and return focus to their opener. Long content scrolls beneath
a fixed header; closing is disabled during saves.
Mutations are saved through the authenticated API; failed
saves retain the recorded values and allow retry. Account and calendar failures
have retry states. The dashboard refreshes on window focus and every minute while
visible, using the server’s date and saved account timezone. Historical editing
and combined-chart management are not exposed on this homepage.
visible, using the server’s date and saved account timezone. Select a calendar date and choose **Edit selected check-in** to correct counts,
checkboxes, or task occurrences using that date’s saved requirements. A date picker
also reaches history outside the displayed year. Future dates and days off are read-only.
Combined-chart management is not exposed on this homepage.
For browser QA without changing application data, run
`bun scripts/dashboard-preview.ts` and visit
@@ -137,8 +147,11 @@ bun run db:migrate
```
Commit the generated `drizzle/` files alongside schema changes. `bun dev` and
`bun start` apply pending migrations before starting the server. The initial
migration creates the users and sessions tables.
`bun start` apply pending migrations before starting the server. Development also
checks for pending migrations when the server entrypoint is hot-reloaded, so new
routes do not run against an older schema. After adding only migration files,
run `bun run db:migrate` or restart `bun dev`. The initial migration creates the
users and sessions tables.
Import `db` from `src/db/index.ts` in backend code to query the database.
`GET /api/health` checks the database connection before returning success.
@@ -215,8 +228,9 @@ production habit data separate from development data.
server-side; Discord access/refresh tokens are never persisted or sent to React.
- React detects the browser timezone during initial registration. The server
validates it using `Intl.DateTimeFormat`, falling back to UTC. Later sign-ins
update the Discord profile without changing the saved timezone. A timezone
settings UI is deferred; `PATCH /api/me` updates the timezone. Habit timestamps
update the Discord profile without changing the saved timezone. **Settings** lets users change their timezone, download a complete JSON export,
and permanently delete their account with typed confirmation. `PATCH /api/me`
updates the timezone. Habit timestamps
remain UTC and daily boundaries follow the saved timezone with preserved historical
deadlines. See the API reference for travel and DST behavior.
@@ -323,3 +337,47 @@ Habit tests cover every route, all PRD acceptance behaviors, ownership and Origi
checks, historical corrections, expiry, timezone boundaries, daily resets, optional
count carryover, Nivo data adaptation, and migration upgrades. Semantic commits
separate the database foundation, REST implementation, carryover, and verification.
### Account data
`GET /api/account/export` downloads versioned JSON containing all owned profile,
habit, revision, dated progress, task, audit, calendar, chart, and delivery records.
It excludes session tokens and credentials. `DELETE /api/account` requires matching
Origin and `{ "confirmation": "DELETE" }`; it atomically removes owned records and
revokes every session. Discord posts and retained operator backups are not erased
by this action.
Discord sharing reserves persistent quotas before posting: one new delivery per
Discord user per 60 seconds, and ten per channel per 60 seconds. Rechecking an
existing delivery does not consume quota. Rejected attempts consume quota too;
429 responses include `Retry-After`. Optional `DISCORD_SHARING_ALLOWED_USER_IDS`
(comma-separated Discord IDs) restricts posting to community members you approve.
An empty list permits all signed-in accounts. Restricted users can still preview
and download PNGs. Quota rows expire and are pruned after 24 hours.
### Daily reminders
Open **Settings → Daily reminders** to opt into one private Discord DM when habits
remain unfinished. Choose a local time and quiet hours; times follow the account
timezone. Quiet-hour start is inclusive, end is exclusive, and equal times disable
quiet hours. If the scheduled time falls in quiet hours, delivery waits until they
end that same local day. A reminder missed for the whole day is not carried forward.
The production server checks once a minute. `DISCORD_BOT_TOKEN` enables delivery;
a sharing channel is not required for reminders. Users must be reachable by the
bot (normally a shared Discord server and enabled DMs). A message contains only
the unfinished-habit count and app link. Settings show the most recent delivery
result, including blocked DMs or unconfirmed delivery. Uncheck the option and save
to opt out. No reminders are enabled by default.
`GET /api/reminders` returns settings and last delivery status. `PUT /api/reminders`
requires authentication, matching Origin, and `{enabled, time, quietStart, quietEnd}`
with `HH:MM` times. Per-user/date reservations persist across restarts and concurrent
workers. Discord 429 retry deadlines are persisted; ambiguous message sends are
not retried that day. Delivery records expire after 90 days. Restore disables
reminders in the recovered database until users opt in again.
The disposable `SHARE_PREVIEW=1 bun scripts/dashboard-preview.ts` mode also runs a
reminder worker against a local transport stub. Set a reminder to `14:00` (its fixed
Europe/Belgrade time), leave a habit unfinished, and inspect delivery status in
settings or `/__preview/reminder`. No real Discord messages are sent in this mode.

View File

@@ -0,0 +1,22 @@
# Launch feature verification — September 4, 2026
| Feature | Implementation and verification |
| --- | --- |
| Historical check-ins | Calendar selection and date picker open dated count, checkbox, and task controls. Integration coverage changes a habit's method and corrects yesterday without changing today. Browser verified a saved historical count and refreshed calendar. |
| Account settings | Timezone changes refresh the dashboard; JSON exports include owned records without session credentials. Typed-confirmation deletion removes dependent records and revokes sessions. API tests cover ownership, Origin checks, confirmation, foreign keys, and another account remaining intact. Browser verified timezone persistence. |
| Discord sharing controls | SQLite reservations enforce one new post per user per minute and ten per channel per minute; duplicate checks remain safe. Tests cover cooldowns, channel quotas, allowlists, and rejected/uncertain delivery behavior. |
| Backup and recovery | Production snapshots, pre-migration snapshot, retention, optional replica directory, and fresh-path recovery. Tests restore committed WAL progress, revoke sessions, disable recovered reminders, verify replicas and retention, reject unavailable storage and existing destinations, and execute both CLIs. |
| Monitoring | Request IDs and sanitized structured error events; database/backup-aware health; independent monitor with sustained-failure and recovery transitions. Tests exercise a closed database and live HTTP probes; one-shot CLI passed against the disposable server. |
| Archive and restore | Dashboard archive action, history view, and restore. Integration and browser checks confirm a restored habit returns to today's dashboard while earlier completion remains intact. |
| Daily reminders | Opt-in settings, timezone/quiet hours, unfinished-only private messages, persistent daily reservations, rate-limit deferral, and delivery status. Tests cover concurrent workers, DST fall-back, opt-out during channel creation, uncertain sends, ownership, export, and deletion. Browser saved the selected time, delivered one message through the local stub, showed Delivered, and saved opt-out. |
Validation commands: `bun run typecheck`, `bun test`, `bun run test:smoke`, and
`git diff --check`. Browser checks used the in-memory preview on port 3107 and
verified desktop and narrow layouts (480px minimum layout viewport available in
the in-app browser), with no page-level horizontal overflow in settings.
Live Discord messages, production account deletion, and deployment were not
performed. Set the production origin, bot credentials/permissions, any sharing
allowlist, durable backup volume/replica, and independent monitor/log collection
when deploying. Off-machine storage and an external alert destination must be
provided by the deployment environment; their provisioning is outside the app.

84
docs/OPERATIONS.md Normal file
View File

@@ -0,0 +1,84 @@
# Deployment operations
## Backups and recovery
Production creates a verified SQLite snapshot before migrations and automatically
when the latest snapshot is older than `BACKUP_INTERVAL_HOURS` (24 by default).
The server checks once a minute. Snapshot files are private (0600), created with
SQLite `VACUUM INTO`, and checked for integrity and foreign-key errors before being
published. Seven snapshots are retained by default; `BACKUP_RETAIN` accepts 2–365.
Pre-migration snapshots are mandatory; a failure prevents migration. Set
`BACKUP_ENABLED=false` only to disable the periodic runner if an external backup
system already handles it. Manual `bun run db:backup` always takes a snapshot.
`BACKUP_DIR` defaults to `backups` beside the database. Relative backup paths are
resolved relative to the database directory, consistently in CLI and production.
Use a durable volume for both the database and backups. Configure
`BACKUP_REPLICA_DIR` to an existing mounted directory on a separate machine/storage
service to keep an off-machine copy. The app does not provision or authenticate
that storage. If the directory is unavailable, backup reports failure and does
not prune old snapshots. Local-only backups do not protect against machine loss.
Snapshots run synchronously; for larger databases use the backup CLI from an
external scheduler and disable the in-process periodic runner.
To rehearse a restore without touching the live database:
```sh
bun run db:backup
bun run db:restore /absolute/path/to/backup.sqlite /absolute/path/to/recovered.sqlite
```
Restore requires a new destination, verifies integrity, and revokes all sessions
in the restored copy. It never overwrites a database or its WAL/SHM sidecars.
Test the recovered copy with the matching release and an isolated local port.
For an actual recovery, stop the service, set `DATABASE_PATH` to the recovered
file, then start the service and sign in again. Keep the original file for rollback.
Do not copy only the live `.sqlite` file: recent commits may be in its WAL.
Backups include account data and session hashes. Protect backup storage and expire
copies according to your published retention period. Restoring an older backup
can restore deleted accounts; reapply deletions performed since the snapshot
before reopening access. Discord images already posted are separate from app data.
Verification: `bun test src/ops/backups.test.ts` exercises WAL progress, restored
content, revoked sessions, replicas, retention, missing storage, and refusal to
overwrite an existing destination.
## Monitoring
Every API response includes a server-generated `X-Request-ID`. Unexpected errors
return that reference to the browser and write a JSON `request_failed` event to
stderr with the request ID, method, route template, error type, and stack locations.
Error messages, SQL values, request bodies, cookies, and query strings are omitted
so credentials and habit content do not enter logs. Production source maps in
`dist/` help map stack locations back to the source. Backup outcomes also emit
`backup_completed` and `backup_failed` events.
`GET /api/health` checks SQLite and returns 503 if enabled automatic backups are
failing or have never succeeded. It returns no account data, paths, or credentials.
Run the health monitor independently of the application process:
```sh
bun run monitor --once
bun run monitor
```
`MONITOR_URL` defaults to `http://127.0.0.1:$PORT/api/health`. For an outside-in
check, run the monitor on another machine and set it to the public HTTPS endpoint.
The one-shot command exits nonzero on failure. Continuous mode checks every minute,
times out after ten seconds, reports `health_failed` after three consecutive
failures, and emits one `health_recovered` event on recovery. It stays quiet while
state is unchanged. Run it under a process supervisor and route these JSON events
to your deployment platform's alerting/log collection. No external alert provider
or off-machine monitor is provisioned automatically by this repository.
## Reminder worker
The production process checks opt-in reminders every minute, with a lock preventing
overlapping runs within the process and transactional delivery reservations across
processes. A crash after reservation may leave a reminder unconfirmed; the worker
chooses not to resend it that day. Known rate-limit rejections defer until Discord's
retry deadline. Quiet hours and completion are checked again immediately before
posting. Recovery disables saved reminder opt-ins to avoid replaying notifications
from an old backup. Use the disposable preview and stubbed transport tests for QA;
real Discord delivery depends on the bot token, shared server, and user DM permissions.

View File

@@ -0,0 +1,5 @@
CREATE TABLE `sharing_limits` (
`key` text PRIMARY KEY NOT NULL,
`started_at` integer NOT NULL,
`count` integer NOT NULL
);

View File

@@ -0,0 +1,20 @@
CREATE TABLE `reminder_deliveries` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`date` text NOT NULL,
`status` text NOT NULL,
`retry_at` integer,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `reminder_deliveries_user_date_idx` ON `reminder_deliveries` (`user_id`,`date`);--> statement-breakpoint
CREATE TABLE `reminder_settings` (
`user_id` text PRIMARY KEY NOT NULL,
`enabled` integer DEFAULT false NOT NULL,
`time` text DEFAULT '20:00' NOT NULL,
`quiet_start` text DEFAULT '22:00' NOT NULL,
`quiet_end` text DEFAULT '08:00' NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);

View File

@@ -0,0 +1,4 @@
CREATE TABLE `reminder_worker_state` (
`id` text PRIMARY KEY NOT NULL,
`blocked_until` integer NOT NULL
);

View File

@@ -0,0 +1,788 @@
{
"version": "6",
"dialect": "sqlite",
"id": "b5822e36-a1a9-42dd-9439-9490297bc097",
"prevId": "8241efb4-d92c-4b72-ac08-a5b5c1cd4a18",
"tables": {
"combined_charts": {
"name": "combined_charts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"habit_ids": {
"name": "habit_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"combined_charts_user_idx": {
"name": "combined_charts_user_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"combined_charts_user_id_users_id_fk": {
"name": "combined_charts_user_id_users_id_fk",
"tableFrom": "combined_charts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"discord_deliveries": {
"name": "discord_deliveries",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image_hash": {
"name": "image_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"message_url": {
"name": "message_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"discord_deliveries_user_id_users_id_fk": {
"name": "discord_deliveries_user_id_users_id_fk",
"tableFrom": "discord_deliveries",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_calendar_settings": {
"name": "habit_calendar_settings",
"columns": {
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"habit_calendar_settings_habit_id_habits_id_fk": {
"name": "habit_calendar_settings_habit_id_habits_id_fk",
"tableFrom": "habit_calendar_settings",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_days": {
"name": "habit_days",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"revision_id": {
"name": "revision_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ends_at": {
"name": "ends_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count_set": {
"name": "count_set",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"done": {
"name": "done",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"habit_days_lookup_idx": {
"name": "habit_days_lookup_idx",
"columns": [
"habit_id",
"date",
"revision_id"
],
"isUnique": false
}
},
"foreignKeys": {
"habit_days_habit_id_habits_id_fk": {
"name": "habit_days_habit_id_habits_id_fk",
"tableFrom": "habit_days",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"habit_days_revision_id_habit_revisions_id_fk": {
"name": "habit_days_revision_id_habit_revisions_id_fk",
"tableFrom": "habit_days",
"tableTo": "habit_revisions",
"columnsFrom": [
"revision_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_revisions": {
"name": "habit_revisions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_date": {
"name": "effective_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"habit_revisions_date_idx": {
"name": "habit_revisions_date_idx",
"columns": [
"habit_id",
"effective_date",
"id"
],
"isUnique": false
}
},
"foreignKeys": {
"habit_revisions_habit_id_habits_id_fk": {
"name": "habit_revisions_habit_id_habits_id_fk",
"tableFrom": "habit_revisions",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habits": {
"name": "habits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_date": {
"name": "created_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"materialized_through": {
"name": "materialized_through",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"habits_user_idx": {
"name": "habits_user_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"habits_user_id_users_id_fk": {
"name": "habits_user_id_users_id_fk",
"tableFrom": "habits",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"progress_events": {
"name": "progress_events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"day_id": {
"name": "day_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"occurrence_id": {
"name": "occurrence_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"before": {
"name": "before",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"after": {
"name": "after",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"progress_events_day_idx": {
"name": "progress_events_day_idx",
"columns": [
"day_id"
],
"isUnique": false
}
},
"foreignKeys": {
"progress_events_day_id_habit_days_id_fk": {
"name": "progress_events_day_id_habit_days_id_fk",
"tableFrom": "progress_events",
"tableTo": "habit_days",
"columnsFrom": [
"day_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"sessions_user_id_idx": {
"name": "sessions_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"sessions_expires_at_idx": {
"name": "sessions_expires_at_idx",
"columns": [
"expires_at"
],
"isUnique": false
}
},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sharing_limits": {
"name": "sharing_limits",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_occurrences": {
"name": "task_occurrences",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"day_id": {
"name": "day_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"done": {
"name": "done",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"expired_at": {
"name": "expired_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"closed_at": {
"name": "closed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"task_occurrences_day_idx": {
"name": "task_occurrences_day_idx",
"columns": [
"day_id"
],
"isUnique": false
}
},
"foreignKeys": {
"task_occurrences_day_id_habit_days_id_fk": {
"name": "task_occurrences_day_id_habit_days_id_fk",
"tableFrom": "task_occurrences",
"tableTo": "habit_days",
"columnsFrom": [
"day_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"discord_id": {
"name": "discord_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"global_name": {
"name": "global_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_hash": {
"name": "avatar_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'UTC'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_discord_id_unique": {
"name": "users_discord_id_unique",
"columns": [
"discord_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,933 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1839b194-bee2-4f37-bf75-1235d2965a48",
"prevId": "b5822e36-a1a9-42dd-9439-9490297bc097",
"tables": {
"combined_charts": {
"name": "combined_charts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"habit_ids": {
"name": "habit_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"combined_charts_user_idx": {
"name": "combined_charts_user_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"combined_charts_user_id_users_id_fk": {
"name": "combined_charts_user_id_users_id_fk",
"tableFrom": "combined_charts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"discord_deliveries": {
"name": "discord_deliveries",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image_hash": {
"name": "image_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"message_url": {
"name": "message_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"discord_deliveries_user_id_users_id_fk": {
"name": "discord_deliveries_user_id_users_id_fk",
"tableFrom": "discord_deliveries",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_calendar_settings": {
"name": "habit_calendar_settings",
"columns": {
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"habit_calendar_settings_habit_id_habits_id_fk": {
"name": "habit_calendar_settings_habit_id_habits_id_fk",
"tableFrom": "habit_calendar_settings",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_days": {
"name": "habit_days",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"revision_id": {
"name": "revision_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ends_at": {
"name": "ends_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count_set": {
"name": "count_set",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"done": {
"name": "done",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"habit_days_lookup_idx": {
"name": "habit_days_lookup_idx",
"columns": [
"habit_id",
"date",
"revision_id"
],
"isUnique": false
}
},
"foreignKeys": {
"habit_days_habit_id_habits_id_fk": {
"name": "habit_days_habit_id_habits_id_fk",
"tableFrom": "habit_days",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"habit_days_revision_id_habit_revisions_id_fk": {
"name": "habit_days_revision_id_habit_revisions_id_fk",
"tableFrom": "habit_days",
"tableTo": "habit_revisions",
"columnsFrom": [
"revision_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_revisions": {
"name": "habit_revisions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_date": {
"name": "effective_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"habit_revisions_date_idx": {
"name": "habit_revisions_date_idx",
"columns": [
"habit_id",
"effective_date",
"id"
],
"isUnique": false
}
},
"foreignKeys": {
"habit_revisions_habit_id_habits_id_fk": {
"name": "habit_revisions_habit_id_habits_id_fk",
"tableFrom": "habit_revisions",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habits": {
"name": "habits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_date": {
"name": "created_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"materialized_through": {
"name": "materialized_through",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"habits_user_idx": {
"name": "habits_user_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"habits_user_id_users_id_fk": {
"name": "habits_user_id_users_id_fk",
"tableFrom": "habits",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"progress_events": {
"name": "progress_events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"day_id": {
"name": "day_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"occurrence_id": {
"name": "occurrence_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"before": {
"name": "before",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"after": {
"name": "after",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"progress_events_day_idx": {
"name": "progress_events_day_idx",
"columns": [
"day_id"
],
"isUnique": false
}
},
"foreignKeys": {
"progress_events_day_id_habit_days_id_fk": {
"name": "progress_events_day_id_habit_days_id_fk",
"tableFrom": "progress_events",
"tableTo": "habit_days",
"columnsFrom": [
"day_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"reminder_deliveries": {
"name": "reminder_deliveries",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"retry_at": {
"name": "retry_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"reminder_deliveries_user_date_idx": {
"name": "reminder_deliveries_user_date_idx",
"columns": [
"user_id",
"date"
],
"isUnique": true
}
},
"foreignKeys": {
"reminder_deliveries_user_id_users_id_fk": {
"name": "reminder_deliveries_user_id_users_id_fk",
"tableFrom": "reminder_deliveries",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"reminder_settings": {
"name": "reminder_settings",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"time": {
"name": "time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'20:00'"
},
"quiet_start": {
"name": "quiet_start",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'22:00'"
},
"quiet_end": {
"name": "quiet_end",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'08:00'"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"reminder_settings_user_id_users_id_fk": {
"name": "reminder_settings_user_id_users_id_fk",
"tableFrom": "reminder_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"sessions_user_id_idx": {
"name": "sessions_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"sessions_expires_at_idx": {
"name": "sessions_expires_at_idx",
"columns": [
"expires_at"
],
"isUnique": false
}
},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sharing_limits": {
"name": "sharing_limits",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_occurrences": {
"name": "task_occurrences",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"day_id": {
"name": "day_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"done": {
"name": "done",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"expired_at": {
"name": "expired_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"closed_at": {
"name": "closed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"task_occurrences_day_idx": {
"name": "task_occurrences_day_idx",
"columns": [
"day_id"
],
"isUnique": false
}
},
"foreignKeys": {
"task_occurrences_day_id_habit_days_id_fk": {
"name": "task_occurrences_day_id_habit_days_id_fk",
"tableFrom": "task_occurrences",
"tableTo": "habit_days",
"columnsFrom": [
"day_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"discord_id": {
"name": "discord_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"global_name": {
"name": "global_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_hash": {
"name": "avatar_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'UTC'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_discord_id_unique": {
"name": "users_discord_id_unique",
"columns": [
"discord_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,957 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1861c134-87ad-4e7e-a113-0f845763483a",
"prevId": "1839b194-bee2-4f37-bf75-1235d2965a48",
"tables": {
"combined_charts": {
"name": "combined_charts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"habit_ids": {
"name": "habit_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"combined_charts_user_idx": {
"name": "combined_charts_user_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"combined_charts_user_id_users_id_fk": {
"name": "combined_charts_user_id_users_id_fk",
"tableFrom": "combined_charts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"discord_deliveries": {
"name": "discord_deliveries",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image_hash": {
"name": "image_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"message_url": {
"name": "message_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"discord_deliveries_user_id_users_id_fk": {
"name": "discord_deliveries_user_id_users_id_fk",
"tableFrom": "discord_deliveries",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_calendar_settings": {
"name": "habit_calendar_settings",
"columns": {
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"habit_calendar_settings_habit_id_habits_id_fk": {
"name": "habit_calendar_settings_habit_id_habits_id_fk",
"tableFrom": "habit_calendar_settings",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_days": {
"name": "habit_days",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"revision_id": {
"name": "revision_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ends_at": {
"name": "ends_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count_set": {
"name": "count_set",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"done": {
"name": "done",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"habit_days_lookup_idx": {
"name": "habit_days_lookup_idx",
"columns": [
"habit_id",
"date",
"revision_id"
],
"isUnique": false
}
},
"foreignKeys": {
"habit_days_habit_id_habits_id_fk": {
"name": "habit_days_habit_id_habits_id_fk",
"tableFrom": "habit_days",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"habit_days_revision_id_habit_revisions_id_fk": {
"name": "habit_days_revision_id_habit_revisions_id_fk",
"tableFrom": "habit_days",
"tableTo": "habit_revisions",
"columnsFrom": [
"revision_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habit_revisions": {
"name": "habit_revisions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"habit_id": {
"name": "habit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_date": {
"name": "effective_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"habit_revisions_date_idx": {
"name": "habit_revisions_date_idx",
"columns": [
"habit_id",
"effective_date",
"id"
],
"isUnique": false
}
},
"foreignKeys": {
"habit_revisions_habit_id_habits_id_fk": {
"name": "habit_revisions_habit_id_habits_id_fk",
"tableFrom": "habit_revisions",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"habits": {
"name": "habits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_date": {
"name": "created_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"materialized_through": {
"name": "materialized_through",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"habits_user_idx": {
"name": "habits_user_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"habits_user_id_users_id_fk": {
"name": "habits_user_id_users_id_fk",
"tableFrom": "habits",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"progress_events": {
"name": "progress_events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"day_id": {
"name": "day_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"occurrence_id": {
"name": "occurrence_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"before": {
"name": "before",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"after": {
"name": "after",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"progress_events_day_idx": {
"name": "progress_events_day_idx",
"columns": [
"day_id"
],
"isUnique": false
}
},
"foreignKeys": {
"progress_events_day_id_habit_days_id_fk": {
"name": "progress_events_day_id_habit_days_id_fk",
"tableFrom": "progress_events",
"tableTo": "habit_days",
"columnsFrom": [
"day_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"reminder_deliveries": {
"name": "reminder_deliveries",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"retry_at": {
"name": "retry_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"reminder_deliveries_user_date_idx": {
"name": "reminder_deliveries_user_date_idx",
"columns": [
"user_id",
"date"
],
"isUnique": true
}
},
"foreignKeys": {
"reminder_deliveries_user_id_users_id_fk": {
"name": "reminder_deliveries_user_id_users_id_fk",
"tableFrom": "reminder_deliveries",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"reminder_settings": {
"name": "reminder_settings",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"time": {
"name": "time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'20:00'"
},
"quiet_start": {
"name": "quiet_start",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'22:00'"
},
"quiet_end": {
"name": "quiet_end",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'08:00'"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"reminder_settings_user_id_users_id_fk": {
"name": "reminder_settings_user_id_users_id_fk",
"tableFrom": "reminder_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"reminder_worker_state": {
"name": "reminder_worker_state",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"blocked_until": {
"name": "blocked_until",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"sessions_user_id_idx": {
"name": "sessions_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"sessions_expires_at_idx": {
"name": "sessions_expires_at_idx",
"columns": [
"expires_at"
],
"isUnique": false
}
},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sharing_limits": {
"name": "sharing_limits",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_occurrences": {
"name": "task_occurrences",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"day_id": {
"name": "day_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"done": {
"name": "done",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"expired_at": {
"name": "expired_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"closed_at": {
"name": "closed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"task_occurrences_day_idx": {
"name": "task_occurrences_day_idx",
"columns": [
"day_id"
],
"isUnique": false
}
},
"foreignKeys": {
"task_occurrences_day_id_habit_days_id_fk": {
"name": "task_occurrences_day_id_habit_days_id_fk",
"tableFrom": "task_occurrences",
"tableTo": "habit_days",
"columnsFrom": [
"day_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"discord_id": {
"name": "discord_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"global_name": {
"name": "global_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_hash": {
"name": "avatar_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'UTC'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_discord_id_unique": {
"name": "users_discord_id_unique",
"columns": [
"discord_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -43,6 +43,27 @@
"when": 1788536554543,
"tag": "0005_bored_firelord",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1788539042148,
"tag": "0006_sharing_limits",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1788539729696,
"tag": "0007_reminders",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1788539875559,
"tag": "0008_reminder_cooldown",
"breakpoints": true
}
]
}

View File

@@ -13,7 +13,10 @@
"test": "bun test",
"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:smoke": "bun run build && bun scripts/api-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"
},
"dependencies": {
"@nivo/calendar": "^0.99.0",

5
scripts/backup.ts Normal file
View File

@@ -0,0 +1,5 @@
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 });
try { console.log(createBackup(db, backupConfig(databasePath))); } finally { db.close(); }

View File

@@ -1,4 +1,6 @@
// 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";
import { fixture } from "../src/habits/test-fixture";
import { createApi } from "../src/api";
import index from "../src/index.html";
@@ -8,6 +10,16 @@ const origin = "http://127.0.0.1:3107";
const previewCookie = "minabot_dashboard_preview_session";
const sharingPreview = process.env.SHARE_PREVIEW === "1";
let sharedImage: Blob | null = null;
let reminderMessage: string | null = null;
const previewDiscord: DiscordFetch = async (url, init) => {
if (url.endsWith("/users/@me/channels")) return Response.json({ id: "523456789012345678" });
if (init?.method === "POST") {
if (init.body instanceof FormData) sharedImage = init.body.get("files[0]") as Blob;
else reminderMessage = JSON.parse(String(init.body)).content;
return Response.json({ id: "423456789012345678" });
}
return Response.json({ type: 0, id: "223456789012345678", guild_id: "323456789012345678", name: "preview-only" });
};
if (sharingPreview) {
f.setTime("2026-08-01T12:00:00Z");
const reading = await f.json("/habits", "POST", { name: "A few pages, every day", method: "count", target: 10, unit: "pages", color: "#90647e" }, 201);
@@ -29,19 +41,20 @@ const app = createApi(
},
undefined,
() => Date.parse("2026-09-04T12:00:00Z"),
sharingPreview ? async (_url, init) => {
if (init?.method === "POST") {
sharedImage = (init.body as FormData).get("files[0]") as Blob;
return Response.json({ id: "423456789012345678" });
}
return Response.json({ type: 0, id: "223456789012345678", guild_id: "323456789012345678", name: "preview-only" });
} : undefined,
sharingPreview ? previewDiscord : undefined,
sharingPreview ? { token: "preview-bot-token", channelId: "223456789012345678" } : undefined,
);
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")); }
finally { reminderRunning = false; }
}, 1000).unref();
const server = Bun.serve({
hostname: "127.0.0.1",
port: 3107,
routes: {
"/__preview/reminder": () => new Response(reminderMessage ?? "No reminder sent"),
"/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }),
"/__preview/login": () =>
new Response(null, {

View File

@@ -1,7 +1,13 @@
import { backupConfig, createBackup } from "../src/ops/backups";
import { databasePath } from "../src/db/config";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { db, sqlite } from "../src/db";
try {
if (process.env.NODE_ENV === "production" && sqlite.query("SELECT name FROM sqlite_master WHERE name = 'habits'").get()) {
// Fail before migrations if the safety snapshot cannot be created.
createBackup(sqlite, backupConfig(databasePath));
}
migrate(db, { migrationsFolder: "./drizzle" });
console.log("Database migrations applied.");
} finally {

21
scripts/monitor.ts Normal file
View File

@@ -0,0 +1,21 @@
import { probeHealth, healthObserver } from "../src/ops/monitor";
const target = new URL(process.env.MONITOR_URL || `http://127.0.0.1:${process.env.PORT ?? 3000}/api/health`);
if (!["http:", "https:"].includes(target.protocol) || target.username || target.password) throw new Error("MONITOR_URL must be an HTTP(S) URL without credentials");
const emit = (event: Record<string, unknown>) => console.log(JSON.stringify(event));
if (process.argv.includes("--once")) {
const result = await probeHealth(target.href);
emit({ event: "health_probe", ...result });
process.exitCode = result.healthy ? 0 : 1;
} else {
const observe = healthObserver(emit);
let running = false;
const check = async () => {
if (running) return;
running = true;
try { observe(await probeHealth(target.href)); } finally { running = false; }
};
await check();
const timer = setInterval(() => void check(), 60000);
const stop = () => { clearInterval(timer); process.exit(0); };
process.on("SIGTERM", stop); process.on("SIGINT", stop);
}

5
scripts/restore.ts Normal file
View File

@@ -0,0 +1,5 @@
import { resolve } from "node:path";
import { restoreBackup } from "../src/ops/backups";
const [source, destination] = process.argv.slice(2);
if (!source || !destination || process.argv.length !== 4) throw new Error("Usage: bun scripts/restore.ts BACKUP NEW_DATABASE_PATH");
console.log(`Restored and verified: ${restoreBackup(resolve(source), resolve(destination))}. All sessions revoked.`);

View File

@@ -1,9 +1,13 @@
import { backupConfig } from "../src/ops/backups";
import { databasePath } from "../src/db/config";
import { fileURLToPath } from "node:url";
// Bun's built HTML assets resolve from dist; keep the database path absolute.
process.env.DATABASE_PATH = databasePath;
process.env.NODE_ENV = "production";
const backups = backupConfig(databasePath);
process.env.BACKUP_DIR = backups.directory;
if (backups.replica) process.env.BACKUP_REPLICA_DIR = backups.replica;
process.chdir(fileURLToPath(new URL("../dist", import.meta.url)));
const entrypoint = new URL("../dist/index.js", import.meta.url).href;
await import(entrypoint);

View File

@@ -1,3 +1,5 @@
import { ReminderSettings } from "./components/ReminderSettings";
import { Modal } from "./components/design-system/Modal";
import {
afterAll,
afterEach,
@@ -9,7 +11,7 @@ import {
test,
} from "bun:test";
import { Window } from "happy-dom";
import { act } from "react";
import { act, StrictMode, useState } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router";
import type { Root } from "react-dom/client";
import { App } from "./App";
@@ -116,6 +118,90 @@ const buttonNamed = (name: string) => [...container.querySelectorAll<HTMLButtonE
.find(button => button.textContent === name || button.getAttribute("aria-label") === name)!;
describe("homepage", () => {
test("development Strict Mode does not restore background focus while a modal is open", async () => {
await act(async () => root.render(<StrictMode><Modal id="strict-modal" title="Editor" onClose={() => {}} initialFocus="input"><input aria-label="Draft" /></Modal></StrictMode>));
await new Promise(resolve => dom.requestAnimationFrame(() => dom.requestAnimationFrame(resolve)));
expect(document.activeElement).toBe(container.querySelector("input"));
expect(container.querySelector<HTMLDialogElement>("dialog")?.open).toBe(true);
expect(document.documentElement.style.overflow).toBe("hidden");
});
test("settings open as a modal and Escape restores the opener and scrolling", async () => {
connectAccount();
await render("/");
const opener = buttonNamed("Settings");
opener.focus();
await act(async () => opener.click());
const dialog = container.querySelector<HTMLDialogElement>("dialog#account-settings")!;
expect(dialog.open).toBe(true);
expect(dialog.contains(document.activeElement)).toBe(true);
expect(document.documentElement.style.overflow).toBe("hidden");
await act(async () => dialog.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
await new Promise(resolve => dom.requestAnimationFrame(resolve));
expect(container.querySelector("dialog")).toBeNull();
expect(document.activeElement).toBe(opener);
expect(document.documentElement.style.overflow).toBe("");
});
test("modal dismissal waits for saves and nested dialogs retain the scroll lock", async () => {
function Harness() {
const [nested, setNested] = useState(true);
const [busy, setBusy] = useState(true);
return <Modal id="outer" title="Outer" onClose={() => {}}>
{nested && <Modal id="inner" title="Inner" busy={busy} onClose={() => setNested(false)}>
<button onClick={() => setBusy(false)}>Finish save</button>
</Modal>}
</Modal>;
}
await act(async () => root.render(<Harness />));
const inner = container.querySelector<HTMLDialogElement>("#inner")!;
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
expect(inner.open).toBe(true);
expect(inner.querySelector<HTMLButtonElement>('[aria-label="Close dialog"]')?.disabled).toBe(true);
await act(async () => buttonNamed("Finish save").click());
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);
expect(document.documentElement.style.overflow).toBe("hidden");
await act(async () => root.render(null));
expect(document.documentElement.style.overflow).toBe("");
});
test("archived habits expose history and restore to the dashboard", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
const h = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await f.json(`/habits/${h.id}/days/2026-09-03/progress`, "PUT", { done: true });
f.setTime("2026-09-04T12:00:00Z");
await f.request(`/habits/${h.id}`, "DELETE");
await render("/");
await act(async () => buttonNamed("Archived habits").click());
expect(buttonNamed("Restore Reading")).toBeDefined();
await act(async () => buttonNamed("View history").click());
expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
await act(async () => buttonNamed("Restore Reading").click());
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);
});
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");
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" });
await render("/");
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());
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");
});
test("signed-out visitors can try progress without calling private habit APIs", async () => {
await render("/");
expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm.");
@@ -223,12 +309,12 @@ describe("homepage", () => {
expect(saved.target).toBe(10);
expect(container.querySelector("form")).toBeNull();
expect(container.querySelector("h3")?.textContent).toContain("Daily water");
await act(async () => buttonNamed("Delete habit Daily water").click());
await act(async () => buttonNamed("Archive habit Daily water").click());
expect((await f.json("/habits")).habits).toHaveLength(1);
await act(async () => buttonNamed("Cancel").click());
expect((await f.json("/habits")).habits).toHaveLength(1);
await act(async () => buttonNamed("Delete habit Daily water").click());
await act(async () => buttonNamed("Delete habit").click());
await act(async () => buttonNamed("Archive habit Daily water").click());
await act(async () => buttonNamed("Archive habit").click());
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`);
@@ -381,15 +467,15 @@ describe("homepage", () => {
const f = connectAccount();
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await render("/");
await act(async () => buttonNamed("Delete habit Reading").click());
await act(async () => buttonNamed("Archive habit Reading").click());
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("Delete habit").click());
await act(async () => buttonNamed("Archive habit").click());
expect(container.querySelector("form")).toBeNull();
expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh");
expect(buttonNamed("Delete habit Reading").disabled).toBe(true);
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());
expect(container.querySelector(".ds-habit-chart")).toBeNull();
@@ -627,3 +713,23 @@ describe("design system tabs", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
});
test("reminder form submits native time values and explicit opt-out", async () => {
const saved: unknown[] = [];
fetchMock.mockImplementation((async (_input, init) => {
if (init?.method === "PUT") { saved.push(JSON.parse(String(init.body))); return Response.json({}); }
return Response.json({ enabled: false, time: "20:00", quietStart: "22:00", quietEnd: "08:00", available: true, lastDelivery: null });
}) as typeof fetch);
await act(async () => root.render(<ReminderSettings timezone="Europe/Belgrade" />));
const time = container.querySelector<HTMLInputElement>('input[name="time"]')!;
await act(async () => {
time.value = "14:30";
container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click();
});
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
expect(saved[0]).toEqual({ enabled: true, time: "14:30", quietStart: "22:00", quietEnd: "08:00" });
await act(async () => container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click());
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
expect(saved[1]).toEqual({ enabled: false, time: "14:30", quietStart: "22:00", quietEnd: "08:00" });
});

View File

@@ -0,0 +1,33 @@
import { test, expect } from 'bun:test';
import { fixture } from '../habits/test-fixture';
test('export is complete, owned, and excludes sessions; deletion removes only the authenticated account', async () => {
const f = fixture();
try {
const a = await f.json('/habits', 'POST', { name: 'Private tasks', method: 'tasks', tasks: [{ name: 'Stretch' }] }, 201);
const b = await f.json('/habits', 'POST', { name: 'Bob only', method: 'manual' }, 201, 'b');
const day = await f.json(`/habits/${a.id}/days/2026-09-04`);
await f.json(`/habits/${a.id}/days/2026-09-04/tasks/${day.tasks[0].taskId}`, 'PUT', { done: true });
await f.json('/charts', 'POST', { name: 'Mine', habitIds: [a.id] }, 201);
const response = await f.request('/account/export');
expect(response.headers.get('content-disposition')).toContain('attachment');
const data = await response.json();
expect(data.habits.map((h: any) => h.id)).toEqual([a.id]);
expect(data.tasks[0].done).toBe(true);
expect(data.progressEvents.length).toBe(1);
expect(data.revisions.length).toBeGreaterThan(0);
expect(data.charts.length).toBe(1);
expect(JSON.stringify(data)).not.toContain('tokenHash');
expect(JSON.stringify(data)).not.toContain('Bob only');
expect((await f.request('/account', 'DELETE', { confirmation: 'DELETE' }, 'a', { Origin: 'https://wrong.test' })).status).toBe(403);
expect((await f.request('/account', 'DELETE', {})).status).toBe(422);
const removed = await f.request('/account', 'DELETE', { confirmation: 'DELETE' });
expect(removed.status).toBe(204);
expect(removed.headers.get('set-cookie')).toContain('Max-Age=0');
expect((await f.request('/me')).status).toBe(401);
expect((await f.request('/account/export')).status).toBe(401);
expect((await f.json('/habits', 'GET', undefined, 200, 'b')).habits.map((h: any) => h.id)).toEqual([b.id]);
expect(f.sqlite.query('PRAGMA foreign_key_check').all()).toEqual([]);
expect(f.sqlite.query('SELECT * FROM progress_events').all()).toEqual([]);
} finally { f.close(); }
});

55
src/account/routes.ts Normal file
View File

@@ -0,0 +1,55 @@
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import { eq, inArray } from "drizzle-orm";
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
import * as schema from "../db/schema";
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 => {
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));
return {
formatVersion: 1, exportedAt: new Date(now()).toISOString(),
user: tx.select().from(schema.users).where(eq(schema.users.id, user.id)).get(),
habits: tx.select().from(schema.habits).where(eq(schema.habits.userId, user.id)).all(),
revisions: tx.select().from(schema.habitRevisions).where(inArray(schema.habitRevisions.habitId, owned)).all(),
days: tx.select().from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned)).all(),
tasks: tx.select().from(schema.taskOccurrences).where(inArray(schema.taskOccurrences.dayId, days)).all(),
progressEvents: tx.select().from(schema.progressEvents).where(inArray(schema.progressEvents.dayId, days)).all(),
calendarSettings: tx.select().from(schema.habitCalendarSettings).where(inArray(schema.habitCalendarSettings.habitId, owned)).all(),
charts: tx.select().from(schema.combinedCharts).where(eq(schema.combinedCharts.userId, user.id)).all(),
reminderSettings: tx.select().from(schema.reminderSettings).where(eq(schema.reminderSettings.userId, user.id)).all(),
reminderDeliveries: tx.select().from(schema.reminderDeliveries).where(eq(schema.reminderDeliveries.userId, user.id)).all(),
discordDeliveries: tx.select().from(schema.discordDeliveries).where(eq(schema.discordDeliveries.userId, user.id)).all(),
};
});
c.header("Content-Disposition", 'attachment; filename="minabot-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;
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));
tx.delete(schema.progressEvents).where(inArray(schema.progressEvents.dayId, days)).run();
tx.delete(schema.taskOccurrences).where(inArray(schema.taskOccurrences.dayId, days)).run();
tx.delete(schema.habitDays).where(inArray(schema.habitDays.habitId, owned)).run();
tx.delete(schema.habitRevisions).where(inArray(schema.habitRevisions.habitId, owned)).run();
tx.delete(schema.habitCalendarSettings).where(inArray(schema.habitCalendarSettings.habitId, owned)).run();
tx.delete(schema.combinedCharts).where(eq(schema.combinedCharts.userId, userId)).run();
tx.delete(schema.habits).where(eq(schema.habits.userId, userId)).run();
tx.delete(schema.users).where(eq(schema.users.id, userId)).run();
});
auth.clearSession(c);
return c.body(null, 204);
});
return app;
}

View File

@@ -1,3 +1,6 @@
import { createReminderRoutes } from "./reminders/routes";
import { errorDetails } from "./ops/monitor";
import { createAccountRoutes } from "./account/routes";
import { createHabitRoutes } from "./habits/routes";
import { ApiError } from "./habits/service";
import { Hono } from "hono";
@@ -8,14 +11,23 @@ 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: "" }) {
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 } = {}) {
const app = new Hono<AuthEnv>();
app.use("*", async (c, next) => {
const requestId = crypto.randomUUID();
c.set("requestId", requestId); c.header("X-Request-ID", requestId);
await next();
});
const auth = createAuth(db, config, request, now);
app.get("/api/health", c => {
c.header("Cache-Control", "no-store");
db.get(sql`SELECT 1`);
if (operations.healthy && !operations.healthy()) return c.json({ status: "degraded" }, 503);
return c.json({ status: "ok" });
});
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));
@@ -23,7 +35,11 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
app.onError((_error, c) => {
c.header("Cache-Control", "no-store");
if (_error instanceof ApiError) return c.json({ error: _error.message }, _error.status);
return c.json({ error: "Internal server error" }, 500);
const requestId = c.get("requestId");
const event = { event: "request_failed", timestamp: new Date().toISOString(), requestId, method: c.req.method,
route: c.req.routePath, ...errorDetails(_error) };
(operations.log ?? (event => console.error(JSON.stringify(event))))(event);
return c.json({ error: "Internal server error", requestId }, 500);
});
return app;
}

View File

@@ -12,7 +12,7 @@ import { fetchDiscordProfile, type FetchDiscord } from "./discord";
const { users, sessions } = schema;
const SESSION_SECONDS = 30 * 24 * 60 * 60;
const STATE_SECONDS = 10 * 60;
export type AuthEnv = { Variables: { user: PublicUser } };
export type AuthEnv = { Variables: { user: PublicUser; requestId: string } };
export type AppDatabase = BunSQLiteDatabase<typeof schema>;
export const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
const randomToken = () => randomBytes(32).toString("base64url");
@@ -142,5 +142,5 @@ export function createAuth(db: AppDatabase, config: AuthConfig, request: FetchDi
deleteCookie(c, sessionName, cookieOptions);
return c.body(null, 204);
});
return { routes, requireAuth, requireSameOrigin };
return { routes, requireAuth, requireSameOrigin, clearSession: (c: Parameters<typeof requireAuth>[0]) => deleteCookie(c, sessionName, cookieOptions) };
}

View File

@@ -0,0 +1,51 @@
import { ReminderSettings } from "./ReminderSettings";
import { useId, useRef, useState } from "react";
import type { PublicUser } from "../shared/user";
import { habitRequest } from "../lib/dashboard";
import { Button, ButtonLink } from "./design-system/primitives";
import { Download, Trash2 } from "lucide-react";
import { FormMessage, SettingRow, WorkspacePanel } from "./design-system/WorkspacePanel";
import { Field } from "./design-system/Field";
export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser; onChanged: () => void; onClose: () => void }) {
const [timezone, setTimezone] = useState(user.timezone);
const [confirmation, setConfirmation] = useState("");
const [deleting, setDeleting] = useState(false);
const [busy, setBusy] = useState(false);
const [reminderBusy, setReminderBusy] = useState(false);
const saving = useRef(false);
const [error, setError] = useState("");
const listId = useId();
async function run(action: () => Promise<unknown>) {
if (saving.current || reminderBusy) return;
saving.current = true; setBusy(true); setError("");
try { await action(); onChanged(); }
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="Your daily rhythm" description={<p>Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.</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)} />}
</Field>
<datalist id={listId}>{["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => <option key={zone} value={zone} />)}</datalist>
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy || reminderBusy}>{busy && !deleting ? "Saving…" : "Save timezone"}</Button></div>
</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>}>
<div className="ds-action-row"><ButtonLink variant="secondary" href="/api/account/export" download="minabot-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>}>
{!deleting ? <div className="ds-action-row"><Button variant="text" disabled={busy || reminderBusy} onClick={() => setDeleting(true)} aria-expanded={false}><Trash2 size={16} aria-hidden="true" />Delete account</Button></div> :
<form className="ds-form-stack ds-confirmation" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/account", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirmation }) })); }}>
<p className="type-ui-heading">This cannot be undone.</p>
<p className="ds-supporting-copy">Download an export first if you want a copy. Images already posted to Discord remain there; server backups expire under the operator’s retention policy.</p>
<Field label="Type DELETE to confirm">{id => <input id={id} value={confirmation} disabled={busy || reminderBusy} autoComplete="off" onChange={event => setConfirmation(event.target.value)} />}</Field>
<div className="ds-action-row"><Button type="submit" disabled={busy || reminderBusy || confirmation !== "DELETE"}>Permanently delete my account</Button><Button variant="text" disabled={busy || reminderBusy} onClick={() => { setDeleting(false); setConfirmation(""); }}>Cancel deletion</Button></div>
</form>}
</SettingRow>
<FormMessage error>{error}</FormMessage>
</WorkspacePanel>;
}

View File

@@ -0,0 +1,58 @@
import { useEffect, useRef, useState } from "react";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
import { Button } from "./design-system/primitives";
import { Archive, ChevronDown, RotateCcw } from "lucide-react";
import { FormMessage, WorkspacePanel } from "./design-system/WorkspacePanel";
import { HabitHistory } from "./HabitHistory";
export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }: {
date: string; revision: number; onChanged: () => void; onExpired: () => void; onClose?: () => void;
}) {
const [habits, setHabits] = useState<TodayHabit[]>([]);
const [selected, setSelected] = useState<string>();
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [attempt, setAttempt] = useState(0);
const saving = useRef(false);
const expired = useRef(onExpired); expired.current = onExpired;
useEffect(() => {
const controller = new AbortController(); setLoading(true); setError("");
habitRequest<{ habits: TodayHabit[] }>(`/days/${date}`, { signal: controller.signal })
.then(data => { if (!controller.signal.aborted) setHabits(data.habits.filter(habit => habit.requirements?.archived)); })
.catch(error => { if (!controller.signal.aborted) { setError(error.message); if (error.message.includes("session has expired")) expired.current(); } })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [date, revision, attempt]);
async function restore(habit: TodayHabit) {
if (saving.current) return;
saving.current = true; setBusy(true); setError(""); setNotice("");
try {
await habitRequest(`/habits/${habit.habitId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ archived: false }) });
setHabits(current => current.filter(item => item.habitId !== habit.habitId));
setNotice(`${habit.name} restored. Check-ins resume today.`); onChanged();
} catch (error) {
const message = error instanceof Error ? error.message : "Could not restore. Try again.";
setError(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">
<FormMessage error>{error}</FormMessage>
{error && <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry archive</Button>}
{loading ? <p className="ds-supporting-copy" role="status">Loading archive…</p> : !habits.length && <div className="ds-empty-panel"><Archive size={24} aria-hidden="true" /><div><p className="type-ui-heading">No archived habits.</p><p className="ds-supporting-copy">When you put a habit aside, its history will be waiting here.</p></div></div>}
<FormMessage>{notice}</FormMessage>
<div className="ds-record-list">
{habits.map(habit => <article key={habit.habitId} className="ds-record" aria-labelledby={`archive-title-${habit.habitId}`}>
<header className="ds-record-heading">
<div className="ds-record-copy"><h3 id={`archive-title-${habit.habitId}`}>{habit.name ?? "Habit"}</h3><p>{scheduleLabel(habit.requirements?.schedule)} · Archived</p></div>
<div className="ds-action-row">
<Button variant="text" onClick={() => setSelected(selected === habit.habitId ? undefined : habit.habitId)} aria-expanded={selected === habit.habitId} aria-controls={selected === habit.habitId ? `archive-history-${habit.habitId}` : undefined}>{selected === habit.habitId ? "Hide history" : "View history"}<ChevronDown className="ds-disclosure-icon" size={16} aria-hidden="true" /></Button>
<Button variant="secondary" disabled={busy || loading} aria-label={`Restore ${habit.name}`} onClick={() => void restore(habit)}><RotateCcw size={16} aria-hidden="true" />Restore</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>}
</article>)}
</div>
</WorkspacePanel>;
}

View File

@@ -0,0 +1,61 @@
import { FormMessage } from "./design-system/WorkspacePanel";
import { useEffect, useRef, useState } from "react";
import { habitRequest, formatTrackingDate, type TodayHabit } from "../lib/dashboard";
import { Button, Checkbox } from "./design-system/primitives";
import { Field } from "./design-system/Field";
export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired, onBusyChange }: {
habitId: string; date: string; disabled?: boolean; onSaved: () => void; onExpired?: () => void; onBusyChange?: (busy: boolean) => void;
}) {
const [day, setDay] = useState<TodayHabit | null>(null);
const [count, setCount] = useState("");
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState(false);
const [attempt, setAttempt] = useState(0);
const saving = useRef(false);
const expired = useRef(onExpired); expired.current = onExpired;
useEffect(() => {
const controller = new AbortController();
setDay(null); setError("");
habitRequest<TodayHabit>(`/habits/${habitId}/days/${date}`, { signal: controller.signal })
.then(value => { if (!controller.signal.aborted) { setDay(value); setCount(String(value.value)); } })
.catch(error => { if (!controller.signal.aborted) {
if (error.message.includes("session has expired")) expired.current?.();
setError(error.message);
} });
return () => controller.abort();
}, [habitId, date, attempt]);
async function save(body: { count: number } | { done: boolean }, taskId?: string) {
if (saving.current || disabled) return;
saving.current = true; setBusy(true); onBusyChange?.(true); setError(""); setNotice("");
try {
const result = await habitRequest<TodayHabit>(`/habits/${habitId}/days/${date}/${taskId ? `tasks/${taskId}` : "progress"}`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
setDay(result); setCount(String(result.value)); setNotice(`Saved check-in for ${date}.`); onSaved();
} catch (error) {
const message = error instanceof Error ? error.message : "Could not save. Try again.";
setError(message); if (message.includes("session has expired")) expired.current?.();
} finally { saving.current = false; setBusy(false); onBusyChange?.(false); }
}
return <div className="ds-form-stack ds-check-in-controls" aria-label={`Check-in for ${date}`}>
<header className="ds-check-in-heading"><p className="ds-eyebrow">EDITING CHECK-IN</p><p className="type-ui-heading">{formatTrackingDate(date)}</p></header>
<FormMessage error>{error}</FormMessage>
{!day ? error ? <Button onClick={() => setAttempt(value => value + 1)}>Retry check-in</Button> : <p role="status">Loading check-in…</p>
: day.future ? <p>Future check-ins cannot be edited.</p>
: !day.due ? <p>Nothing was scheduled for this date.</p>
: <>
<p className="ds-supporting-copy">{day.timezone} · Using this day’s saved target and schedule.</p>
{day.method === "count" ? <form className="ds-form-stack" onSubmit={event => { event.preventDefault(); void save({ count: Number(count) }); }}>
<Field label={`Count (${day.unit}, target ${day.target})`}>
{id => <input id={id} type="number" min={0} max={1000000000} step={1} required value={count} disabled={busy || disabled} onChange={event => setCount(event.target.value)} />}
</Field>
{day.carriedFrom && <p className="type-small">Includes carryover from {day.carriedFrom}. Saving sets this day's total.</p>}
<div className="ds-action-row"><Button type="submit" disabled={busy || disabled}>{busy ? "Saving…" : "Save check-in"}</Button></div>
</form> : day.method === "manual" ? <Checkbox label="Completed on this date" checked={day.complete} disabled={busy || disabled} onChange={event => void save({ done: event.target.checked })} />
: day.tasks.map(task => <Checkbox key={task.taskId} label={task.name} checked={task.done} disabled={busy || disabled} onChange={event => void save({ done: event.target.checked }, task.taskId)} />)}
</>}
<FormMessage>{notice}</FormMessage>
</div>;
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Button, SectionHeading } from "./design-system/primitives";
import { Modal } from "./design-system/Modal";
import { useRef, useState, type FormEvent } from "react";
import { Button } from "./design-system/primitives";
import { Field } from "./design-system/Field";
import { ScheduleEditor } from "./design-system/EditingWorkbench";
import { HabitColorPicker } from "./design-system/HabitColorPicker";
@@ -30,12 +31,6 @@ export function HabitForm({
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
form.current?.querySelector<HTMLInputElement>("input")?.focus();
return () => previous?.focus();
}, []);
async function submit(event: FormEvent) {
event.preventDefault();
if (saving.current) return;
@@ -80,23 +75,7 @@ export function HabitForm({
}
return (
<section
className="ds-section ds-split-section"
aria-labelledby="new-habit-title"
id="new-habit"
>
<SectionHeading
number="A NEW HABIT"
id="new-habit-title"
title={
<>
Make it <em>yours.</em>
</>
}
>
Choose what counts as complete. Your schedule follows your account’s
timezone.
</SectionHeading>
<Modal id="new-habit" eyebrow="A NEW HABIT" title={<>Make it <em>yours.</em></>} description="Choose what counts as complete. Your schedule follows your account’s timezone." 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}>
<Field label="Habit name">
@@ -223,6 +202,6 @@ export function HabitForm({
</Button>
</div>
</form>
</section>
</Modal>
);
}

View File

@@ -1,3 +1,5 @@
import { Modal } from "./design-system/Modal";
import { Pencil } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { CalendarResponse } from "../shared/calendar";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
@@ -7,6 +9,8 @@ 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 { Field } from "./design-system/Field";
import { ItemActions } from "./design-system/ItemActions";
export function HabitHistory({
@@ -20,6 +24,8 @@ export function HabitHistory({
tasks,
editor,
onExpired,
onHistorySaved,
calendarOnly = false,
}: {
habit: TodayHabit;
date: string;
@@ -31,7 +37,12 @@ export function HabitHistory({
tasks?: ReactNode;
editor?: ReactNode;
onExpired?: () => void;
onHistorySaved?: () => void;
calendarOnly?: boolean;
}) {
const [selectedDate, setSelectedDate] = useState<string>();
const [editingDay, setEditingDay] = useState(false);
const [savingDay, setSavingDay] = useState(false);
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0);
@@ -106,7 +117,10 @@ export function HabitHistory({
Loading your habit history…
</p>
) : (
<>
<CalendarHeatmap
onSelectDate={setSelectedDate}
selectedDate={selectedDate}
compact
historyLabel="Your recorded progress"
label={`${habit.name} progress calendar`}
@@ -114,6 +128,7 @@ export function HabitHistory({
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>}
legend={
<CalendarLegend
label={`${habit.name} progress calendar`}
@@ -123,7 +138,15 @@ 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}>
<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><p className="ds-supporting-copy">Update the progress recorded for this day.</p></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>}
</>
);
if (calendarOnly) return chart;
return (
<HabitChart
id={`history-${habit.habitId}`}
@@ -171,11 +194,11 @@ export function HabitHistory({
{onDelete && (
<Button
variant="text"
aria-label={`Delete ${habit.name}`}
aria-label={`Archive ${habit.name}`}
disabled={disabled}
onClick={onDelete}
>
Delete
Archive
</Button>
)}
</div>

View File

@@ -1,10 +1,11 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Modal } from "./design-system/Modal";
import { useId, useRef, useState, type ReactNode } from "react";
import { Button } from "./design-system/primitives";
import { Field } from "./design-system/Field";
export type ItemMode = "edit" | "delete";
/** In-place editing and confirmation, shared by habit headings and task rows. */
/** Shared editing and confirmation dialogs for habit headings and task rows. */
export function InlineItemForm({ name, kind, mode, disabled, children, onSave, onDelete, onClose, submitLabel, formLabel }: {
name: string;
kind: "habit" | "task";
@@ -22,17 +23,12 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
const [draft, setDraft] = useState(name);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
form.current?.querySelector<HTMLElement>(mode === "edit" ? "input" : "button")?.focus();
return () => { if (previous?.isConnected) previous.focus(); };
}, [mode]);
const id = useId();
const action = mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete";
return (
<form ref={form} className={`ds-inline-item-form ds-inline-item-form--${kind}`} aria-label={formLabel ?? `${mode === "edit" ? "Edit" : "Delete"} ${kind} ${name}`}
onKeyDown={(event) => {
if (event.key === "Escape" && !saving.current) { event.preventDefault(); onClose(); }
}}
<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)'}>
<form ref={form} className="ds-modal-form" aria-label={formLabel ?? `${mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete"} ${kind} ${name}`}
onSubmit={async (event) => {
event.preventDefault();
if (saving.current || disabled) return;
@@ -60,8 +56,8 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
</fieldset>
) : (
<p className="ds-inline-delete-copy">
Delete <strong>{name}</strong>? {kind === "habit"
? "This removes the habit from your dashboard. Earlier history is kept."
{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."
: "This removes the task from today and future check-ins. Earlier history is kept."}
</p>
)}
@@ -69,10 +65,11 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
<div className="ds-inline-form-actions">
{mode === "delete" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
<Button type="submit" disabled={busy || disabled}>
{busy ? "Saving…" : mode === "delete" ? `Delete ${kind}` : submitLabel ?? "Save changes"}
{busy ? "Saving…" : mode === "delete" ? `${kind === "habit" ? "Archive" : "Delete"} ${kind}` : submitLabel ?? "Save changes"}
</Button>
{mode === "edit" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,55 @@
import { FormMessage, SettingRow } from "./design-system/WorkspacePanel";
import { useEffect, useRef, useState } from "react";
import { habitRequest } from "../lib/dashboard";
import { defaultReminder, type ReminderResponse } from "../reminders/contracts";
import { Button, Checkbox } from "./design-system/primitives";
import { Field } from "./design-system/Field";
const deliveryLabels: Record<string, string> = {
sent: "Delivered", deferred: "Waiting to retry", pending: "Delivery unconfirmed", uncertain: "Delivery unconfirmed; check Discord. We will not send it again today.",
failed: "Discord could not deliver it. Check your DM permissions and that you share a server with the bot.", skipped: "Skipped because your check-ins or settings changed",
};
export function ReminderSettings({ timezone, onBusyChange, disabled = false }: { timezone: string; onBusyChange?: (busy: boolean) => void; disabled?: boolean }) {
const [settings, setSettings] = useState<ReminderResponse>();
const [draft, setDraft] = useState(defaultReminder);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState(false);
const [attempt, setAttempt] = useState(0);
const saving = useRef(false);
useEffect(() => {
const controller = new AbortController(); setError("");
habitRequest<ReminderResponse>("/reminders", { signal: controller.signal })
.then(data => { if (!controller.signal.aborted) { setSettings(data); setDraft({ enabled: data.enabled, time: data.time, quietStart: data.quietStart, quietEnd: data.quietEnd }); } })
.catch(error => { if (!controller.signal.aborted) setError(error.message); });
return () => controller.abort();
}, [attempt]);
return <SettingRow title="Daily reminders" description={<><p>A private Discord nudge when you still have habits left today. No habit names are sent.</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;
const submitted = { ...draft, ...Object.fromEntries((["time", "quietStart", "quietEnd"] as const).map(key => [key, (form.elements.namedItem(key) as HTMLInputElement).value])) };
saving.current = true; setBusy(true); onBusyChange?.(true); setError(""); setNotice("");
try {
await habitRequest("/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(submitted) });
setDraft(submitted);
setNotice(submitted.enabled ? "Daily Discord reminders enabled." : "Reminders turned off.");
} catch (error) { setError(error instanceof Error ? error.message : "Could not save reminders. Try again."); }
finally { saving.current = false; setBusy(false); onBusyChange?.(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="At most one message per day. Turn off anytime." label="Enable daily Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} />
<div className="ds-clock-fields">
{([['time', 'Remind me at'], ['quietStart', 'Quiet hours start'], ['quietEnd', 'Quiet hours end']] as const).map(([key, label]) =>
<Field key={key} label={label}>{id => <input id={id} name={key} type="time" required defaultValue={draft[key]} />}</Field>)}
</div>
<p className="ds-supporting-copy">During quiet hours we wait until they end. Equal start and end times turn quiet hours off. Missed reminders never carry into the next day.</p>
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy || disabled}>{busy ? "Saving…" : "Save reminders"}</Button></div>
</fieldset>
{settings.lastDelivery && <p className="ds-delivery-note"><span className="ds-eyebrow">LAST REMINDER</span><span>{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}</span></p>}
</form>}
<FormMessage error>{error}</FormMessage>
<FormMessage>{notice}</FormMessage>
</SettingRow>;
}

View File

@@ -1,5 +1,6 @@
import { Modal } from "./design-system/Modal";
import { useEffect, useRef, useState } from "react";
import { Download, Send, X } from "lucide-react";
import { Download, Send } from "lucide-react";
import { Button, Checkbox } from "./design-system/primitives";
import { Field } from "./design-system/Field";
import { habitRequest, type TodayResponse } from "../lib/dashboard";
@@ -25,12 +26,10 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
const [sendError, setSendError] = useState("");
const lock = useRef(false);
const lastImage = useRef<{ hash: string; deliveryId: string } | null>(null);
const heading = useRef<HTMLHeadingElement>(null);
const key = JSON.stringify({ ids, from, to, privacy, revision, user });
const ready = card?.key === key ? card : null;
const sent = ready && delivery?.id === ready.deliveryId ? delivery : null;
const busy = sending;
useEffect(() => { heading.current?.focus(); }, []);
useEffect(() => {
const controller = new AbortController();
setConnectionError("");
@@ -70,11 +69,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
finally { lock.current = false; setSending(false); }
}
return (
<section id="share-progress" className="ds-share-panel" aria-labelledby="share-progress-title">
<header className="ds-share-heading">
<div><p className="ds-eyebrow">A LITTLE PROGRESS, WORTH SHARING</p><h2 id="share-progress-title" ref={heading} tabIndex={-1} className="type-section">Your progress, in a picture.</h2></div>
<Button variant="text" disabled={busy} onClick={onClose} aria-label="Close sharing"><X size={20} aria-hidden="true" /></Button>
</header>
<Modal id="share-progress" eyebrow="A LITTLE PROGRESS, WORTH SHARING" title="Your progress, in a picture." onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
<div className="ds-share-layout">
<div className="ds-share-controls">
<fieldset className="ds-share-fieldset" disabled={busy}>
@@ -103,6 +98,6 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
{sent && <p role="status">{sent.status === "sent" ? <>Your card was sent. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord ↗</a>}</> : <>Discord didn’t confirm delivery. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"} before creating another card; this attempt won’t be resent.</>}</p>}
</div>
</div>
</section>
</Modal>
);
}

View File

@@ -24,6 +24,7 @@ export function CalendarHeatmap({
onSelectDate,
historyLabel,
legend,
selectedDayAction,
}: {
days: CalendarDay[];
unit: string;
@@ -35,6 +36,7 @@ export function CalendarHeatmap({
onSelectDate?: (date: string) => void;
historyLabel?: string;
legend?: ReactNode;
selectedDayAction?: (date: string) => ReactNode;
}) {
const [months, setMonths] = useState<MonthView | "custom">(12);
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
@@ -281,8 +283,8 @@ export function CalendarHeatmap({
</p>
</details>
</div>
<div id={inspectorId} className="ds-date-inspector" aria-live="polite">
<div className="ds-date-inspector-heading">
<div id={inspectorId} className="ds-date-inspector">
<div className="ds-date-inspector-heading" aria-live="polite">
<CalendarDays size={22} aria-hidden="true" />
<div>
<span className="ds-eyebrow">SELECTED DAY</span>
@@ -297,6 +299,7 @@ export function CalendarHeatmap({
<span className="ds-date-inspector-status">{describeDay(selected, unit)}</span>
</div>
</div>
{selectedDayAction && <div className="ds-action-row ds-date-inspector-actions">{selectedDayAction(selected.date)}</div>}
</div>
</div>
);

View File

@@ -1,4 +1,4 @@
import { Pencil, Trash2 } from "lucide-react";
import { Archive, Pencil, Trash2 } from "lucide-react";
import { Button } from "./primitives";
export function ItemActions({ name, kind, disabled, onEdit, onDelete }: {
@@ -13,8 +13,8 @@ export function ItemActions({ name, kind, disabled, onEdit, onDelete }: {
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onEdit} aria-label={`Edit ${kind} ${name}`} title={`Edit ${kind}`}>
<Pencil size={16} aria-hidden="true" />
</Button>
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onDelete} aria-label={`Delete ${kind} ${name}`} title={`Delete ${kind}`}>
<Trash2 size={16} aria-hidden="true" />
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onDelete} aria-label={`${kind === "habit" ? "Archive" : "Delete"} ${kind} ${name}`} title={`${kind === "habit" ? "Archive" : "Delete"} ${kind}`}>
{kind === "habit" ? <Archive size={16} aria-hidden="true" /> : <Trash2 size={16} aria-hidden="true" />}
</Button>
</span>
);

View File

@@ -0,0 +1,80 @@
import { useLayoutEffect, useRef, type ReactNode } from "react";
import { X } from "lucide-react";
import { Button } from "./primitives";
let openModals = 0;
let previousOverflow = "";
/** Native top-layer dialog supplies focus containment and an inert background. */
export function Modal({ id, eyebrow, title, description, children, onClose, closeLabel = "Close dialog", busy = false, size = "wide", initialFocus, returnFocus }: {
id: string; eyebrow?: string; title: ReactNode; description?: ReactNode; children: ReactNode;
onClose?: () => void; closeLabel?: string; busy?: boolean; size?: "wide" | "compact"; initialFocus?: string; returnFocus?: () => HTMLElement | null;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const heading = useRef<HTMLHeadingElement>(null);
const pointerOnBackdrop = useRef(false);
const mounted = useRef(false);
useLayoutEffect(() => {
mounted.current = true;
const element = dialog.current!;
const opener = returnFocus?.() ?? document.activeElement as HTMLElement | null;
if (!openModals++) {
previousOverflow = document.documentElement.style.overflow;
document.documentElement.style.overflow = "hidden";
}
element.showModal();
const target = initialFocus ? element.querySelector<HTMLElement>(initialFocus) : heading.current;
target?.focus({ preventScroll: true });
return () => {
mounted.current = false;
element.close();
if (!--openModals) document.documentElement.style.overflow = previousOverflow;
// A successful save may keep its opener disabled until the dashboard
// refresh finishes. Restore focus when it becomes available again.
window.requestAnimationFrame(() => window.requestAnimationFrame(() => {
// Ignore React Strict Mode's setup/cleanup rehearsal.
if (mounted.current || !opener?.isConnected) return;
if (!opener.matches(":disabled")) { opener.focus({ preventScroll: true }); return; }
const observer = new window.MutationObserver(() => {
if (opener.matches(":disabled")) return;
observer.disconnect();
window.clearTimeout(timeout);
if (opener.isConnected && document.activeElement === document.body) opener.focus({ preventScroll: true });
});
observer.observe(opener, { attributes: true, attributeFilter: ["disabled"] });
const timeout = window.setTimeout(() => observer.disconnect(), 5000);
}));
};
}, []);
const dismiss = () => { if (!busy) onClose?.(); };
return <dialog ref={dialog} id={id} className={`ds-modal ds-modal--${size}`} aria-labelledby={`${id}-title`}
onCancel={event => { event.preventDefault(); event.stopPropagation(); dismiss(); }}
onKeyDown={event => {
if (event.key !== "Tab") return;
event.stopPropagation();
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, [tabindex]')]
.filter(element => element.tabIndex >= 0 && !element.matches(':disabled') && element.getClientRects().length > 0);
const first = controls[0];
const last = controls.at(-1);
if (!first || !last) { event.preventDefault(); heading.current?.focus(); return; }
const active = document.activeElement;
if (event.shiftKey && (active === first || !controls.includes(active as HTMLElement))) {
event.preventDefault(); last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault(); first.focus();
}
}}
onPointerDown={event => { pointerOnBackdrop.current = event.target === event.currentTarget; }}
onClick={event => {
if (!pointerOnBackdrop.current || event.target !== event.currentTarget) return;
const bounds = event.currentTarget.getBoundingClientRect();
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) dismiss();
}}>
<header className="ds-modal-heading">
<div>{eyebrow && <p className="ds-eyebrow">{eyebrow}</p>}<h2 id={`${id}-title`} ref={heading} tabIndex={-1}>{title}</h2>{description && <p className="ds-panel-description">{description}</p>}</div>
<Button variant="text" className="ds-panel-close" disabled={busy || !onClose} onClick={dismiss} aria-label={closeLabel} title={closeLabel}><X size={20} aria-hidden="true" /></Button>
</header>
<div className="ds-modal-body">{children}</div>
</dialog>;
}

View File

@@ -0,0 +1,22 @@
import { useId, type ReactNode } from "react";
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;
}) {
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 }) {
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>
<div className="ds-setting-controls">{children}</div>
</section>;
}
export function FormMessage({ children, error = false }: { children: ReactNode; error?: boolean }) {
return <p className={`ds-form-message${error ? " ds-form-message--error" : ""}`} role={error ? "alert" : "status"}>{children}</p>;
}

View File

@@ -0,0 +1,53 @@
import { expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
test("development entrypoint upgrades an existing database before serving requests", async () => {
const directory = mkdtempSync(join(tmpdir(), "minabot-dev-upgrade-"));
const path = join(directory, "app.sqlite");
const sqlite = new Database(path);
let server: ReturnType<typeof Bun.spawn> | undefined;
try {
const migrations = join(directory, "migrations");
mkdirSync(join(migrations, "meta"), { recursive: true });
const journal = JSON.parse(readFileSync("drizzle/meta/_journal.json", "utf8"));
journal.entries = journal.entries.slice(0, 6);
writeFileSync(join(migrations, "meta/_journal.json"), JSON.stringify(journal));
for (const entry of journal.entries) copyFileSync(`drizzle/${entry.tag}.sql`, join(migrations, `${entry.tag}.sql`));
migrate(drizzle(sqlite), { migrationsFolder: migrations });
sqlite.exec("INSERT INTO users (id, discord_id, username, created_at, updated_at) VALUES ('existing', 'existing', 'Existing user', 0, 0)");
expect(sqlite.query("SELECT name FROM sqlite_master WHERE name = 'reminder_settings'").get()).toBeNull();
// Launch directly, as Bun does when re-evaluating the entrypoint during HMR;
// deliberately skip package.json's separate migration command.
server = Bun.spawn([process.execPath, "src/index.ts"], {
env: { ...process.env, NODE_ENV: "development", DATABASE_PATH: path, PORT: "0", APP_ORIGIN: "http://127.0.0.1", DISCORD_BOT_TOKEN: "", DISCORD_ALLOWED_USER_IDS: "" },
stdout: "pipe", stderr: "pipe",
});
const reader = (server.stdout as ReadableStream<Uint8Array>).getReader();
let output = "";
while (!output.includes("Server running at")) {
const chunk = await reader.read();
if (chunk.done) throw new Error("Development server exited before it was ready");
output += new TextDecoder().decode(chunk.value);
}
reader.releaseLock();
const origin = output.match(/Server running at (http:\/\/[^\s]+)/)?.[1];
expect(origin).toBeDefined();
expect((await fetch(new URL("/api/health", origin))).status).toBe(200);
expect(sqlite.query("SELECT COUNT(*) AS count FROM __drizzle_migrations").get()).toEqual({ count: JSON.parse(readFileSync("drizzle/meta/_journal.json", "utf8")).entries.length });
expect(sqlite.query("SELECT enabled FROM reminder_settings").all()).toEqual([]);
expect(sqlite.query("SELECT retry_at FROM reminder_deliveries").all()).toEqual([]);
expect(sqlite.query("SELECT blocked_until FROM reminder_worker_state").all()).toEqual([]);
expect(sqlite.query("SELECT username FROM users WHERE id = 'existing'").get()).toEqual({ username: "Existing user" });
} finally {
server?.kill();
if (server) await server.exited;
sqlite.close();
rmSync(directory, { recursive: true, force: true });
}
}, 15000);

View File

@@ -1,4 +1,4 @@
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
@@ -60,3 +60,27 @@ export const combinedCharts = sqliteTable('combined_charts', {
habitIds: text('habit_ids', { mode: 'json' }).$type<string[]>().notNull(), settings: text('settings', { mode: 'json' }).$type<import('../habits/contracts').CalendarSettings>().notNull(),
createdAt: integer('created_at').notNull(), updatedAt: integer('updated_at').notNull(),
}, t => [index('combined_charts_user_idx').on(t.userId)]);
export const sharingLimits = sqliteTable('sharing_limits', {
key: text('key').primaryKey(),
startedAt: integer('started_at').notNull(),
count: integer('count').notNull(),
});
export const reminderSettings = sqliteTable('reminder_settings', {
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(false),
time: text('time').notNull().default('20:00'),
quietStart: text('quiet_start').notNull().default('22:00'),
quietEnd: text('quiet_end').notNull().default('08:00'),
updatedAt: integer('updated_at').notNull(),
});
export const reminderDeliveries = sqliteTable('reminder_deliveries', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
date: text('date').notNull(), status: text('status').notNull(),
retryAt: integer('retry_at'), updatedAt: integer('updated_at').notNull(),
}, t => [uniqueIndex('reminder_deliveries_user_date_idx').on(t.userId, t.date)]);
export const reminderWorkerState = sqliteTable('reminder_worker_state', {
id: text('id').primaryKey(), blockedUntil: integer('blocked_until').notNull(),
});

View File

@@ -5,7 +5,7 @@ beforeEach(() => { f = fixture(); }); afterEach(() => f.close());
test('failed revision writes roll back the habit atomically and do not expose database errors', async () => {
f.sqlite.exec("CREATE TRIGGER reject_revision BEFORE INSERT ON habit_revisions BEGIN SELECT RAISE(ABORT, 'private database detail'); END");
const response = await f.request('/habits', 'POST', { name: 'Rollback', method: 'manual' });
expect(response.status).toBe(500); expect(await response.json()).toEqual({ error: 'Internal server error' });
expect(response.status).toBe(500); expect(await response.json()).toEqual({ error: 'Internal server error', requestId: response.headers.get('x-request-id') });
expect((await f.json('/habits')).habits).toHaveLength(0);
});
@@ -20,7 +20,7 @@ test('creation saves color atomically and never puts presentation into requireme
f.sqlite.exec("CREATE TRIGGER reject_color BEFORE INSERT ON habit_calendar_settings BEGIN SELECT RAISE(ABORT, 'private color detail'); END");
const response = await f.request('/habits', 'POST', { name: 'Rollback color', method: 'manual', color: '#426582' });
expect(response.status).toBe(500);
expect(await response.json()).toEqual({ error: 'Internal server error' });
expect(await response.json()).toEqual({ error: 'Internal server error', requestId: response.headers.get('x-request-id') });
expect((await f.json('/habits')).habits).toHaveLength(3);
});
test('edits save color with requirements atomically, preserving history and other settings', async () => {

View File

@@ -1,10 +1,26 @@
import { db } from "./db";
import { startReminders } from "./reminders/worker";
import { backupConfig, startBackups } from "./ops/backups";
import { databasePath } from "./db/config";
import { db, sqlite } from "./db";
import { createApi } from "./api";
import { readAuthConfig } from "./auth/config";
import { readDiscordSharingConfig } from "./sharing/config";
import index from "./index.html";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig());
// Hot reloads skip the package script's migration step. Apply pending changes
// before the reloaded development server accepts requests.
if (process.env.NODE_ENV !== "production") {
migrate(db, { migrationsFolder: "./drizzle" });
}
const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined;
if (import.meta.hot) import.meta.hot.dispose(() => backups?.stop());
const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin }) : undefined;
if (import.meta.hot) import.meta.hot.dispose(() => reminders?.stop());
const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig(), { healthy: () => !backups?.state.enabled || (!backups.state.failed && backups.state.lastSuccess > 0) });
const server = Bun.serve({
hostname: "127.0.0.1",

View File

@@ -20,7 +20,7 @@ export async function habitRequest<T>(
throw new Error("Your session has expired. Sign in again to continue.");
const body = await response.json().catch(() => null);
throw new Error(
body?.error || "Could not reach your workspace. Please try again.",
(body?.error || "Could not reach your workspace. Please try again.") + (response.status >= 500 && body?.requestId ? ` Reference: ${body.requestId}` : ""),
);
}
return response.status === 204

73
src/ops/backups.test.ts Normal file
View File

@@ -0,0 +1,73 @@
import { expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { mkdtempSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { fixture } from "../habits/test-fixture";
import { backupConfig, createBackup, latestBackupTime, restoreBackup, startBackups } from "./backups";
test("WAL snapshots restore committed progress, revoke sessions, replicate, and retain only owned snapshots", async () => {
const directory = mkdtempSync(join(tmpdir(), "minabot-backup-"));
const f = fixture(join(directory, "live.sqlite"));
try {
f.sqlite.exec("PRAGMA journal_mode=WAL");
const config = backupConfig(join(directory, "live.sqlite"), { BACKUP_RETAIN: "2", BACKUP_REPLICA_DIR: "replica" });
mkdirSync(config.replica!);
const habit = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "cups" }, 201);
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, "PUT", { count: 6 });
f.sqlite.exec("INSERT INTO reminder_settings (user_id, enabled, time, quiet_start, quiet_end, updated_at) VALUES ('alice', 1, '20:00', '22:00', '08:00', 0)");
const first = createBackup(f.sqlite, config);
expect(statSync(first).mode & 0o777).toBe(0o600);
expect(latestBackupTime(config)).toBeGreaterThan(0);
const destination = join(directory, "restored.sqlite");
restoreBackup(first, destination);
const restored = new Database(destination, { readonly: true });
try {
expect(restored.query("SELECT count FROM habit_days").get()).toEqual({ count: 6 });
expect(restored.query("SELECT * FROM sessions").all()).toEqual([]);
expect(restored.query("SELECT enabled FROM reminder_settings").get()).toEqual({ enabled: 0 });
expect(restored.query("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok" });
} finally { restored.close(); }
expect(() => restoreBackup(first, destination)).toThrow("new path");
writeFileSync(join(config.directory, "keep.txt"), "unrelated");
createBackup(f.sqlite, config); createBackup(f.sqlite, config);
expect(readdirSync(config.directory).filter(name => name.endsWith(".sqlite"))).toHaveLength(2);
expect(readdirSync(config.replica!)).toHaveLength(2);
expect(readdirSync(config.directory)).toContain("keep.txt");
const runner = startBackups(f.sqlite, config);
expect(runner.state.failed).toBe(false); expect(runner.state.lastSuccess).toBeGreaterThan(0); runner.stop();
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
});
test("invalid backups and missing replica mounts fail without overwriting data", () => {
const directory = mkdtempSync(join(tmpdir(), "minabot-backup-failure-"));
const f = fixture();
try {
const bad = join(directory, "bad.sqlite"); writeFileSync(bad, "not a database");
expect(() => restoreBackup(bad, join(directory, "target.sqlite"))).toThrow();
const config = backupConfig(join(directory, "live.sqlite"), { BACKUP_REPLICA_DIR: "missing" });
expect(() => createBackup(f.sqlite, config)).toThrow();
expect(latestBackupTime(config)).toBe(0);
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
});
test("backup and restore CLIs work against a live database without overwriting it", async () => {
const directory = mkdtempSync(join(tmpdir(), "minabot-backup-cli-"));
const path = join(directory, "live.sqlite");
const f = fixture(path);
try {
f.sqlite.exec("PRAGMA journal_mode=WAL");
await f.json('/habits', 'POST', { name: 'CLI recovery', method: 'manual' }, 201);
const backup = Bun.spawnSync([process.execPath, 'scripts/backup.ts'], {
env: { ...process.env, DATABASE_PATH: path, BACKUP_DIR: 'backups', BACKUP_REPLICA_DIR: '', BACKUP_RETAIN: '7', BACKUP_INTERVAL_HOURS: '24' },
});
expect(backup.exitCode).toBe(0);
const snapshot = backup.stdout.toString().trim();
const restored = join(directory, 'recovered.sqlite');
const result = Bun.spawnSync([process.execPath, 'scripts/restore.ts', snapshot, restored]);
expect(result.exitCode).toBe(0);
const check = new Database(restored, { readonly: true });
try { expect(check.query('SELECT count(*) AS n FROM habits').get()).toEqual({ n: 1 }); } finally { check.close(); }
expect(f.sqlite.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 });
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
});

99
src/ops/backups.ts Normal file
View File

@@ -0,0 +1,99 @@
import { Database } from "bun:sqlite";
import { chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
export type BackupConfig = { directory: string; replica?: string; retain: number; intervalMs: number; enabled: boolean };
const filenamePattern = /^minabot-\d{4}-\d{2}-\d{2}T[\d-]+Z-[a-f0-9-]+\.sqlite$/;
export function backupConfig(database: string, env = process.env): BackupConfig {
const retain = Number(env.BACKUP_RETAIN ?? 7);
const hours = Number(env.BACKUP_INTERVAL_HOURS ?? 24);
if (!Number.isInteger(retain) || retain < 2 || retain > 365 || !Number.isFinite(hours) || hours < 1 || hours > 168)
throw new Error("BACKUP_RETAIN must be 2–365 and BACKUP_INTERVAL_HOURS must be 1–168");
const base = dirname(database);
const directory = resolve(base, env.BACKUP_DIR ?? "backups");
const replica = env.BACKUP_REPLICA_DIR ? resolve(base, env.BACKUP_REPLICA_DIR) : undefined;
if (replica === directory) throw new Error("The backup replica must use a separate directory");
return { directory, replica, retain, intervalMs: hours * 3600000, enabled: env.BACKUP_ENABLED !== "false" };
}
function durable(path: string) {
chmodSync(path, 0o600);
const fd = openSync(path, "r"); try { fsyncSync(fd); } finally { closeSync(fd); }
}
export function verifyBackup(path: string) {
const db = new Database(path, { readonly: true, strict: true });
try {
const integrity = db.query("PRAGMA integrity_check").all() as { integrity_check: string }[];
if (integrity.length !== 1 || integrity[0]?.integrity_check !== "ok" || db.query("PRAGMA foreign_key_check").all().length)
throw new Error("Backup integrity check failed");
db.query("SELECT id FROM users LIMIT 1").all();
db.query("SELECT id FROM habits LIMIT 1").all();
db.query("SELECT hash FROM __drizzle_migrations LIMIT 1").all();
} finally { db.close(); }
}
function snapshots(directory: string) {
if (!existsSync(directory)) return [];
return readdirSync(directory).filter(name => filenamePattern.test(name)).sort().reverse();
}
export function latestBackupTime(config: BackupConfig) {
const latest = snapshots(config.directory)[0];
if (!latest || (config.replica && !existsSync(join(config.replica, latest)))) return 0;
return statSync(join(config.directory, latest)).mtimeMs;
}
export function createBackup(database: Database, config: BackupConfig) {
const name = `minabot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomUUID()}.sqlite`;
mkdirSync(config.directory, { recursive: true, mode: 0o700 });
const path = join(config.directory, name);
const staging = `${path}.partial`;
mkdirSync(staging, { mode: 0o700 });
const temporary = join(staging, "snapshot.sqlite");
try {
// VACUUM INTO includes committed WAL transactions in one consistent snapshot.
// SQLite creates the output inside a private staging directory.
database.query("VACUUM INTO ?").run(temporary);
verifyBackup(temporary); durable(temporary); renameSync(temporary, path);
if (config.replica) {
// Require the configured mount to exist; never silently create an absent mount.
if (!statSync(config.replica).isDirectory()) throw new Error("Backup replica directory unavailable");
const replica = join(config.replica, name);
copyFileSync(path, `${replica}.partial`, constants.COPYFILE_EXCL);
durable(`${replica}.partial`); verifyBackup(`${replica}.partial`); renameSync(`${replica}.partial`, replica);
}
for (const directory of [config.directory, ...(config.replica ? [config.replica] : [])])
for (const old of snapshots(directory).slice(config.retain)) unlinkSync(join(directory, old));
return path;
} finally { rmSync(staging, { recursive: true, force: true }); }
}
export function restoreBackup(source: string, destination: string) {
if (existsSync(destination) || existsSync(`${destination}-wal`) || existsSync(`${destination}-shm`))
throw new Error("Restore destination must be a new path with no SQLite sidecars");
verifyBackup(source);
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
const temporary = `${destination}.${crypto.randomUUID()}.partial`;
try {
copyFileSync(source, temporary, constants.COPYFILE_EXCL); chmodSync(temporary, 0o600);
const restored = new Database(temporary);
try {
restored.exec("PRAGMA journal_mode=DELETE; DELETE FROM sessions;");
if (restored.query("SELECT name FROM sqlite_master WHERE name = 'reminder_settings'").get()) restored.exec("UPDATE reminder_settings SET enabled=0");
} finally { restored.close(); }
verifyBackup(temporary); durable(temporary);
// Link is exclusive: a concurrently-created destination is never overwritten.
linkSync(temporary, destination);
} finally { if (existsSync(temporary)) unlinkSync(temporary); }
return destination;
}
export function startBackups(database: Database, config: BackupConfig) {
const state = { enabled: config.enabled, lastSuccess: 0, failed: false };
if (!config.enabled) return { state, stop() {} };
const tick = () => {
try {
const latest = latestBackupTime(config);
if (latest && Date.now() - latest < config.intervalMs) { state.lastSuccess = latest; state.failed = false; return; }
createBackup(database, config); state.lastSuccess = Date.now(); state.failed = false;
console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date().toISOString() }));
} catch { state.failed = true; console.error(JSON.stringify({ event: "backup_failed", timestamp: new Date().toISOString() })); }
};
tick();
const timer = setInterval(tick, 60000); timer.unref();
return { state, stop: () => clearInterval(timer) };
}

39
src/ops/monitor.test.ts Normal file
View File

@@ -0,0 +1,39 @@
import { expect, test } from "bun:test";
import { fixture } from "../habits/test-fixture";
import { createApi } from "../api";
import { healthObserver, probeHealth } from "./monitor";
test("unexpected database errors are correlated without logging secrets", async () => {
const f = fixture(); const events: Record<string, unknown>[] = [];
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-at-least-32-characters" }, undefined, undefined, undefined, undefined, { log: event => events.push(event) });
f.close();
const response = await app.request(`${f.origin}/api/health?token=private-secret`, { headers: { Cookie: "private-cookie", "X-Request-ID": "untrusted" } });
expect(response.status).toBe(500);
const body = await response.json();
expect(body.requestId).toBe(response.headers.get("x-request-id"));
expect(body.requestId).not.toBe("untrusted");
expect(events[0]?.requestId).toBe(body.requestId);
expect(events[0]?.type).toBeDefined();
expect(JSON.stringify(events)).not.toContain("private");
});
test("failed backups degrade health, and probes validate status and body", async () => {
const f = fixture();
try {
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-at-least-32-characters" }, undefined, undefined, undefined, undefined, { healthy: () => false });
const response = await app.request(`${f.origin}/api/health`);
expect(response.status).toBe(503); expect(await response.json()).toEqual({ status: "degraded" });
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => Response.json({ status: "ok" }) });
try { expect((await probeHealth(String(server.url))).healthy).toBe(true); } finally { await server.stop(true); }
expect((await probeHealth(String(server.url))).healthy).toBe(false);
} finally { f.close(); }
});
test("monitor reports sustained failure and recovery once per transition", () => {
const events: Record<string, unknown>[] = [];
const observe = healthObserver(event => events.push(event));
observe({ healthy: true, reason: "ok" });
for (let i = 0; i < 5; i++) observe({ healthy: false, reason: "HTTP 503" });
observe({ healthy: true, reason: "ok" }); observe({ healthy: true, reason: "ok" });
expect(events.map(event => event.event)).toEqual(["monitor_started", "health_failed", "health_recovered"]);
});

34
src/ops/monitor.ts Normal file
View File

@@ -0,0 +1,34 @@
export type HealthResult = { healthy: boolean; reason: string };
export async function probeHealth(url: string, request: typeof fetch = fetch): Promise<HealthResult> {
try {
const response = await request(url, { signal: AbortSignal.timeout(10000), redirect: "error", cache: "no-store" });
if (!response.ok) return { healthy: false, reason: `HTTP ${response.status}` };
const body = await response.json() as { status?: string };
return body.status === "ok" ? { healthy: true, reason: "ok" } : { healthy: false, reason: "Invalid health response" };
} catch { return { healthy: false, reason: "Health check unavailable" }; }
}
export function healthObserver(emit: (event: Record<string, unknown>) => void, threshold = 3) {
let failures = 0;
let incident = false;
let initialized = false;
return (result: HealthResult) => {
failures = result.healthy ? 0 : failures + 1;
const failed = failures >= threshold;
const event = !initialized && result.healthy ? "monitor_started" : failed && !incident ? "health_failed" : incident && result.healthy ? "health_recovered" : null;
if (event) emit({ event, timestamp: new Date().toISOString(), reason: result.reason, failures });
if (failed) incident = true;
if (result.healthy) incident = false;
initialized = true;
};
}
/** Keep secrets, SQL values, request bodies, OAuth queries and cookies out of logs. */
export function errorDetails(error: Error) {
return {
type: ["Error", "TypeError", "RangeError", "SyntaxError", "SQLiteError"].includes(error.name) ? error.name : "Error",
frames: (error.stack ?? "").split("\n").slice(1).flatMap(line => {
const location = line.match(/([A-Za-z0-9_.-]+\.(?:js|ts|tsx):\d+:\d+)/);
return location ? [location[1]] : [];
}).slice(0, 8),
};
}

View File

@@ -1,3 +1,6 @@
import { Archive, Settings2 } from "lucide-react";
import { ArchivedHabits } from "../components/ArchivedHabits";
import { AccountSettings } from "../components/AccountSettings";
import { useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "../components/AuthProvider";
import { DiscordSignInButton } from "../components/DiscordSignInButton";
@@ -165,8 +168,11 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
const [loading, setLoading] = useState(true);
const [adding, setAdding] = useState(false);
const [sharing, setSharing] = useState(false);
const [settings, setSettings] = useState(false);
const [archive, setArchive] = useState(false);
const [busy, setBusy] = useState(false);
const [needsRefresh, setNeedsRefresh] = useState(false);
const focusAddAfterRefresh = useRef(false);
const saving = useRef(false);
const mounted = useRef(true);
const expired = useRef(onExpired);
@@ -202,7 +208,13 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
if (!controller.signal.aborted) reportError(error);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
if (!controller.signal.aborted) {
setLoading(false);
if (focusAddAfterRefresh.current) {
focusAddAfterRefresh.current = false;
window.requestAnimationFrame(() => document.getElementById("add-habit")?.focus({ preventScroll: true }));
}
}
});
return () => controller.abort();
}, [attempt, reportError]);
@@ -303,7 +315,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
}
if (mounted.current) {
setNotice(
`${taskId || createTask ? "Task" : "Habit"} ${createTask ? "added" : patch ? "updated" : "deleted"}.`
`${taskId || createTask ? "Task" : "Habit"} ${createTask ? "added" : patch ? "updated" : taskId ? "deleted" : "archived"}.`
);
if (!patch)
window.requestAnimationFrame(() => {
@@ -353,9 +365,10 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
</p>
)}
</div>
<div className="ds-actions">
<div className="ds-action-row ds-primary-actions">
<Button
id="add-habit"
aria-haspopup="dialog"
disabled={adding || busy || loading || needsRefresh}
onClick={() => setAdding(true)}
aria-expanded={adding}
@@ -365,14 +378,20 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
</Button>
{today.habits.length > 0 && (
<>
<Button id="open-sharing" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button>
<Button id="open-sharing" aria-haspopup="dialog" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button>
<ButtonLink href="#habits-title">View your habits ↓</ButtonLink>
</>
)}
</div>
<div className="ds-action-row ds-utility-row">
<Button variant="text" disabled={busy} aria-haspopup="dialog" aria-expanded={archive} aria-controls={archive ? "archived-habits" : undefined} onClick={() => setArchive(!archive)}><Archive size={16} aria-hidden="true" />Archived habits</Button>
<Button variant="text" disabled={busy} aria-haspopup="dialog" aria-expanded={settings} aria-controls={settings ? "account-settings" : undefined} onClick={() => setSettings(!settings)}><Settings2 size={16} aria-hidden="true" />Settings</Button>
</div>
</>
)}
</WelcomePanel>
{archive && today && <ArchivedHabits onClose={() => setArchive(false)} date={today.date} revision={revision} onExpired={onExpired} onChanged={() => setAttempt(value => value + 1)} />}
{settings && <AccountSettings user={user} onChanged={onExpired} onClose={() => setSettings(false)} />}
{sharing && today && <ShareProgress user={user} today={today} revision={revision} onClose={() => { setSharing(false); requestAnimationFrame(() => document.getElementById("open-sharing")?.focus()); }} />}
{error && (
<div className="ds-form-feedback" role="alert">
@@ -399,6 +418,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
onCancel={() => setAdding(false)}
onExpired={() => expired.current()}
onCreated={(name) => {
focusAddAfterRefresh.current = true;
setAdding(false);
setNotice(`${name} created.`);
setAttempt((value) => value + 1);
@@ -433,6 +453,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
date={today.date}
revision={revision}
disabled={busy || loading || needsRefresh}
onHistorySaved={() => setAttempt(value => value + 1)}
onUpdate={update}
onManage={manage}
onExpired={() => expired.current()}
@@ -454,6 +475,7 @@ function SavedHabit({
disabled,
onUpdate,
onManage,
onHistorySaved,
onExpired,
}: {
habit: TodayHabit;
@@ -466,6 +488,7 @@ function SavedHabit({
taskId?: string
) => Promise<void>;
onExpired: () => void;
onHistorySaved: () => void;
onManage: (
habit: TodayHabit,
patch: Record<string, unknown> | null,
@@ -483,6 +506,7 @@ function SavedHabit({
revision={revision}
disabled={disabled}
onExpired={onExpired}
onHistorySaved={onHistorySaved}
onEdit={(color) => setEditing({ mode: "edit", color })}
onDelete={() => setEditing({ mode: "delete", color: "#196127" })}
editor={
@@ -521,7 +545,7 @@ function SavedHabit({
/>
);
})}
{addingTask ? (
{addingTask && (
<InlineTaskEditor
name=""
schedule={{ type: "daily" }}
@@ -534,20 +558,21 @@ function SavedHabit({
onSave={(patch) => onManage(habit, patch, undefined, true)}
onDelete={async () => {}}
/>
) : (
)}
<div className="ds-task-add-action">
{!habit.requirements.tasks.length && (
<p className="ds-footnote">Add a task to start checking in.</p>
)}
<Button
variant="text"
aria-haspopup="dialog"
aria-expanded={addingTask}
disabled={disabled || habit.requirements.tasks.length >= 100}
onClick={() => setAddingTask(true)}
>
Add task +
</Button>
</div>
)}
</>
) : undefined
}

View File

@@ -0,0 +1,10 @@
import { z } from "zod";
const clock = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/, "Use HH:MM time");
export const reminderInput = z.object({ enabled: z.boolean(), time: clock, quietStart: clock, quietEnd: clock }).strict();
export const defaultReminder = { enabled: false, time: "20:00", quietStart: "22:00", quietEnd: "08:00" };
export type ReminderPreferences = z.infer<typeof reminderInput>;
export type ReminderResponse = ReminderPreferences & { available: boolean; lastDelivery: { date: string; status: string } | null };
export function inQuietHours(time: string, start: string, end: string) {
if (start === end) return false;
return start < end ? time >= start && time < end : time >= start || time < end;
}

28
src/reminders/routes.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import { desc, eq } from "drizzle-orm";
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
import { reminderDeliveries, reminderSettings } from "../db/schema";
import { defaultReminder, reminderInput } from "./contracts";
export function createReminderRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, available: boolean, now: () => number) {
const app = new Hono<AuthEnv>();
app.use("*", auth.requireAuth);
app.get("/", c => {
const userId = c.get("user").id;
const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, userId)).get() ?? defaultReminder;
const last = db.select().from(reminderDeliveries).where(eq(reminderDeliveries.userId, userId)).orderBy(desc(reminderDeliveries.updatedAt)).get();
return c.json({ enabled: prefs.enabled, time: prefs.time, quietStart: prefs.quietStart, quietEnd: prefs.quietEnd, available,
lastDelivery: last ? { date: last.date, status: last.status === "pending" ? "uncertain" : last.status } : null });
});
app.put("/", auth.requireSameOrigin, bodyLimit({ maxSize: 2048 }), async c => {
const parsed = reminderInput.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) return c.json({ error: "Choose valid reminder and quiet-hour times." }, 422);
if (parsed.data.enabled && !available) return c.json({ error: "Discord reminders are not configured on this server." }, 422);
const row = { ...parsed.data, updatedAt: now() };
db.insert(reminderSettings).values({ userId: c.get("user").id, ...row })
.onConflictDoUpdate({ target: reminderSettings.userId, set: row }).run();
return c.json(parsed.data);
});
return app;
}

View File

@@ -0,0 +1,100 @@
import { expect, test } from "bun:test";
import { eq } from "drizzle-orm";
import { fixture } from "../habits/test-fixture";
import { reminderDeliveries, reminderSettings } from "../db/schema";
import { sendDueReminders } from "./worker";
import { createApi } from "../api";
import { defaultReminder, inQuietHours } from "./contracts";
import type { DiscordFetch } from "../sharing/routes";
const config = { token: "test-only-bot", origin: "https://minabot.example" };
async function setup() {
const f = fixture();
const habit = await f.json("/habits", "POST", { name: "Private habit name", method: "manual" }, 201);
f.db.insert(reminderSettings).values({ userId: "alice", ...defaultReminder, enabled: true, updatedAt: 0 }).run();
return { f, habit };
}
function transport(sent: string[]): DiscordFetch {
return async (url, init) => {
if (url.endsWith("/users/@me/channels")) return Response.json({ id: "223456789012345678" });
sent.push(String(init?.body)); return Response.json({ id: "323456789012345678" });
};
}
test("reminders follow local time, send only unfinished habits, and deduplicate concurrent workers", async () => {
const { f, habit } = await setup(); const sent: string[] = [];
try {
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T17:59:00Z")); expect(sent).toHaveLength(0);
const now = () => Date.parse("2026-09-04T18:00:00Z");
await Promise.all([sendDueReminders(f.db, config, transport(sent), now), sendDueReminders(f.db, config, transport(sent), now)]);
expect(sent).toHaveLength(1); expect(sent[0]).not.toContain("Private habit name");
expect(JSON.parse(sent[0]!).allowed_mentions.parse).toEqual([]);
await sendDueReminders(f.db, config, transport(sent), now); expect(sent).toHaveLength(1);
f.setTime("2026-09-05T18:00:00Z");
await f.json(`/habits/${habit.id}/days/2026-09-05/progress`, "PUT", { done: true });
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-05T18:00:00Z")); expect(sent).toHaveLength(1);
} finally { f.close(); }
});
test("quiet hours defer until their end and DST overlap sends once", async () => {
const { f } = await setup(); const sent: string[] = [];
try {
expect(inQuietHours("23:00", "22:00", "08:00")).toBe(true);
expect(inQuietHours("07:59", "22:00", "08:00")).toBe(true);
expect(inQuietHours("08:00", "22:00", "08:00")).toBe(false);
f.db.update(reminderSettings).set({ quietStart: "19:00", quietEnd: "21:00" }).run();
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T18:00:00Z")); expect(sent).toHaveLength(0);
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T19:00:00Z")); expect(sent).toHaveLength(1);
f.db.update(reminderSettings).set({ time: "02:30", quietStart: "00:00", quietEnd: "00:00" }).run();
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-10-25T00:30:00Z"));
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-10-25T01:30:00Z")); expect(sent).toHaveLength(2);
} finally { f.close(); }
});
test("opt-out during channel creation cancels the message", async () => {
const { f } = await setup(); let posts = 0;
try {
await sendDueReminders(f.db, config, async url => {
if (!url.endsWith("/users/@me/channels")) posts++;
f.db.update(reminderSettings).set({ enabled: false }).run();
return Response.json({ id: "223456789012345678" });
}, () => Date.parse("2026-09-04T18:00:00Z"));
expect(posts).toBe(0);
expect(f.db.select().from(reminderDeliveries).get()?.status).toBe("skipped");
} finally { f.close(); }
});
test("rate limits persist their retry deadline; ambiguous sends are not retried", async () => {
const { f } = await setup(); let attempts = 0;
let timestamp = Date.parse("2026-09-04T18:00:00Z");
try {
const request: DiscordFetch = async url => {
if (url.endsWith("/users/@me/channels")) return Response.json({ id: "223456789012345678" });
attempts++;
if (attempts === 1) return Response.json({ retry_after: 600 }, { status: 429 });
throw new Error("Ambiguous connection failure");
};
await sendDueReminders(f.db, config, request, () => timestamp);
timestamp += 60000; await sendDueReminders(f.db, config, request, () => timestamp); expect(attempts).toBe(1);
timestamp += 540000; await sendDueReminders(f.db, config, request, () => timestamp); expect(attempts).toBe(2);
expect(f.db.select().from(reminderDeliveries).get()?.status).toBe("uncertain");
timestamp += 60000; await sendDueReminders(f.db, config, request, () => timestamp); expect(attempts).toBe(2);
} finally { f.close(); }
});
test("reminder preferences require authentication, origin, valid clocks, and configured delivery", async () => {
const f = fixture();
try {
expect((await f.request("/reminders", "PUT", { ...defaultReminder, enabled: true })).status).toBe(422);
expect((await f.request("/reminders", "PUT", defaultReminder, "a", { Origin: "https://evil.test" })).status).toBe(403);
expect((await f.request("/reminders", "GET", undefined, "x")).status).toBe(401);
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-at-least-32-characters" }, undefined, undefined, undefined, { token: "test", channelId: "" });
const call = (body: unknown) => app.request(`${f.origin}/api/reminders`, { method: "PUT", headers: { Cookie: `minabot_session=${"a".repeat(43)}`, Origin: f.origin, "Content-Type": "application/json" }, body: JSON.stringify(body) });
expect((await call({ ...defaultReminder, time: "25:00" })).status).toBe(422);
expect((await call({ ...defaultReminder, enabled: true })).status).toBe(200);
expect(f.db.select().from(reminderSettings).where(eq(reminderSettings.userId, "alice")).get()?.enabled).toBe(true);
expect((await f.json("/reminders", "GET", undefined, 200, "b")).enabled).toBe(false);
const exported = await f.json("/account/export"); expect(exported.reminderSettings[0].enabled).toBe(true);
await f.request("/account", "DELETE", { confirmation: "DELETE" });
expect(f.db.select().from(reminderSettings).all()).toEqual([]);
} finally { f.close(); }
});

86
src/reminders/worker.ts Normal file
View File

@@ -0,0 +1,86 @@
import { createHash } from "node:crypto";
import { and, eq, lt } from "drizzle-orm";
import type { AppDatabase } from "../auth";
import { reminderDeliveries, reminderSettings, reminderWorkerState, users } from "../db/schema";
import { HabitService } from "../habits/service";
import { localDate } from "../habits/calendar";
import { inQuietHours } from "./contracts";
import type { DiscordFetch } from "../sharing/routes";
export type ReminderConfig = { token: string; origin: string };
function localClock(timestamp: number, timezone: string) {
const parts = new Intl.DateTimeFormat("en-GB", { timeZone: timezone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(timestamp);
return `${parts.find(part => part.type === "hour")!.value}:${parts.find(part => part.type === "minute")!.value}`;
}
export async function sendDueReminders(db: AppDatabase, config: ReminderConfig, request: DiscordFetch = fetch, now: () => number = Date.now) {
if (!config.token) return;
const cooldown = db.select().from(reminderWorkerState).where(eq(reminderWorkerState.id, "discord")).get();
if (cooldown && cooldown.blockedUntil > now()) return;
const headers = { Authorization: `Bot ${config.token}`, "Content-Type": "application/json" };
const rows = db.select({ prefs: reminderSettings, user: users }).from(reminderSettings).innerJoin(users, eq(users.id, reminderSettings.userId)).where(eq(reminderSettings.enabled, true)).all();
// Retain recent delivery history and deduplication records without unbounded growth.
db.delete(reminderDeliveries).where(lt(reminderDeliveries.updatedAt, now() - 90 * 86400000)).run();
for (const { user } of rows) {
function due() {
const currentUser = db.select().from(users).where(eq(users.id, user.id)).get();
const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get();
if (!currentUser || !prefs?.enabled) return null;
const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone);
if (clock < prefs.time || inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null;
const service = new HabitService(db, { id: currentUser.id, discordId: currentUser.discordId, username: currentUser.username, displayName: currentUser.globalName ?? currentUser.username, avatarUrl: null, timezone: currentUser.timezone }, timestamp);
service.sync();
// Avoid sending an artificial "today" after a backwards timezone change.
if (service.today !== localDate(timestamp, currentUser.timezone)) return null;
const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length;
return remaining ? { date: service.today, remaining } : null;
}
const initial = due(); if (!initial) continue;
const id = db.transaction(tx => {
const previous = tx.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, initial.date))).get();
if (previous && (previous.status !== "deferred" || (previous.retryAt ?? 0) > now())) return null;
const id = previous?.id ?? crypto.randomUUID();
if (previous) tx.update(reminderDeliveries).set({ status: "pending", retryAt: null, updatedAt: now() }).where(eq(reminderDeliveries.id, id)).run();
else tx.insert(reminderDeliveries).values({ id, userId: user.id, date: initial.date, status: "pending", updatedAt: now() }).run();
return id;
}, { behavior: "immediate" });
if (!id) continue;
const update = (status: string, retryAt: number | null = null) => db.update(reminderDeliveries).set({ status, retryAt, updatedAt: now() }).where(eq(reminderDeliveries.id, id)).run();
const defer = async (response?: Response) => {
const body = response ? await response.json().catch(() => null) as { retry_after?: number } | null : null;
const seconds = Math.min(86400, Math.max(60, Number(body?.retry_after) || 60));
update("deferred", now() + seconds * 1000);
if (response?.status === 429) db.insert(reminderWorkerState).values({ id: "discord", blockedUntil: now() + seconds * 1000 })
.onConflictDoUpdate({ target: reminderWorkerState.id, set: { blockedUntil: now() + seconds * 1000 } }).run();
};
let channel: Response;
try { channel = await request("https://discord.com/api/v10/users/@me/channels", {
method: "POST", headers, body: JSON.stringify({ recipient_id: user.discordId }), redirect: "error", signal: AbortSignal.timeout(10000),
}); } catch { await defer(); continue; }
if (channel.status === 429) { await defer(channel); return; }
if (channel.status >= 500) { await defer(channel); continue; }
const destination = channel.ok ? await channel.json().catch(() => null) as { id?: string } | null : null;
if (!destination?.id || !/^\d{17,20}$/.test(destination.id)) { update("failed"); continue; }
// A user may opt out or finish a habit while Discord opens the DM channel.
const latest = due();
if (!latest || latest.date !== initial.date) { update("skipped"); continue; }
let response: Response;
try { response = await request(`https://discord.com/api/v10/channels/${destination.id}/messages`, {
method: "POST", headers, redirect: "error", signal: AbortSignal.timeout(15000),
body: JSON.stringify({ content: `A little time for yourself: ${latest.remaining} ${latest.remaining === 1 ? "habit remains" : "habits remain"} today. Check in: ${config.origin}/`,
allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(id).digest("hex").slice(0, 24), enforce_nonce: true }),
}); } catch { update("uncertain"); continue; }
if (response.status === 429) { await defer(response); return; }
update(response.ok ? "sent" : response.status >= 500 ? "uncertain" : "failed");
}
}
export function startReminders(db: AppDatabase, config: ReminderConfig) {
let running = false;
const tick = async () => {
if (running) return; running = true;
try { await sendDueReminders(db, config); }
catch { console.error(JSON.stringify({ event: "reminder_worker_failed", timestamp: new Date().toISOString() })); }
finally { running = false; }
};
const timer = setInterval(() => void tick(), 60000); timer.unref();
return { stop: () => clearInterval(timer) };
}

View File

@@ -1,8 +1,11 @@
export type DiscordSharingConfig = { token: string; channelId: string };
export type DiscordSharingConfig = { token: string; channelId: string; allowedUserIds?: string[] };
/** Read only from the server entrypoint; never include credentials in public config. */
export function readDiscordSharingConfig(env = process.env): DiscordSharingConfig {
const allowedUserIds = (env.DISCORD_SHARING_ALLOWED_USER_IDS ?? "").split(",").map(id => id.trim()).filter(Boolean);
if (allowedUserIds.some(id => !/^\d{17,20}$/.test(id))) throw new Error("DISCORD_SHARING_ALLOWED_USER_IDS must contain comma-separated Discord user IDs");
return {
allowedUserIds,
token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""),
channelId: (env.DISCORD_SHARING_CHANNEL_ID ?? "").trim(),
};

View File

@@ -11,9 +11,10 @@ const botConfig = { token: "test-bot-token-do-not-expose", channelId: "223456789
const channel = { type: 0, id: botConfig.channelId, guild_id: "323456789012345678", name: "progress" };
function setup(request: DiscordFetch = async () => Response.json(channel), config: DiscordSharingConfig = botConfig) {
const f = fixture(); fixtures.push(f);
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-with-at-least-32-characters" }, undefined, () => Date.parse("2026-09-04T12:00:00Z"), request, config);
let timestamp = Date.parse("2026-09-04T12:00:00Z");
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-with-at-least-32-characters" }, undefined, () => timestamp, request, config);
const call = (path: string, method = "GET", body?: unknown, user = "a", origin = f.origin) => app.request(`${f.origin}/api/sharing${path}`, { method, headers: { Cookie: `minabot_session=${user.repeat(43)}`, Origin: origin, ...(body instanceof FormData ? {} : { "Content-Type": "application/json" }) }, body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body) });
return { f, call, connect: () => call("/discord") };
return { f, call, advance: (seconds: number) => { timestamp += seconds * 1000; }, connect: () => call("/discord") };
}
// A small real PNG is not 1200px wide; use a header fixture for transport validation.
function upload(id = crypto.randomUUID()) {
@@ -87,12 +88,14 @@ test("delivery sends exactly the PNG with mentions disabled and deduplicates ret
});
test("uncertain delivery is not resent and a known rate-limit rejection can be retried", async () => {
let posts = 0;
const { call, connect } = setup(async (_, init) => {
const { call, connect, advance } = setup(async (_, init) => {
if (init?.method !== "POST") return Response.json(channel);
posts++; if (posts === 1) return new Response(null, { status: 429 }); throw new Error("Network failed after sending");
});
await connect(); const id = crypto.randomUUID();
expect((await call("/discord/send", "POST", upload(id))).status).toBe(429);
expect((await call("/discord/send", "POST", upload(id))).status).toBe(429);
advance(60);
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain");
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain"); expect(posts).toBe(2);
});
@@ -103,3 +106,32 @@ test("invalid files and absent bot configuration cannot send", async () => {
await connect(); const form = upload(); form.set("image", new Blob(["not png"], { type: "image/png" }), "x.png");
expect((await call("/discord/send", "POST", form)).status).toBe(422); expect(posts).toBe(0);
});
test("posting quotas block new deliveries, allow duplicate checks, and expire", async () => {
let posts = 0;
const { f, call, advance } = setup(async (_, init) => {
if (init?.method === "POST") { posts++; return Response.json({ id: "423456789012345678" }); }
return Response.json(channel);
});
const id = crypto.randomUUID();
expect((await call("/discord/send", "POST", upload(id))).status).toBe(200);
expect((await call("/discord/send", "POST", upload(id))).status).toBe(200);
const limited = await call("/discord/send", "POST", upload());
expect(limited.status).toBe(429); expect(limited.headers.get("retry-after")).toBe("60");
expect((await call("/discord/send", "POST", upload(), "b")).status).toBe(200);
expect(posts).toBe(2);
f.sqlite.query("UPDATE sharing_limits SET count = 10 WHERE key = ?").run(`channel:${channel.id}`);
advance(30);
expect((await call("/discord/send", "POST", upload())).status).toBe(429);
advance(30);
expect((await call("/discord/send", "POST", upload())).status).toBe(200);
expect(posts).toBe(3);
});
test("an allowlist blocks unauthorized posting without preventing exports", async () => {
const { call } = setup(undefined, { ...botConfig, allowedUserIds: ["bob"] });
expect((await call("/discord/send", "POST", upload())).status).toBe(403);
expect((await (await call("/discord")).json()).connected).toBe(false);
expect((await (await call("/discord", "GET", undefined, "b")).json()).connected).toBe(true);
});

View File

@@ -1,10 +1,10 @@
import { createHash } from "node:crypto";
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import { eq } from "drizzle-orm";
import { eq, lt } from "drizzle-orm";
import { z } from "zod";
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
import { discordDeliveries } from "../db/schema";
import { discordDeliveries, sharingLimits } from "../db/schema";
import { HabitService, ApiError } from "../habits/service";
import { shade } from "../habits/calendar";
import { shareInput, type ShareData } from "./contracts";
@@ -93,11 +93,14 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
});
app.get("/discord", async c => {
if (!configured) return c.json({ connected: false, message: "Discord sharing has not been configured on this server." });
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId))
return c.json({ connected: false, message: "Discord sharing is limited to approved community members. PNG download remains available." });
const destination = await channel();
return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl });
});
app.post("/discord/send", async c => {
const userId = c.get("user").id;
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId)) return c.json({ error: "Discord sharing is limited to approved community members." }, 403);
if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");
const form = await c.req.formData().catch(() => { throw new ApiError(400, "Expected a progress image."); });
const id = z.string().uuid().safeParse(form.get("deliveryId"));
@@ -112,7 +115,38 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
if (previous.userId !== userId || previous.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again.");
return c.json({ status: previous.status === "sent" ? "sent" : "uncertain", messageUrl: previous.messageUrl ?? undefined });
}
db.insert(discordDeliveries).values({ id: id.data, userId, imageHash, status: "pending", createdAt: now() }).run();
const timestamp = now();
// Reserve both quotas and the delivery before any asynchronous outbound work.
// SQLite makes limits survive restarts and coordinate concurrent workers.
const retryAfter = db.transaction(tx => {
const raced = tx.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get();
if (raced) {
if (raced.userId !== userId || raced.imageHash !== imageHash) throw new ApiError(409, "Create a new preview before sending again.");
return -1;
}
tx.delete(sharingLimits).where(lt(sharingLimits.startedAt, timestamp - 86400000)).run();
const quotas = [
{ key: `channel:${config.channelId}`, maximum: 10 },
{ key: `channel:${config.channelId}:user:${c.get("user").discordId}`, maximum: 1 },
].map(quota => ({ ...quota, row: tx.select().from(sharingLimits).where(eq(sharingLimits.key, quota.key)).get() }));
const blocked = quotas.filter(({ row, maximum }) => row && timestamp < row.startedAt + 60000 && row.count >= maximum);
if (blocked.length) return Math.max(...blocked.map(({ row }) => Math.ceil((row!.startedAt + 60000 - timestamp) / 1000)));
for (const { key, row } of quotas) {
const current = row && timestamp < row.startedAt + 60000;
tx.insert(sharingLimits).values({ key, startedAt: current ? row.startedAt : timestamp, count: current ? row.count + 1 : 1 })
.onConflictDoUpdate({ target: sharingLimits.key, set: { startedAt: current ? row.startedAt : timestamp, count: current ? row.count + 1 : 1 } }).run();
}
tx.insert(discordDeliveries).values({ id: id.data, userId, imageHash, status: "pending", createdAt: timestamp }).run();
return 0;
}, { behavior: "immediate" });
if (retryAfter === -1) {
const delivery = db.select().from(discordDeliveries).where(eq(discordDeliveries.id, id.data)).get()!;
return c.json({ status: delivery.status === "sent" ? "sent" : "uncertain", messageUrl: delivery.messageUrl ?? undefined });
}
if (retryAfter > 0) {
c.header("Retry-After", String(retryAfter));
return c.json({ error: `Sharing limit reached. Try again in ${retryAfter} seconds.` }, 429);
}
const attachment = new FormData();
attachment.set("payload_json", JSON.stringify({ allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(`${userId}:${id.data}`).digest("hex").slice(0, 24), enforce_nonce: true, attachments: [{ id: 0, filename: "minabot-progress.png", description: "Progress card shared from minabot" }] }));
attachment.set("files[0]", image, "minabot-progress.png");

View File

@@ -993,6 +993,12 @@
@apply type-small;
}
.ds-date-inspector-heading { display: flex; align-items: center; gap: 14px; min-width: 0; }
.ds-date-inspector-actions { flex-shrink: 0; margin-left: auto; }
.ds-date-inspector:has(.ds-date-inspector-actions) { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
@media (max-width: 640px) {
.ds-date-inspector:has(.ds-date-inspector-actions) { align-items: stretch; flex-direction: column; gap: 18px; }
.ds-date-inspector-actions { margin-left: 36px; }
}
.ds-date-inspector-heading > svg { flex-shrink: 0; color: var(--ds-secondary); }
.ds-date-inspector-heading time { display: block; margin-top: 3px; @apply type-ui-heading; }
.ds-date-inspector-status { display: block; margin-top: 4px; color: var(--ds-secondary); overflow-wrap: anywhere; }
@@ -1405,10 +1411,6 @@
.ds-editable-task-row > .ds-checkbox { flex: 1; min-height: 76px; border-top: 0; }
.ds-checkbox-copy { display: grid; gap: 4px; min-width: 0; }
.ds-checkbox-description { color: var(--ds-secondary); @apply type-small; }
.ds-share-panel { margin-top: 32px; padding: 32px; border: 1px solid var(--ds-rule); }
.ds-share-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 32px; }
.ds-share-heading h2 { margin-top: 8px; }
.ds-share-heading > .ds-button { flex: 0 0 auto; padding: 12px; }
.ds-share-layout { display: grid; grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); gap: 36px; align-items: start; }
.ds-share-controls { display: grid; gap: 24px; min-width: 0; }
.ds-share-fieldset { border: 0; margin: 0; padding: 0; display: grid; gap: 10px; min-width: 0; }
@@ -1426,7 +1428,6 @@
.ds-share-actions .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
@media (max-width: 900px) {
.ds-share-layout { grid-template-columns: minmax(0, 1fr); gap: 28px; }
.ds-share-panel { padding: 20px; }
.ds-share-preview { padding: 12px; }
}
.ds-task-add-action { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; }
@@ -1435,3 +1436,89 @@
.ds-editable-task-row { gap: 4px; }
.ds-inline-item-form { padding: 16px; }
}
/* Secondary workspace flows share the same flat surfaces and editorial hierarchy. */
.ds-action-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; min-width: 0; }
.ds-action-row > .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; max-width: 100%; overflow-wrap: anywhere; }
.ds-action-row > .ds-button svg { flex: 0 0 auto; }
.ds-primary-actions { margin-top: 24px; }
.ds-utility-row { border-top: 1px solid var(--ds-rule); padding-top: 12px; margin-top: 20px; gap: 4px; }
.ds-utility-row .ds-button { color: var(--ds-secondary); @apply type-small; }
.ds-utility-row .ds-button[aria-expanded="true"] { color: var(--ds-ink); background: var(--ds-paper); }
.ds-panel-description { margin-top: 12px !important; color: var(--ds-secondary); max-width: 52ch; }
.ds-panel-close { flex: 0 0 44px; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid var(--ds-rule); }
.ds-setting-row { display: grid; grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.6fr); gap: 48px; padding: 28px 0; border-top: 1px solid var(--ds-rule); }
.ds-setting-row:last-of-type { padding-bottom: 0; }
.ds-setting-heading h3 { @apply type-ui-heading; }
.ds-setting-heading > div { display: grid; gap: 12px; margin-top: 10px; max-width: 32ch; color: var(--ds-secondary); @apply type-small; }
.ds-setting-controls { display: grid; gap: 16px; min-width: 0; align-content: start; }
.ds-form-stack { display: grid; gap: 20px; min-width: 0; align-content: start; }
.ds-form-stack .ds-field { margin-bottom: 0; }
.ds-supporting-copy, .ds-meta-label { color: var(--ds-secondary); @apply type-small; overflow-wrap: anywhere; }
.ds-meta-label { font-variant-numeric: tabular-nums; }
.ds-clock-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.ds-clock-fields input { font-variant-numeric: tabular-nums; }
.ds-preference-toggle { padding: 16px; background: var(--ds-surface); border: 1px solid var(--ds-rule); }
.ds-preference-toggle .ds-checkbox-description { margin-top: 4px; }
.ds-delivery-note { display: grid; gap: 6px; padding: 14px 16px; border-left: 2px solid var(--ds-control-border); background: var(--ds-surface); @apply type-small; }
.ds-confirmation { padding: 24px; background: var(--ds-surface); border-left: 2px solid var(--ds-ink); }
.ds-form-message { @apply type-small; color: var(--ds-secondary); overflow-wrap: anywhere; }
.ds-form-message:empty { display: none; }
.ds-form-message:not(:empty) { padding: 12px 0; }
.ds-form-message--error:not(:empty) { padding: 12px 16px; border-left: 2px solid var(--ds-ink); background: var(--ds-surface); color: var(--ds-ink); }
.ds-record { border-top: 1px solid var(--ds-rule); min-width: 0; }
.ds-record-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 24px 0; }
.ds-record-copy { min-width: 0; }
.ds-record-copy h3 { @apply type-title; overflow-wrap: anywhere; }
.ds-record-copy p { margin-top: 6px; color: var(--ds-secondary); @apply type-small; }
.ds-record-heading > .ds-action-row { flex-shrink: 0; }
.ds-disclosure-icon { transition: transform 160ms ease; }
[aria-expanded="true"] > .ds-disclosure-icon { transform: rotate(180deg); }
.ds-record-detail { padding: 8px 0 24px; }
.ds-empty-panel { display: flex; align-items: flex-start; gap: 18px; padding: 28px; background: var(--ds-surface); }
.ds-empty-panel > svg { flex-shrink: 0; color: var(--ds-secondary); }
.ds-empty-panel .ds-supporting-copy { margin-top: 8px; max-width: 48ch; }
.ds-history-actions { justify-content: flex-end; margin-top: 12px; }
.ds-history-editor { display: grid; grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.6fr); gap: 32px; padding: 24px; margin-top: 12px; border: 1px solid var(--ds-rule); background: var(--ds-surface); }
.ds-check-in-heading { display: grid; gap: 6px; }
.ds-check-in-controls { padding-left: 28px; border-left: 1px solid var(--ds-rule); }
@media (max-width: 900px) {
.ds-setting-row { gap: 28px; grid-template-columns: minmax(160px, 0.8fr) minmax(0, 1.6fr); }
.ds-clock-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ds-clock-fields > .ds-field:first-child { grid-column: 1 / -1; }
.ds-record-heading { align-items: flex-start; flex-direction: column; gap: 16px; }
}
@media (max-width: 640px) {
.ds-panel-description { @apply type-small; }
.ds-setting-row { grid-template-columns: minmax(0, 1fr); gap: 20px; padding: 24px 0; }
.ds-setting-heading > div { max-width: none; }
.ds-confirmation, .ds-empty-panel { padding: 18px; }
.ds-history-editor { grid-template-columns: minmax(0, 1fr); padding: 20px; gap: 24px; }
.ds-check-in-controls { padding: 24px 0 0; border-left: 0; border-top: 1px solid var(--ds-rule); }
.ds-history-actions { justify-content: flex-start; }
.ds-record-heading > .ds-action-row { flex-shrink: 1; gap: 8px; }
}
@media (prefers-reduced-motion: reduce) { .ds-disclosure-icon { transition: none; } }
/* Shared top-layer modals keep temporary flows out of the dashboard layout. */
.ds-modal { width: min(1040px, calc(100vw - 64px)); max-width: none; max-height: calc(100dvh - 64px); margin: auto; padding: 0; border: 1px solid var(--ds-rule); background: var(--ds-paper); color: var(--ds-ink); overflow: hidden; box-shadow: 0 24px 80px #00000026; }
.ds-modal[open] { display: flex; flex-direction: column; }
.ds-modal--compact { width: min(660px, calc(100vw - 64px)); }
.ds-modal::backdrop { background: #11111166; backdrop-filter: blur(3px); }
.ds-modal-heading { display: flex; flex: 0 0 auto; justify-content: space-between; align-items: flex-start; gap: 24px; padding: 28px 32px 24px; border-bottom: 1px solid var(--ds-rule); }
.ds-modal-heading > div { min-width: 0; }
.ds-modal-heading h2 { margin-top: 8px; @apply type-section; overflow-wrap: anywhere; }
.ds-modal-heading h2:focus { outline: none; }
.ds-modal-heading .ds-panel-description { @apply type-small; }
.ds-modal-body { overflow-y: auto; overscroll-behavior: contain; min-height: 0; padding: 28px 32px 32px; scrollbar-gutter: stable; }
.ds-modal-body > .ds-setting-row:first-child { border-top: 0; padding-top: 0; }
.ds-modal-body > .ds-record-list > .ds-record:first-child { border-top: 0; }
.ds-modal-body > .ds-record-list > .ds-record:first-child > .ds-record-heading { padding-top: 0; }
.ds-modal-form { min-width: 0; }
.ds-modal-body > .ds-history-editor { border: 0; background: transparent; padding: 0; margin: 0; }
@media (max-width: 640px) {
.ds-modal, .ds-modal--compact { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); }
.ds-modal-heading { padding: 20px; gap: 16px; }
.ds-modal-heading h2 { @apply type-title; }
.ds-modal-body { padding: 20px; }
}