feat: add opt-in daily Discord reminders with quiet hours

This commit is contained in:
syntaxbullet
2026-09-04 18:45:42 +02:00
parent 16d92f171c
commit 1208e93953
22 changed files with 2367 additions and 9 deletions

View File

@@ -3,6 +3,8 @@
Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The React Router 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. 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, See [the REST API reference](docs/API.md) for habits, task recurrence, dated progress,
combined calendars, historical corrections, daily resets, and optional count carryover. 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. (comma-separated Discord IDs) restricts posting to community members you approve.
An empty list permits all signed-in accounts. Restricted users can still preview 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. 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.

View File

@@ -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 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 to your deployment platform's alerting/log collection. No external alert provider
or off-machine monitor is provisioned automatically by this repository. 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,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,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

@@ -50,6 +50,20 @@
"when": 1788539042148, "when": 1788539042148,
"tag": "0006_sharing_limits", "tag": "0006_sharing_limits",
"breakpoints": true "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

@@ -1,4 +1,6 @@
// Isolated browser QA server. Never opens or changes the user's database. // 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 { fixture } from "../src/habits/test-fixture";
import { createApi } from "../src/api"; import { createApi } from "../src/api";
import index from "../src/index.html"; 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 previewCookie = "minabot_dashboard_preview_session";
const sharingPreview = process.env.SHARE_PREVIEW === "1"; const sharingPreview = process.env.SHARE_PREVIEW === "1";
let sharedImage: Blob | null = null; 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) { if (sharingPreview) {
f.setTime("2026-08-01T12:00:00Z"); 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); 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, undefined,
() => Date.parse("2026-09-04T12:00:00Z"), () => Date.parse("2026-09-04T12:00:00Z"),
sharingPreview ? async (_url, init) => { sharingPreview ? previewDiscord : undefined,
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 ? { token: "preview-bot-token", channelId: "223456789012345678" } : 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({ const server = Bun.serve({
hostname: "127.0.0.1", hostname: "127.0.0.1",
port: 3107, port: 3107,
routes: { routes: {
"/__preview/reminder": () => new Response(reminderMessage ?? "No reminder sent"),
"/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }), "/__preview/shared-image": () => sharingPreview && sharedImage ? new Response(sharedImage) : new Response(null, { status: 404 }),
"/__preview/login": () => "/__preview/login": () =>
new Response(null, { new Response(null, {

View File

@@ -1,3 +1,4 @@
import { ReminderSettings } from "./components/ReminderSettings";
import { import {
afterAll, afterAll,
afterEach, afterEach,
@@ -662,3 +663,23 @@ describe("design system tabs", () => {
expect(fetchMock).not.toHaveBeenCalled(); 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

@@ -24,6 +24,8 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
progressEvents: tx.select().from(schema.progressEvents).where(inArray(schema.progressEvents.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(), 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(), 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(), discordDeliveries: tx.select().from(schema.discordDeliveries).where(eq(schema.discordDeliveries.userId, user.id)).all(),
}; };
}); });

View File

@@ -1,3 +1,4 @@
import { createReminderRoutes } from "./reminders/routes";
import { errorDetails } from "./ops/monitor"; import { errorDetails } from "./ops/monitor";
import { createAccountRoutes } from "./account/routes"; import { createAccountRoutes } from "./account/routes";
import { createHabitRoutes } from "./habits/routes"; import { createHabitRoutes } from "./habits/routes";
@@ -25,6 +26,7 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
return c.json({ status: "ok" }); return c.json({ status: "ok" });
}); });
app.route("/api/auth", auth.routes); app.route("/api/auth", auth.routes);
app.route("/api/reminders", createReminderRoutes(db, auth, Boolean(sharingConfig.token), now ?? Date.now));
app.route("/api/account", createAccountRoutes(db, auth, now ?? Date.now)); app.route("/api/account", createAccountRoutes(db, auth, now ?? Date.now));
app.get("/api/me", auth.requireAuth, c => c.json(c.get("user"))); app.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now)); app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));

View File

@@ -1,3 +1,4 @@
import { ReminderSettings } from "./ReminderSettings";
import { useId, useRef, useState } from "react"; import { useId, useRef, useState } from "react";
import type { PublicUser } from "../shared/user"; import type { PublicUser } from "../shared/user";
import { habitRequest } from "../lib/dashboard"; import { habitRequest } from "../lib/dashboard";
@@ -29,6 +30,7 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
<datalist id={listId}>{["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => <option key={zone} value={zone} />)}</datalist> <datalist id={listId}>{["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => <option key={zone} value={zone} />)}</datalist>
<Button type="submit" disabled={busy}>Save timezone</Button> <Button type="submit" disabled={busy}>Save timezone</Button>
</form> </form>
<ReminderSettings timezone={user.timezone} />
<div className="ds-actions"> <div className="ds-actions">
<ButtonLink href="/api/account/export" download="minabot-export.json">Export my data</ButtonLink> <ButtonLink href="/api/account/export" download="minabot-export.json">Export my data</ButtonLink>
<Button variant="text" disabled={busy} onClick={() => setDeleting(!deleting)} aria-expanded={deleting}>Delete account</Button> <Button variant="text" disabled={busy} onClick={() => setDeleting(!deleting)} aria-expanded={deleting}>Delete account</Button>

View File

@@ -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<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 }: { timezone: string }) {
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 <div className="ds-inline-item-form" aria-label="Daily reminders">
<h3 className="type-ui-heading">Daily reminders</h3>
<p>One private Discord message when you still have habits left today. Times follow {timezone}. No habit names are sent.</p>
{!settings ? <Button onClick={() => setAttempt(value => value + 1)}>Reload reminders</Button> : <form onSubmit={async event => {
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 && <p>Discord reminders are not configured on this server.</p>}
<fieldset className="ds-inline-item-fields" disabled={busy}>
<Checkbox 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-form-grid">
{([['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="type-small">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>
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save reminders"}</Button>
</fieldset>
{settings.lastDelivery && <p className="type-small">{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}</p>}
</form>}
{error && <p role="alert">{error}</p>}
<p role="status">{notice}</p>
</div>;
}

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", { export const users = sqliteTable("users", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@@ -66,3 +66,21 @@ export const sharingLimits = sqliteTable('sharing_limits', {
startedAt: integer('started_at').notNull(), startedAt: integer('started_at').notNull(),
count: integer('count').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

@@ -1,3 +1,4 @@
import { startReminders } from "./reminders/worker";
import { backupConfig, startBackups } from "./ops/backups"; import { backupConfig, startBackups } from "./ops/backups";
import { databasePath } from "./db/config"; import { databasePath } from "./db/config";
import { db, sqlite } from "./db"; 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; const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined;
if (import.meta.hot) import.meta.hot.dispose(() => backups?.stop()); 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 app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig(), { healthy: () => !backups?.state.enabled || (!backups.state.failed && backups.state.lastSuccess > 0) });
const server = Bun.serve({ const server = Bun.serve({

View File

@@ -15,6 +15,7 @@ test("WAL snapshots restore committed progress, revoke sessions, replicate, and
mkdirSync(config.replica!); mkdirSync(config.replica!);
const habit = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "cups" }, 201); 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 }); 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); const first = createBackup(f.sqlite, config);
expect(statSync(first).mode & 0o777).toBe(0o600); expect(statSync(first).mode & 0o777).toBe(0o600);
expect(latestBackupTime(config)).toBeGreaterThan(0); expect(latestBackupTime(config)).toBeGreaterThan(0);
@@ -24,6 +25,7 @@ test("WAL snapshots restore committed progress, revoke sessions, replicate, and
try { try {
expect(restored.query("SELECT count FROM habit_days").get()).toEqual({ count: 6 }); expect(restored.query("SELECT count FROM habit_days").get()).toEqual({ count: 6 });
expect(restored.query("SELECT * FROM sessions").all()).toEqual([]); 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" }); expect(restored.query("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok" });
} finally { restored.close(); } } finally { restored.close(); }
expect(() => restoreBackup(first, destination)).toThrow("new path"); 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); expect(latestBackupTime(config)).toBe(0);
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); } } 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 }); }
});

View File

@@ -72,7 +72,10 @@ export function restoreBackup(source: string, destination: string) {
try { try {
copyFileSync(source, temporary, constants.COPYFILE_EXCL); chmodSync(temporary, 0o600); copyFileSync(source, temporary, constants.COPYFILE_EXCL); chmodSync(temporary, 0o600);
const restored = new Database(temporary); 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); verifyBackup(temporary); durable(temporary);
// Link is exclusive: a concurrently-created destination is never overwritten. // Link is exclusive: a concurrently-created destination is never overwritten.
linkSync(temporary, destination); linkSync(temporary, destination);

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