Add named reminders with multiple times and day schedules

This commit is contained in:
syntaxbullet
2026-09-08 07:36:49 +02:00
parent 8ba23961ef
commit 8e846fa11b
12 changed files with 1341 additions and 53 deletions

View File

@@ -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 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 ### Reminders
Open **Settings → Daily reminders** to opt into one private Discord DM when habits Open **Settings → Reminders** to configure up to 20 reminders, each with an optional
remain unfinished. Choose a local time and quiet hours; times follow the account name (up to 80 characters), local time, and selected weekdays or a specific date.
timezone. Quiet-hour start is inclusive, end is exclusive, and equal times disable Select all seven days for a daily reminder. Discord messages include any reminder names that were set, along with the
quiet hours. If the scheduled time falls in quiet hours, delivery waits until they unfinished-habit count and app link. Combined nudges include the applicable names. Reminders
end that same local day. A reminder missed for the whole day is not carried forward. 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; 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 a sharing channel is not required. Users must be reachable by the bot (normally a
bot (normally a shared Discord server and enabled DMs). A message contains only shared Discord server and enabled DMs). Settings show the most recent delivery
the unfinished-habit count and app link. Settings show the most recent delivery result. Uncheck the option and save to opt out. Reminders are disabled by default.
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` `GET /api/reminders` returns settings and last delivery status. `PUT /api/reminders`
requires authentication, matching Origin, and `{enabled, time, quietStart, quietEnd}` requires authentication, matching Origin, and `{enabled, reminders, quietStart,
with `HH:MM` times. Per-user/date reservations persist across restarts and concurrent quietEnd}`. Times use `HH:MM`. Each reminder is either
workers. Discord 429 retry deadlines are persisted; ambiguous message sends are `{type: "weekly", name?: string, time: "09:00", days: [1, 3, 5]}` (Sunday is 0) or
not retried that day. Delivery records expire after 90 days. Restore disables `{type: "date", name?: string, time: "18:00", date: "2026-09-08"}`.
reminders in the recovered database until users opt in again. 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 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 reminder worker against a local transport stub. Set a reminder to `14:00` (its fixed

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,13 @@
"when": 1788597003524, "when": 1788597003524,
"tag": "0010_tidy_thena", "tag": "0010_tidy_thena",
"breakpoints": true "breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1788845281801,
"tag": "0011_multiple_reminders",
"breakpoints": true
} }
] ]
} }

View File

@@ -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 }); return Response.json({ enabled: false, time: "20:00", quietStart: "22:00", quietEnd: "08:00", available: true, lastDelivery: null });
}) as typeof fetch); }) as typeof fetch);
await act(async () => root.render(<ReminderSettings timezone="Europe/Belgrade" />)); await act(async () => root.render(<ReminderSettings timezone="Europe/Belgrade" />));
const time = container.querySelector<HTMLInputElement>('input[name="time"]')!; const time = container.querySelector<HTMLInputElement>('input[name="reminder-time-0"]')!;
await act(async () => { await act(async () => {
time.value = "14:30"; time.value = "14:30";
time.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click(); 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)); 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<HTMLInputElement>('input[type="checkbox"]')!.click()); 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)); 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" });
}); });

View File

@@ -1,17 +1,17 @@
import { FormMessage, SettingRow } from "./design-system/WorkspacePanel"; import { FormMessage, SettingRow } from "./design-system/WorkspacePanel";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { habitRequest } from "../lib/dashboard"; 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 { Button, Checkbox } from "./design-system/primitives";
import { Field } from "./design-system/Field"; import { Field } from "./design-system/Field";
const deliveryLabels: Record<string, string> = { 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.", 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", 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 }) { export function ReminderSettings({ timezone, onBusyChange, disabled = false }: { timezone: string; onBusyChange?: (busy: boolean) => void; disabled?: boolean }) {
const [settings, setSettings] = useState<ReminderResponse>(); const [settings, setSettings] = useState<ReminderResponse>();
const [draft, setDraft] = useState(defaultReminder); const [draft, setDraft] = useState({ ...defaultReminder, reminders: entries(defaultReminder) });
const [error, setError] = useState(""); const [error, setError] = useState("");
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -20,31 +20,42 @@ export function ReminderSettings({ timezone, onBusyChange, disabled = false }: {
useEffect(() => { useEffect(() => {
const controller = new AbortController(); setError(""); const controller = new AbortController(); setError("");
habitRequest<ReminderResponse>("/reminders", { signal: controller.signal }) 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 }); } }) .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); }); .catch(error => { if (!controller.signal.aborted) setError(error.message); });
return () => controller.abort(); return () => controller.abort();
}, [attempt]); }, [attempt]);
return <SettingRow title="Daily reminders" description={<><p>A private Discord nudge when you still have habits left today. No habit names are sent.</p><p className="ds-meta-label">Times in {timezone}</p></>}> const changeEntry = (index: number, entry: ReminderEntry) => setDraft(value => ({ ...value, reminders: value.reminders.map((previous, position) => position === index ? entry : previous) }));
return <SettingRow title="Reminders" description={<><p>A private Discord nudge when you still have habits left today. Named reminders include their name in the message. No habit names are sent.</p><p className="ds-meta-label">Times in {timezone}</p></>}>
{!settings ? error ? <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Reload reminders</Button> : <p className="ds-supporting-copy" role="status">Loading reminders…</p> : <form className="ds-form-stack" onSubmit={async event => { {!settings ? error ? <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Reload reminders</Button> : <p className="ds-supporting-copy" role="status">Loading reminders…</p> : <form className="ds-form-stack" onSubmit={async event => {
event.preventDefault(); if (saving.current || disabled) return; event.preventDefault(); if (saving.current || disabled) return;
const form = event.currentTarget; 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(""); saving.current = true; setBusy(true); onBusyChange?.(true); setError(""); setNotice("");
try { try {
await habitRequest("/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(submitted) }); await habitRequest("/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(submitted) });
setDraft(submitted); setDraft(value => ({ ...value, ...submitted }));
setNotice(submitted.enabled ? "Daily Discord reminders enabled." : "Reminders turned off."); setNotice(submitted.enabled ? "Discord reminders saved." : "Reminders turned off.");
} catch (error) { setError(error instanceof Error ? error.message : "Could not save reminders. Try again."); } } catch (error) { setError(error instanceof Error ? error.message : "Could not save reminders. Try again."); }
finally { saving.current = false; setBusy(false); onBusyChange?.(false); } finally { saving.current = false; setBusy(false); onBusyChange?.(false); }
}}> }}>
{!settings.available && <p className="ds-supporting-copy">Discord reminders are not configured on this server.</p>} {!settings.available && <p className="ds-supporting-copy">Discord reminders are not configured on this server.</p>}
<fieldset className="ds-inline-item-fields ds-form-stack" disabled={busy || disabled}> <fieldset className="ds-inline-item-fields ds-form-stack" disabled={busy || disabled}>
<Checkbox className="ds-preference-toggle" description="At most one message per day. Turn off anytime." label="Enable daily Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} /> <Checkbox className="ds-preference-toggle" description="Choose when to receive a nudge. Turn off anytime." label="Enable Discord reminders" checked={draft.enabled} disabled={!settings.available && !draft.enabled} onChange={event => setDraft(value => ({ ...value, enabled: event.target.checked }))} />
{draft.reminders.map((entry, index) => <div key={index} className="ds-form-stack">
<Field label={`Reminder ${index + 1} name (optional)`}>{id => <input id={id} value={entry.name ?? ""} placeholder="e.g. Morning check-in" maxLength={80} onChange={event => changeEntry(index, { ...entry, name: event.target.value })} />}</Field>
<div className="ds-clock-fields">
<Field label={`Reminder ${index + 1} time`}>{id => <input id={id} name={`reminder-time-${index}`} type="time" required value={entry.time} onInput={event => changeEntry(index, { ...entry, time: event.currentTarget.value })} />}</Field>
<Field label="Repeat">{id => <select id={id} value={entry.type} onChange={event => changeEntry(index, event.target.value === "date" ? { type: "date", name: entry.name, time: entry.time, date: "" } : { type: "weekly", name: entry.name, time: entry.time, days: allWeekdays })}><option value="weekly">Weekdays</option><option value="date">Specific date</option></select>}</Field>
</div>
{entry.type === "date" ? <Field label="Date">{id => <input id={id} name={`reminder-date-${index}`} type="date" required value={entry.date} onInput={event => changeEntry(index, { ...entry, date: event.currentTarget.value })} />}</Field> :
<div className="ds-action-row" role="group" aria-label={`Reminder ${index + 1} weekdays`}>{[[1, "Mon"], [2, "Tue"], [3, "Wed"], [4, "Thu"], [5, "Fri"], [6, "Sat"], [0, "Sun"]].map(([day, label]) => <Checkbox key={day} label={String(label)} checked={entry.days.includes(Number(day))} onChange={event => changeEntry(index, { ...entry, days: event.target.checked ? [...entry.days, Number(day)] : entry.days.filter(value => value !== day) })} />)}</div>}
<Button variant="text" disabled={draft.reminders.length === 1} onClick={() => setDraft(value => ({ ...value, reminders: value.reminders.filter((_, position) => position !== index) }))}>Remove reminder {index + 1}</Button>
</div>)}
<Button variant="secondary" disabled={draft.reminders.length >= 20} onClick={() => setDraft(value => ({ ...value, reminders: [...value.reminders, { type: "weekly", time: "20:00", days: allWeekdays }] }))}>Add reminder</Button>
<div className="ds-clock-fields"> <div className="ds-clock-fields">
{([['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]) =>
<Field key={key} label={label}>{id => <input id={id} name={key} type="time" required defaultValue={draft[key]} />}</Field>)} <Field key={key} label={label}>{id => <input id={id} name={key} type="time" required defaultValue={draft[key]} />}</Field>)}
</div> </div>
<p className="ds-supporting-copy">During quiet hours we wait until they end. Equal start and end times turn quiet hours off. Missed reminders never carry into the next day.</p>
<div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy || disabled}>{busy ? "Saving…" : "Save reminders"}</Button></div> <div className="ds-action-row"><Button type="submit" variant="secondary" disabled={busy || disabled}>{busy ? "Saving…" : "Save reminders"}</Button></div>
</fieldset> </fieldset>
{settings.lastDelivery && <p className="ds-delivery-note"><span className="ds-eyebrow">LAST REMINDER</span><span>{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}</span></p>} {settings.lastDelivery && <p className="ds-delivery-note"><span className="ds-eyebrow">LAST REMINDER</span><span>{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}</span></p>}

View File

@@ -51,3 +51,24 @@ test('deadline migration repairs historical expiry using the original state befo
]); ]);
} finally { sqlite.close(); rmSync(directory, { recursive: true, force: true }); } } 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 }); }
});

View File

@@ -71,6 +71,7 @@ export const reminderSettings = sqliteTable('reminder_settings', {
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }), userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(false), enabled: integer('enabled', { mode: 'boolean' }).notNull().default(false),
time: text('time').notNull().default('20:00'), time: text('time').notNull().default('20:00'),
reminders: text('reminders', { mode: 'json' }).$type<import('../reminders/contracts').ReminderEntry[]>(),
quietStart: text('quiet_start').notNull().default('22:00'), quietStart: text('quiet_start').notNull().default('22:00'),
quietEnd: text('quiet_end').notNull().default('08:00'), quietEnd: text('quiet_end').notNull().default('08:00'),
updatedAt: integer('updated_at').notNull(), updatedAt: integer('updated_at').notNull(),
@@ -78,9 +79,10 @@ export const reminderSettings = sqliteTable('reminder_settings', {
export const reminderDeliveries = sqliteTable('reminder_deliveries', { export const reminderDeliveries = sqliteTable('reminder_deliveries', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
slot: text('slot').notNull().default('*'),
date: text('date').notNull(), status: text('status').notNull(), date: text('date').notNull(), status: text('status').notNull(),
retryAt: integer('retry_at'), updatedAt: integer('updated_at').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', { export const reminderWorkerState = sqliteTable('reminder_worker_state', {
id: text('id').primaryKey(), blockedUntil: integer('blocked_until').notNull(), id: text('id').primaryKey(), blockedUntil: integer('blocked_until').notNull(),
}); });

View File

@@ -1,6 +1,23 @@
import { z } from "zod"; import { z } from "zod";
const clock = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/, "Use HH:MM time"); 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<typeof reminderEntry>;
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<typeof entries>[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<typeof entries>[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 const defaultReminder = { enabled: false, time: "20:00", quietStart: "22:00", quietEnd: "08:00" };
export type ReminderPreferences = z.infer<typeof reminderInput>; export type ReminderPreferences = z.infer<typeof reminderInput>;
export type ReminderResponse = ReminderPreferences & { available: boolean; lastDelivery: { date: string; status: string } | null }; export type ReminderResponse = ReminderPreferences & { available: boolean; lastDelivery: { date: string; status: string } | null };

View File

@@ -3,7 +3,7 @@ import { bodyLimit } from "hono/body-limit";
import { desc, eq } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import type { AppDatabase, AuthEnv, createAuth } from "../auth"; import type { AppDatabase, AuthEnv, createAuth } from "../auth";
import { reminderDeliveries, reminderSettings } from "../db/schema"; import { reminderDeliveries, reminderSettings } from "../db/schema";
import { defaultReminder, reminderInput } from "./contracts"; import { defaultReminder, entries, reminderInput } from "./contracts";
export function createReminderRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, available: boolean, now: () => number) { export function createReminderRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, available: boolean, now: () => number) {
const app = new Hono<AuthEnv>(); const app = new Hono<AuthEnv>();
@@ -12,14 +12,14 @@ export function createReminderRoutes(db: AppDatabase, auth: ReturnType<typeof cr
const userId = c.get("user").id; const userId = c.get("user").id;
const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, userId)).get() ?? defaultReminder; 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(); 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, return c.json({ enabled: prefs.enabled, time: prefs.time, reminders: entries(prefs), quietStart: prefs.quietStart, quietEnd: prefs.quietEnd, available,
lastDelivery: last ? { date: last.date, status: last.status === "pending" ? "uncertain" : last.status } : null }); lastDelivery: last ? { date: last.date, status: last.status === "pending" ? "uncertain" : last.status } : null });
}); });
app.put("/", auth.requireSameOrigin, bodyLimit({ maxSize: 2048 }), async c => { app.put("/", auth.requireSameOrigin, bodyLimit({ maxSize: 8192 }), async c => {
const parsed = reminderInput.safeParse(await c.req.json().catch(() => null)); 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); 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 }) db.insert(reminderSettings).values({ userId: c.get("user").id, ...row })
.onConflictDoUpdate({ target: reminderSettings.userId, set: row }).run(); .onConflictDoUpdate({ target: reminderSettings.userId, set: row }).run();
return c.json(parsed.data); return c.json(parsed.data);

View File

@@ -98,3 +98,117 @@ test("reminder preferences require authentication, origin, valid clocks, and con
expect(f.db.select().from(reminderSettings).all()).toEqual([]); expect(f.db.select().from(reminderSettings).all()).toEqual([]);
} finally { f.close(); } } 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(); }
});

View File

@@ -1,10 +1,10 @@
import { createHash } from "node:crypto"; 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 type { AppDatabase } from "../auth";
import { reminderDeliveries, reminderSettings, reminderWorkerState, users } from "../db/schema"; import { reminderDeliveries, reminderSettings, reminderWorkerState, users } from "../db/schema";
import { HabitService } from "../habits/service"; import { HabitService } from "../habits/service";
import { localDate } from "../habits/calendar"; import { localDate } from "../habits/calendar";
import { inQuietHours } from "./contracts"; import { entriesForDate, inQuietHours, timesForDate } from "./contracts";
import type { DiscordFetch } from "../sharing/routes"; import type { DiscordFetch } from "../sharing/routes";
export type ReminderConfig = { token: string; origin: string }; 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(); const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get();
if (!currentUser || !prefs?.enabled) return null; if (!currentUser || !prefs?.enabled) return null;
const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone); 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); 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(); service.sync();
// Avoid sending an artificial "today" after a backwards timezone change. // Avoid sending an artificial "today" after a backwards timezone change.
if (service.today !== localDate(timestamp, currentUser.timezone)) return null; 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; 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 initial = due(); if (!initial) continue;
const id = db.transaction(tx => { const claims = db.transaction(tx => {
const previous = tx.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, initial.date))).get(); // A crashed/aborted send must not block later times indefinitely.
if (previous && (previous.status !== "deferred" || (previous.retryAt ?? 0) > now())) return null; tx.update(reminderDeliveries).set({ status: "uncertain" }).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.status, "pending"), lt(reminderDeliveries.updatedAt, now() - 60000))).run();
const id = previous?.id ?? crypto.randomUUID(); const previous = tx.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, initial.date))).all();
if (previous) tx.update(reminderDeliveries).set({ status: "pending", retryAt: null, updatedAt: now() }).where(eq(reminderDeliveries.id, id)).run(); // Legacy deliveries cover the whole day. An in-flight batch owns this user's turn.
else tx.insert(reminderDeliveries).values({ id, userId: user.id, date: initial.date, status: "pending", updatedAt: now() }).run(); const legacy = previous.find(row => row.slot === "*");
return id; 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" }); }, { behavior: "immediate" });
if (!id) continue; if (!claims.length) continue;
const update = (status: string, retryAt: number | null = null) => db.update(reminderDeliveries).set({ status, retryAt, updatedAt: now() }).where(eq(reminderDeliveries.id, id)).run(); 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 defer = async (response?: Response) => {
const body = response ? await response.json().catch(() => null) as { retry_after?: number } | null : null; 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)); 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; } 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. // A user may opt out or finish a habit while Discord opens the DM channel.
const latest = due(); 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; let response: Response;
try { response = await request(`https://discord.com/api/v10/channels/${destination.id}/messages`, { try { response = await request(`https://discord.com/api/v10/channels/${destination.id}/messages`, {
method: "POST", headers, redirect: "error", signal: AbortSignal.timeout(15000), 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}/`, 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(id).digest("hex").slice(0, 24), enforce_nonce: true }), allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(ids.sort().join(":")).digest("hex").slice(0, 24), enforce_nonce: true }),
}); } catch { update("uncertain"); continue; } }); } catch { update("uncertain"); continue; }
if (response.status === 429) { await defer(response); return; } if (response.status === 429) { await defer(response); return; }
update(response.ok ? "sent" : response.status >= 500 ? "uncertain" : "failed"); update(response.ok ? "sent" : response.status >= 500 ? "uncertain" : "failed");