Add named reminders with multiple times and day schedules
This commit is contained in:
44
README.md
44
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
|
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
|
||||||
|
|||||||
4
drizzle/0011_multiple_reminders.sql
Normal file
4
drizzle/0011_multiple_reminders.sql
Normal 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;
|
||||||
1082
drizzle/meta/0011_snapshot.json
Normal file
1082
drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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" });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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 }); }
|
||||||
|
});
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user