diff --git a/README.md b/README.md index e3cbea2..1f86dd0 100644 --- a/README.md +++ b/README.md @@ -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. @@ -345,3 +347,30 @@ existing delivery does not consume quota. Rejected attempts consume quota too; (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. diff --git a/docs/LAUNCH-VERIFICATION.md b/docs/LAUNCH-VERIFICATION.md new file mode 100644 index 0000000..60b3be9 --- /dev/null +++ b/docs/LAUNCH-VERIFICATION.md @@ -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. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 85e940b..827fb39 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -71,3 +71,14 @@ failures, and emits one `health_recovered` event on recovery. It stays quiet whi 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. diff --git a/drizzle/0007_reminders.sql b/drizzle/0007_reminders.sql new file mode 100644 index 0000000..1daa0c3 --- /dev/null +++ b/drizzle/0007_reminders.sql @@ -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 +); diff --git a/drizzle/0008_reminder_cooldown.sql b/drizzle/0008_reminder_cooldown.sql new file mode 100644 index 0000000..be977e2 --- /dev/null +++ b/drizzle/0008_reminder_cooldown.sql @@ -0,0 +1,4 @@ +CREATE TABLE `reminder_worker_state` ( + `id` text PRIMARY KEY NOT NULL, + `blocked_until` integer NOT NULL +); diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..aec5949 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..4929231 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index bde93af..57615e3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,20 @@ "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 } ] } \ No newline at end of file diff --git a/scripts/dashboard-preview.ts b/scripts/dashboard-preview.ts index dd6b97e..b23f80f 100644 --- a/scripts/dashboard-preview.ts +++ b/scripts/dashboard-preview.ts @@ -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, { diff --git a/src/App.test.tsx b/src/App.test.tsx index 8b1d814..3ba3f08 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,3 +1,4 @@ +import { ReminderSettings } from "./components/ReminderSettings"; import { afterAll, afterEach, @@ -662,3 +663,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()); + const time = container.querySelector('input[name="time"]')!; + await act(async () => { + time.value = "14:30"; + container.querySelector('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('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" }); +}); diff --git a/src/account/routes.ts b/src/account/routes.ts index af63085..ded6a4b 100644 --- a/src/account/routes.ts +++ b/src/account/routes.ts @@ -24,6 +24,8 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType c.json(c.get("user"))); app.route("/api", createHabitRoutes(db, auth, now ?? Date.now)); diff --git a/src/components/AccountSettings.tsx b/src/components/AccountSettings.tsx index f2d5442..f9b97d4 100644 --- a/src/components/AccountSettings.tsx +++ b/src/components/AccountSettings.tsx @@ -1,3 +1,4 @@ +import { ReminderSettings } from "./ReminderSettings"; import { useId, useRef, useState } from "react"; import type { PublicUser } from "../shared/user"; import { habitRequest } from "../lib/dashboard"; @@ -29,6 +30,7 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser {["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => +
Export my data diff --git a/src/components/ReminderSettings.tsx b/src/components/ReminderSettings.tsx new file mode 100644 index 0000000..792a355 --- /dev/null +++ b/src/components/ReminderSettings.tsx @@ -0,0 +1,56 @@ +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 = { + 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 }: { timezone: string }) { + const [settings, setSettings] = useState(); + 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("/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
+

Daily reminders

+

One private Discord message when you still have habits left today. Times follow {timezone}. No habit names are sent.

+ {!settings ? :
{ + event.preventDefault(); if (saving.current) 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); 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); } + }}> + {!settings.available &&

Discord reminders are not configured on this server.

} +
+ setDraft(value => ({ ...value, enabled: event.target.checked }))} /> +
+ {([['time', 'Remind me at'], ['quietStart', 'Quiet hours start'], ['quietEnd', 'Quiet hours end']] as const).map(([key, label]) => + {id => })} +
+

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.

+ +
+ {settings.lastDelivery &&

{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}

} +
} + {error &&

{error}

} +

{notice}

+
; +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 5bc1e58..a11695a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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(), @@ -66,3 +66,21 @@ export const sharingLimits = sqliteTable('sharing_limits', { 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(), +}); diff --git a/src/index.ts b/src/index.ts index a5378a3..9a7a80d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { startReminders } from "./reminders/worker"; import { backupConfig, startBackups } from "./ops/backups"; import { databasePath } from "./db/config"; import { db, sqlite } from "./db"; @@ -9,6 +10,9 @@ import index from "./index.html"; 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({ diff --git a/src/ops/backups.test.ts b/src/ops/backups.test.ts index 9389f83..b9bd3ea 100644 --- a/src/ops/backups.test.ts +++ b/src/ops/backups.test.ts @@ -15,6 +15,7 @@ test("WAL snapshots restore committed progress, revoke sessions, replicate, and 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); @@ -24,6 +25,7 @@ test("WAL snapshots restore committed progress, revoke sessions, replicate, and 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"); @@ -48,3 +50,24 @@ test("invalid backups and missing replica mounts fail without overwriting data", 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 }); } +}); diff --git a/src/ops/backups.ts b/src/ops/backups.ts index 0dabd90..1e1176c 100644 --- a/src/ops/backups.ts +++ b/src/ops/backups.ts @@ -72,7 +72,10 @@ export function restoreBackup(source: string, destination: string) { 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;"); } finally { restored.close(); } + 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); diff --git a/src/reminders/contracts.ts b/src/reminders/contracts.ts new file mode 100644 index 0000000..29be852 --- /dev/null +++ b/src/reminders/contracts.ts @@ -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; +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; +} diff --git a/src/reminders/routes.ts b/src/reminders/routes.ts new file mode 100644 index 0000000..ab4cf00 --- /dev/null +++ b/src/reminders/routes.ts @@ -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, available: boolean, now: () => number) { + const app = new Hono(); + 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; +} diff --git a/src/reminders/worker.test.ts b/src/reminders/worker.test.ts new file mode 100644 index 0000000..5687cd4 --- /dev/null +++ b/src/reminders/worker.test.ts @@ -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(); } +}); diff --git a/src/reminders/worker.ts b/src/reminders/worker.ts new file mode 100644 index 0000000..12bb105 --- /dev/null +++ b/src/reminders/worker.ts @@ -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) }; +}