From 8e846fa11b2f9d2337dd4fc3b38b2f5ad62ceed5 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 07:36:49 +0200 Subject: [PATCH] Add named reminders with multiple times and day schedules --- README.md | 44 +- drizzle/0011_multiple_reminders.sql | 4 + drizzle/meta/0011_snapshot.json | 1082 +++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/App.test.tsx | 7 +- src/components/ReminderSettings.tsx | 33 +- src/db/migrations.test.ts | 21 + src/db/schema.ts | 4 +- src/reminders/contracts.ts | 19 +- src/reminders/routes.ts | 10 +- src/reminders/worker.test.ts | 114 +++ src/reminders/worker.ts | 49 +- 12 files changed, 1341 insertions(+), 53 deletions(-) create mode 100644 drizzle/0011_multiple_reminders.sql create mode 100644 drizzle/meta/0011_snapshot.json diff --git a/README.md b/README.md index 443d4b0..73abcfe 100644 --- a/README.md +++ b/README.md @@ -379,27 +379,39 @@ existing delivery does not consume quota. Rejected attempts consume quota too; 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 +### 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. +Open **Settings → Reminders** to configure up to 20 reminders, each with an optional +name (up to 80 characters), local time, and selected weekdays or a specific date. +Select all seven days for a daily reminder. Discord messages include any reminder names that were set, along with the +unfinished-habit count and app link. Combined nudges include the applicable names. Reminders +only send when habits remain unfinished, and times follow the account timezone. + +Quiet-hour start is inclusive, end is exclusive, and equal times disable quiet +hours. Overdue reminders are combined into one nudge when quiet hours end or the +server resumes, then later scheduled times continue normally. Overlapping entries +at the same time produce one nudge. Missed reminders never carry into another day. +Editing a name or removing and re-adding a time does not resend an already delivered +slot that day; adding a past time can trigger a nudge on the next worker tick. 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. +a sharing channel is not required. Users must be reachable by the bot (normally a +shared Discord server and enabled DMs). Settings show the most recent delivery +result. Uncheck the option and save to opt out. Reminders are disabled 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. +requires authentication, matching Origin, and `{enabled, reminders, quietStart, +quietEnd}`. Times use `HH:MM`. Each reminder is either +`{type: "weekly", name?: string, time: "09:00", days: [1, 3, 5]}` (Sunday is 0) or +`{type: "date", name?: string, time: "18:00", date: "2026-09-08"}`. +The legacy single `time` input remains supported. Existing settings retain their +daily schedule; pre-upgrade deliveries prevent another nudge that same day. + +Per-user/date/time reservations persist across restarts and concurrent workers. +Discord 429 retry deadlines are persisted; ambiguous sends are not retried for +that time slot. Later reminders can still send. Delivery records expire after +90 days. Account export includes schedules and names; restore disables reminders +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 diff --git a/drizzle/0011_multiple_reminders.sql b/drizzle/0011_multiple_reminders.sql new file mode 100644 index 0000000..99c6d5e --- /dev/null +++ b/drizzle/0011_multiple_reminders.sql @@ -0,0 +1,4 @@ +DROP INDEX `reminder_deliveries_user_date_idx`;--> statement-breakpoint +ALTER TABLE `reminder_deliveries` ADD `slot` text DEFAULT '*' NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX `reminder_deliveries_user_date_slot_idx` ON `reminder_deliveries` (`user_id`,`date`,`slot`);--> statement-breakpoint +ALTER TABLE `reminder_settings` ADD `reminders` text; \ No newline at end of file diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..00c9d1b --- /dev/null +++ b/drizzle/meta/0011_snapshot.json @@ -0,0 +1,1082 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "31d42afd-5a60-4cbe-8188-d9aff933e13e", + "prevId": "418fab6d-dda8-490f-8cb3-acccd64218bb", + "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": {} + }, + "live_discord_cards": { + "name": "live_discord_cards", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message_url": { + "name": "message_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_hash": { + "name": "image_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dirty": { + "name": "dirty", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "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": {}, + "foreignKeys": { + "live_discord_cards_user_id_users_id_fk": { + "name": "live_discord_cards_user_id_users_id_fk", + "tableFrom": "live_discord_cards", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "slot": { + "name": "slot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'*'" + }, + "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_slot_idx": { + "name": "reminder_deliveries_user_date_slot_idx", + "columns": [ + "user_id", + "date", + "slot" + ], + "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'" + }, + "reminders": { + "name": "reminders", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 8a5fd32..e89ed83 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1788597003524, "tag": "0010_tidy_thena", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1788845281801, + "tag": "0011_multiple_reminders", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/App.test.tsx b/src/App.test.tsx index cc536b4..3f9cabf 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -766,14 +766,15 @@ test("reminder form submits native time values and explicit opt-out", async () = 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"]')!; + const time = container.querySelector('input[name="reminder-time-0"]')!; await act(async () => { time.value = "14:30"; + time.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event); 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" }); + expect(saved[0]).toEqual({ enabled: true, reminders: [{ type: "weekly", time: "14:30", days: [0, 1, 2, 3, 4, 5, 6] }], 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" }); + expect(saved[1]).toEqual({ enabled: false, reminders: [{ type: "weekly", time: "14:30", days: [0, 1, 2, 3, 4, 5, 6] }], quietStart: "22:00", quietEnd: "08:00" }); }); diff --git a/src/components/ReminderSettings.tsx b/src/components/ReminderSettings.tsx index cc22834..ff58fac 100644 --- a/src/components/ReminderSettings.tsx +++ b/src/components/ReminderSettings.tsx @@ -1,17 +1,17 @@ import { FormMessage, SettingRow } from "./design-system/WorkspacePanel"; import { useEffect, useRef, useState } from "react"; import { habitRequest } from "../lib/dashboard"; -import { defaultReminder, type ReminderResponse } from "../reminders/contracts"; +import { allWeekdays, defaultReminder, entries, type ReminderEntry, 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.", + sent: "Delivered", deferred: "Waiting to retry", pending: "Delivery unconfirmed", uncertain: "Delivery unconfirmed; check Discord. This reminder will not be retried.", failed: "Discord could not deliver it. Check your DM permissions and that you share a server with the bot.", skipped: "Skipped because your check-ins or settings changed", }; export function ReminderSettings({ timezone, onBusyChange, disabled = false }: { timezone: string; onBusyChange?: (busy: boolean) => void; disabled?: boolean }) { const [settings, setSettings] = useState(); - const [draft, setDraft] = useState(defaultReminder); + const [draft, setDraft] = useState({ ...defaultReminder, reminders: entries(defaultReminder) }); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); const [busy, setBusy] = useState(false); @@ -20,31 +20,42 @@ export function ReminderSettings({ timezone, onBusyChange, disabled = 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 }); } }) + .then(data => { if (!controller.signal.aborted) { setSettings(data); setDraft({ enabled: data.enabled, time: data.time ?? "20:00", reminders: entries(data), quietStart: data.quietStart, quietEnd: data.quietEnd }); } }) .catch(error => { if (!controller.signal.aborted) setError(error.message); }); return () => controller.abort(); }, [attempt]); - return

A private Discord nudge when you still have habits left today. No habit names are sent.

Times in {timezone}

}> + const changeEntry = (index: number, entry: ReminderEntry) => setDraft(value => ({ ...value, reminders: value.reminders.map((previous, position) => position === index ? entry : previous) })); + return

A private Discord nudge when you still have habits left today. Named reminders include their name in the message. No habit names are sent.

Times in {timezone}

}> {!settings ? error ? :

Loading reminders…

:
{ event.preventDefault(); if (saving.current || disabled) return; const form = event.currentTarget; - const submitted = { ...draft, ...Object.fromEntries((["time", "quietStart", "quietEnd"] as const).map(key => [key, (form.elements.namedItem(key) as HTMLInputElement).value])) }; + const submitted = { enabled: draft.enabled, reminders: draft.reminders.map((entry, index) => ({ ...entry, time: (form.elements.namedItem(`reminder-time-${index}`) as HTMLInputElement).value, ...(entry.type === "date" ? { date: (form.elements.namedItem(`reminder-date-${index}`) as HTMLInputElement).value } : {}) })), quietStart: draft.quietStart, quietEnd: draft.quietEnd, ...Object.fromEntries((["quietStart", "quietEnd"] as const).map(key => [key, (form.elements.namedItem(key) as HTMLInputElement).value])) }; saving.current = true; setBusy(true); onBusyChange?.(true); setError(""); setNotice(""); try { await habitRequest("/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(submitted) }); - setDraft(submitted); - setNotice(submitted.enabled ? "Daily Discord reminders enabled." : "Reminders turned off."); + setDraft(value => ({ ...value, ...submitted })); + setNotice(submitted.enabled ? "Discord reminders saved." : "Reminders turned off."); } catch (error) { setError(error instanceof Error ? error.message : "Could not save reminders. Try again."); } finally { saving.current = false; setBusy(false); onBusyChange?.(false); } }}> {!settings.available &&

Discord reminders are not configured on this server.

}
- setDraft(value => ({ ...value, enabled: event.target.checked }))} /> + setDraft(value => ({ ...value, enabled: event.target.checked }))} /> + {draft.reminders.map((entry, index) =>
+ {id => changeEntry(index, { ...entry, name: event.target.value })} />} +
+ {id => changeEntry(index, { ...entry, time: event.currentTarget.value })} />} + {id => } +
+ {entry.type === "date" ? {id => changeEntry(index, { ...entry, date: event.currentTarget.value })} />} : +
{[[1, "Mon"], [2, "Tue"], [3, "Wed"], [4, "Thu"], [5, "Fri"], [6, "Sat"], [0, "Sun"]].map(([day, label]) => changeEntry(index, { ...entry, days: event.target.checked ? [...entry.days, Number(day)] : entry.days.filter(value => value !== day) })} />)}
} + +
)} +
- {([['time', 'Remind me at'], ['quietStart', 'Quiet hours start'], ['quietEnd', 'Quiet hours end']] as const).map(([key, label]) => + {([['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 &&

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

} diff --git a/src/db/migrations.test.ts b/src/db/migrations.test.ts index c6f6482..9480f77 100644 --- a/src/db/migrations.test.ts +++ b/src/db/migrations.test.ts @@ -51,3 +51,24 @@ test('deadline migration repairs historical expiry using the original state befo ]); } finally { sqlite.close(); rmSync(directory, { recursive: true, force: true }); } }); + +test('multiple reminder migration preserves existing schedules and delivery history', () => { + const directory = mkdtempSync(join(tmpdir(), 'minabot-reminders-upgrade-')); + const sqlite = new Database(':memory:'); sqlite.exec('PRAGMA foreign_keys = ON'); const db = drizzle(sqlite); + try { + mkdirSync(join(directory, 'meta')); + const journal = JSON.parse(readFileSync('drizzle/meta/_journal.json', 'utf8')); + journal.entries = journal.entries.slice(0, 11); + writeFileSync(join(directory, 'meta/_journal.json'), JSON.stringify(journal)); + for (const entry of journal.entries) copyFileSync(`drizzle/${entry.tag}.sql`, join(directory, `${entry.tag}.sql`)); + migrate(db, { migrationsFolder: directory }); + sqlite.exec("INSERT INTO users (id, discord_id, username, created_at, updated_at) VALUES ('u', 'u', 'u', 0, 0)"); + sqlite.exec("INSERT INTO reminder_settings (user_id, enabled, time, quiet_start, quiet_end, updated_at) VALUES ('u', 1, '17:30', '23:00', '07:00', 0)"); + sqlite.exec("INSERT INTO reminder_deliveries (id, user_id, date, status, updated_at) VALUES ('d', 'u', '2026-09-08', 'sent', 0)"); + migrate(db, { migrationsFolder: './drizzle' }); + expect(sqlite.query('SELECT enabled, time, quiet_start, quiet_end, reminders FROM reminder_settings').get()).toEqual({ enabled: 1, time: '17:30', quiet_start: '23:00', quiet_end: '07:00', reminders: null }); + expect(sqlite.query('SELECT slot, status FROM reminder_deliveries').get()).toEqual({ slot: '*', status: 'sent' }); + sqlite.exec("INSERT INTO reminder_deliveries (id, user_id, date, slot, status, updated_at) VALUES ('d2', 'u', '2026-09-09', '09:00', 'sent', 0), ('d3', 'u', '2026-09-09', '18:00', 'sent', 0)"); + expect(() => sqlite.exec("INSERT INTO reminder_deliveries (id, user_id, date, slot, status, updated_at) VALUES ('d4', 'u', '2026-09-09', '18:00', 'sent', 0)")).toThrow(); + } finally { sqlite.close(); rmSync(directory, { recursive: true, force: true }); } +}); diff --git a/src/db/schema.ts b/src/db/schema.ts index 1c1bd4d..5c90b1b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -71,6 +71,7 @@ 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'), + reminders: text('reminders', { mode: 'json' }).$type(), quietStart: text('quiet_start').notNull().default('22:00'), quietEnd: text('quiet_end').notNull().default('08:00'), updatedAt: integer('updated_at').notNull(), @@ -78,9 +79,10 @@ export const reminderSettings = sqliteTable('reminder_settings', { export const reminderDeliveries = sqliteTable('reminder_deliveries', { id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + slot: text('slot').notNull().default('*'), 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)]); +}, t => [uniqueIndex('reminder_deliveries_user_date_slot_idx').on(t.userId, t.date, t.slot)]); export const reminderWorkerState = sqliteTable('reminder_worker_state', { id: text('id').primaryKey(), blockedUntil: integer('blocked_until').notNull(), }); diff --git a/src/reminders/contracts.ts b/src/reminders/contracts.ts index 29be852..c6606f6 100644 --- a/src/reminders/contracts.ts +++ b/src/reminders/contracts.ts @@ -1,6 +1,23 @@ 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 allWeekdays = [0, 1, 2, 3, 4, 5, 6]; +export const reminderEntry = z.discriminatedUnion("type", [ + z.object({ type: z.literal("weekly"), name: z.string().trim().max(80).optional(), time: clock, days: z.array(z.number().int().min(0).max(6)).min(1).max(7).refine(days => new Set(days).size === days.length) }).strict(), + z.object({ type: z.literal("date"), name: z.string().trim().max(80).optional(), time: clock, date: z.iso.date() }).strict(), +]); +export type ReminderEntry = z.infer; +export const reminderInput = z.object({ enabled: z.boolean(), time: clock.optional(), reminders: z.array(reminderEntry).min(1).max(20).optional(), quietStart: clock, quietEnd: clock }).strict() + .refine(value => value.reminders !== undefined || value.time !== undefined, "Add a reminder time"); +export function entries(prefs: { reminders?: ReminderEntry[] | null; time?: string }): ReminderEntry[] { + return prefs.reminders ?? [{ type: "weekly", time: prefs.time ?? "20:00", days: allWeekdays }]; +} +export function entriesForDate(prefs: Parameters[0], date: string): ReminderEntry[] { + const weekday = new Date(`${date}T12:00:00Z`).getUTCDay(); + return entries(prefs).filter(entry => entry.type === "date" ? entry.date === date : entry.days.includes(weekday)); +} +export function timesForDate(prefs: Parameters[0], date: string): string[] { + return [...new Set(entriesForDate(prefs, date).map(entry => entry.time))].sort(); +} 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 }; diff --git a/src/reminders/routes.ts b/src/reminders/routes.ts index ab4cf00..843fbce 100644 --- a/src/reminders/routes.ts +++ b/src/reminders/routes.ts @@ -3,7 +3,7 @@ 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"; +import { defaultReminder, entries, reminderInput } from "./contracts"; export function createReminderRoutes(db: AppDatabase, auth: ReturnType, available: boolean, now: () => number) { const app = new Hono(); @@ -12,14 +12,14 @@ export function createReminderRoutes(db: AppDatabase, auth: ReturnType { + app.put("/", auth.requireSameOrigin, bodyLimit({ maxSize: 8192 }), 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.success) return c.json({ error: "Choose valid reminder times, days or dates, and quiet hours (up to 20 reminders)." }, 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() }; + const row = { ...parsed.data, reminders: entries(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); diff --git a/src/reminders/worker.test.ts b/src/reminders/worker.test.ts index 5687cd4..0e3f00d 100644 --- a/src/reminders/worker.test.ts +++ b/src/reminders/worker.test.ts @@ -98,3 +98,117 @@ test("reminder preferences require authentication, origin, valid clocks, and con expect(f.db.select().from(reminderSettings).all()).toEqual([]); } finally { f.close(); } }); + +test("multiple times respect weekdays and dates, deduplicate overlaps, and stop after completion", async () => { + const { f, habit } = await setup(); const sent: string[] = []; + try { + f.db.update(reminderSettings).set({ reminders: [ + { type: "weekly", time: "10:00", days: [5] }, + { type: "date", time: "10:00", date: "2026-09-04" }, + { type: "date", time: "12:00", date: "2026-09-04" }, + { type: "weekly", time: "14:00", days: [1] }, + { type: "weekly", time: "18:00", days: [5] }, + ] }).run(); + const tick = (time: string) => sendDueReminders(f.db, config, transport(sent), () => Date.parse(`2026-09-04T${time}:00Z`)); + await Promise.all([tick("08:00"), tick("08:00")]); expect(sent).toHaveLength(1); + await tick("10:00"); expect(sent).toHaveLength(2); + await tick("12:00"); expect(sent).toHaveLength(2); + f.setTime("2026-09-04T15:00:00Z"); + await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, "PUT", { done: true }); + await tick("16:00"); expect(sent).toHaveLength(2); + expect(f.db.select().from(reminderDeliveries).all().map(row => row.slot)).toEqual(["10:00", "12:00"]); + await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-05T18:00:00Z")); expect(sent).toHaveLength(2); + } finally { f.close(); } +}); + +test("overdue reminders coalesce after quiet hours and retain separate delivery records", async () => { + const { f } = await setup(); const sent: string[] = []; + try { + f.db.update(reminderSettings).set({ quietStart: "09:00", quietEnd: "13:00", reminders: [ + { type: "weekly", time: "10:00", days: [5] }, { type: "weekly", time: "12:00", days: [5] }, { type: "weekly", time: "16:00", days: [5] }, + ] }).run(); + const tick = (time: string) => sendDueReminders(f.db, config, transport(sent), () => Date.parse(`2026-09-04T${time}:00Z`)); + await tick("10:00"); expect(sent).toHaveLength(0); + await Promise.all([tick("11:00"), tick("11:00")]); expect(sent).toHaveLength(1); + expect(f.db.select().from(reminderDeliveries).all()).toHaveLength(2); + await tick("14:00"); expect(sent).toHaveLength(2); + await tick("15:00"); expect(sent).toHaveLength(2); + } finally { f.close(); } +}); + +test("removing a due schedule while opening Discord cancels delivery", 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({ reminders: [{ type: "date", time: "20:00", date: "2026-09-05" }] }).run(); + return Response.json({ id: "223456789012345678" }); + }, () => Date.parse("2026-09-04T18:00:00Z")); + expect(posts).toBe(0); + } finally { f.close(); } +}); + +test("schedule validation rejects empty days, invalid dates, and excessive entries", async () => { + const { reminderInput } = await import("./contracts"); + const parse = (reminders: unknown[]) => reminderInput.safeParse({ enabled: true, quietStart: "22:00", quietEnd: "08:00", reminders }).success; + expect(parse([])).toBe(false); + expect(parse([{ type: "weekly", time: "12:00", days: [] }])).toBe(false); + expect(parse([{ type: "date", time: "12:00", date: "2026-02-30" }])).toBe(false); + expect(parse(Array.from({ length: 21 }, () => ({ type: "weekly", time: "12:00", days: [1] })))).toBe(false); + expect(parse([{ type: "weekly", time: "12:00", days: [1, 3] }, { type: "date", time: "18:00", date: "2026-09-08" }])).toBe(true); +}); + +test("named schedules round-trip through the API and account export", async () => { + const f = fixture(); + try { + const reminders = [{ type: "weekly", name: "Morning check-in", time: "09:00", days: [1, 2, 3, 4, 5] }, { type: "date", name: "Evening review", time: "18:00", date: "2026-09-08" }]; + await f.json("/reminders", "PUT", { enabled: false, reminders, quietStart: "22:00", quietEnd: "08:00" }); + expect((await f.json("/reminders")).reminders).toEqual(reminders); + expect((await f.json("/account/export")).reminderSettings[0].reminders).toEqual(reminders); + expect((await f.request("/reminders", "PUT", { enabled: false, reminders: [{ ...reminders[0], name: "x".repeat(81) }], quietStart: "22:00", quietEnd: "08:00" })).status).toBe(422); + } finally { f.close(); } +}); + +test("legacy delivery records prevent upgrade duplicates and deferred records still retry", async () => { + const { f } = await setup(); const sent: string[] = []; + try { + const timestamp = Date.parse("2026-09-04T18:00:00Z"); + f.db.insert(reminderDeliveries).values({ id: "old", userId: "alice", date: "2026-09-04", status: "sent", updatedAt: timestamp }).run(); + await sendDueReminders(f.db, config, transport(sent), () => timestamp); expect(sent).toHaveLength(0); + f.db.update(reminderDeliveries).set({ status: "deferred", retryAt: timestamp }).run(); + await sendDueReminders(f.db, config, transport(sent), () => timestamp); expect(sent).toHaveLength(1); + await sendDueReminders(f.db, config, transport(sent), () => timestamp); expect(sent).toHaveLength(1); + } finally { f.close(); } +}); + +test("a stale pending send does not block a later reminder and is not retried", async () => { + const { f } = await setup(); const sent: string[] = []; + try { + const timestamp = Date.parse("2026-09-04T18:00:00Z"); + f.db.update(reminderSettings).set({ reminders: [{ type: "weekly", time: "10:00", days: [5] }, { type: "weekly", time: "20:00", days: [5] }] }).run(); + f.db.insert(reminderDeliveries).values({ id: "stale", userId: "alice", date: "2026-09-04", slot: "10:00", status: "pending", updatedAt: timestamp - 120000 }).run(); + await sendDueReminders(f.db, config, transport(sent), () => timestamp); + expect(sent).toHaveLength(1); + expect(f.db.select().from(reminderDeliveries).where(eq(reminderDeliveries.id, "stale")).get()?.status).toBe("uncertain"); + } finally { f.close(); } +}); + +test("messages include names only for the applicable reminders in the delivery batch", async () => { + const { f } = await setup(); const sent: string[] = []; + try { + f.db.update(reminderSettings).set({ reminders: [ + { type: "weekly", name: "Morning check-in", time: "09:00", days: [5] }, + { type: "date", name: "Midday review", time: "12:00", date: "2026-09-04" }, + { type: "date", name: "Tomorrow's review", time: "12:00", date: "2026-09-05" }, + { type: "weekly", name: "Evening review", time: "20:00", days: [5] }, + ] }).run(); + await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T10:00:00Z")); + const first = JSON.parse(sent[0]!).content; + expect(first).toContain("Reminder: Morning check-in; Midday review\n"); + expect(first).not.toContain("Tomorrow"); expect(first).not.toContain("Evening review"); expect(first).not.toContain("Private habit name"); + await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T18:00:00Z")); + const second = JSON.parse(sent[1]!).content; + expect(second).toContain("Reminder: Evening review\n"); expect(second).not.toContain("Morning check-in"); + expect(JSON.parse(sent[1]!).allowed_mentions.parse).toEqual([]); + } finally { f.close(); } +}); diff --git a/src/reminders/worker.ts b/src/reminders/worker.ts index 12bb105..57d1616 100644 --- a/src/reminders/worker.ts +++ b/src/reminders/worker.ts @@ -1,10 +1,10 @@ import { createHash } from "node:crypto"; -import { and, eq, lt } from "drizzle-orm"; +import { and, eq, inArray, 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 { entriesForDate, inQuietHours, timesForDate } from "./contracts"; import type { DiscordFetch } from "../sharing/routes"; export type ReminderConfig = { token: string; origin: string }; @@ -26,25 +26,38 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig, 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; + if (inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null; + const times = timesForDate(prefs, localDate(timestamp, currentUser.timezone)).filter(time => time <= clock); + if (!times.length) return null; const service = new HabitService(db, { id: currentUser.id, discordId: currentUser.discordId, username: currentUser.username, displayName: currentUser.globalName ?? currentUser.username, avatarUrl: null, timezone: currentUser.timezone }, timestamp); service.sync(); // Avoid sending an artificial "today" after a backwards timezone change. if (service.today !== localDate(timestamp, currentUser.timezone)) return null; const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length; - return remaining ? { date: service.today, remaining } : null; + return remaining ? { date: service.today, remaining, times, entries: entriesForDate(prefs, service.today) } : 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; + const claims = db.transaction(tx => { + // A crashed/aborted send must not block later times indefinitely. + tx.update(reminderDeliveries).set({ status: "uncertain" }).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.status, "pending"), lt(reminderDeliveries.updatedAt, now() - 60000))).run(); + const previous = tx.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, initial.date))).all(); + // Legacy deliveries cover the whole day. An in-flight batch owns this user's turn. + const legacy = previous.find(row => row.slot === "*"); + if ((legacy && legacy.status !== "deferred") || previous.some(row => row.status === "pending")) return []; + const claims: { id: string; slot: string }[] = []; + for (const slot of legacy ? ["*"] : initial.times) { + const prior = previous.find(row => row.slot === slot); + if (prior && (prior.status !== "deferred" || (prior.retryAt ?? 0) > now())) continue; + const id = prior?.id ?? crypto.randomUUID(); + if (prior) 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, slot, status: "pending", updatedAt: now() }).run(); + claims.push({ id, slot }); + } + return claims; }, { 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(); + if (!claims.length) continue; + const ids = claims.map(claim => claim.id); + const update = (status: string, retryAt: number | null = null) => db.update(reminderDeliveries).set({ status, retryAt, updatedAt: now() }).where(inArray(reminderDeliveries.id, ids)).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)); @@ -62,12 +75,16 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig, 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; } + if (!latest || latest.date !== initial.date || !claims.some(claim => claim.slot === "*" || latest.times.includes(claim.slot))) { update("skipped"); continue; } + const names = [...new Set(latest.entries.filter(entry => claims.some(claim => claim.slot === "*" ? latest.times.includes(entry.time) : claim.slot === entry.time)).map(entry => entry.name?.trim()).filter((name): name is string => Boolean(name)))]; + // Bound the label prefix so the count and link always fit Discord's message limit. + const labels = names.join("; "); + const prefix = labels ? `Reminder: ${labels.length > 1400 ? labels.slice(0, 1399) + "…" : labels}\n` : ""; 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 }), + body: JSON.stringify({ content: `${prefix}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(ids.sort().join(":")).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");