Export my data
diff --git a/src/components/ReminderSettings.tsx b/src/components/ReminderSettings.tsx
new file mode 100644
index 0000000..792a355
--- /dev/null
+++ b/src/components/ReminderSettings.tsx
@@ -0,0 +1,56 @@
+import { useEffect, useRef, useState } from "react";
+import { habitRequest } from "../lib/dashboard";
+import { defaultReminder, type ReminderResponse } from "../reminders/contracts";
+import { Button, Checkbox } from "./design-system/primitives";
+import { Field } from "./design-system/Field";
+
+const deliveryLabels: Record
= {
+ sent: "Delivered", deferred: "Waiting to retry", pending: "Delivery unconfirmed", uncertain: "Delivery unconfirmed; check Discord. We will not send it again today.",
+ failed: "Discord could not deliver it. Check your DM permissions and that you share a server with the bot.", skipped: "Skipped because your check-ins or settings changed",
+};
+export function ReminderSettings({ timezone }: { timezone: string }) {
+ const [settings, setSettings] = useState();
+ const [draft, setDraft] = useState(defaultReminder);
+ const [error, setError] = useState("");
+ const [notice, setNotice] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [attempt, setAttempt] = useState(0);
+ const saving = useRef(false);
+ useEffect(() => {
+ const controller = new AbortController(); setError("");
+ habitRequest("/reminders", { signal: controller.signal })
+ .then(data => { if (!controller.signal.aborted) { setSettings(data); setDraft({ enabled: data.enabled, time: data.time, quietStart: data.quietStart, quietEnd: data.quietEnd }); } })
+ .catch(error => { if (!controller.signal.aborted) setError(error.message); });
+ return () => controller.abort();
+ }, [attempt]);
+ return
+
Daily reminders
+
One private Discord message when you still have habits left today. Times follow {timezone}. No habit names are sent.
+ {!settings ?
:
}
+ {error &&
{error}
}
+
{notice}
+
;
+}
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 5bc1e58..a11695a 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -1,4 +1,4 @@
-import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
+import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
@@ -66,3 +66,21 @@ export const sharingLimits = sqliteTable('sharing_limits', {
startedAt: integer('started_at').notNull(),
count: integer('count').notNull(),
});
+
+export const reminderSettings = sqliteTable('reminder_settings', {
+ userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().default(false),
+ time: text('time').notNull().default('20:00'),
+ quietStart: text('quiet_start').notNull().default('22:00'),
+ quietEnd: text('quiet_end').notNull().default('08:00'),
+ updatedAt: integer('updated_at').notNull(),
+});
+export const reminderDeliveries = sqliteTable('reminder_deliveries', {
+ id: text('id').primaryKey(),
+ userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
+ date: text('date').notNull(), status: text('status').notNull(),
+ retryAt: integer('retry_at'), updatedAt: integer('updated_at').notNull(),
+}, t => [uniqueIndex('reminder_deliveries_user_date_idx').on(t.userId, t.date)]);
+export const reminderWorkerState = sqliteTable('reminder_worker_state', {
+ id: text('id').primaryKey(), blockedUntil: integer('blocked_until').notNull(),
+});
diff --git a/src/index.ts b/src/index.ts
index a5378a3..9a7a80d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,4 @@
+import { startReminders } from "./reminders/worker";
import { backupConfig, startBackups } from "./ops/backups";
import { databasePath } from "./db/config";
import { db, sqlite } from "./db";
@@ -9,6 +10,9 @@ import index from "./index.html";
const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined;
if (import.meta.hot) import.meta.hot.dispose(() => backups?.stop());
+const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin }) : undefined;
+if (import.meta.hot) import.meta.hot.dispose(() => reminders?.stop());
+
const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig(), { healthy: () => !backups?.state.enabled || (!backups.state.failed && backups.state.lastSuccess > 0) });
const server = Bun.serve({
diff --git a/src/ops/backups.test.ts b/src/ops/backups.test.ts
index 9389f83..b9bd3ea 100644
--- a/src/ops/backups.test.ts
+++ b/src/ops/backups.test.ts
@@ -15,6 +15,7 @@ test("WAL snapshots restore committed progress, revoke sessions, replicate, and
mkdirSync(config.replica!);
const habit = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "cups" }, 201);
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, "PUT", { count: 6 });
+ f.sqlite.exec("INSERT INTO reminder_settings (user_id, enabled, time, quiet_start, quiet_end, updated_at) VALUES ('alice', 1, '20:00', '22:00', '08:00', 0)");
const first = createBackup(f.sqlite, config);
expect(statSync(first).mode & 0o777).toBe(0o600);
expect(latestBackupTime(config)).toBeGreaterThan(0);
@@ -24,6 +25,7 @@ test("WAL snapshots restore committed progress, revoke sessions, replicate, and
try {
expect(restored.query("SELECT count FROM habit_days").get()).toEqual({ count: 6 });
expect(restored.query("SELECT * FROM sessions").all()).toEqual([]);
+ expect(restored.query("SELECT enabled FROM reminder_settings").get()).toEqual({ enabled: 0 });
expect(restored.query("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok" });
} finally { restored.close(); }
expect(() => restoreBackup(first, destination)).toThrow("new path");
@@ -48,3 +50,24 @@ test("invalid backups and missing replica mounts fail without overwriting data",
expect(latestBackupTime(config)).toBe(0);
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
});
+
+test("backup and restore CLIs work against a live database without overwriting it", async () => {
+ const directory = mkdtempSync(join(tmpdir(), "minabot-backup-cli-"));
+ const path = join(directory, "live.sqlite");
+ const f = fixture(path);
+ try {
+ f.sqlite.exec("PRAGMA journal_mode=WAL");
+ await f.json('/habits', 'POST', { name: 'CLI recovery', method: 'manual' }, 201);
+ const backup = Bun.spawnSync([process.execPath, 'scripts/backup.ts'], {
+ env: { ...process.env, DATABASE_PATH: path, BACKUP_DIR: 'backups', BACKUP_REPLICA_DIR: '', BACKUP_RETAIN: '7', BACKUP_INTERVAL_HOURS: '24' },
+ });
+ expect(backup.exitCode).toBe(0);
+ const snapshot = backup.stdout.toString().trim();
+ const restored = join(directory, 'recovered.sqlite');
+ const result = Bun.spawnSync([process.execPath, 'scripts/restore.ts', snapshot, restored]);
+ expect(result.exitCode).toBe(0);
+ const check = new Database(restored, { readonly: true });
+ try { expect(check.query('SELECT count(*) AS n FROM habits').get()).toEqual({ n: 1 }); } finally { check.close(); }
+ expect(f.sqlite.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 });
+ } finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
+});
diff --git a/src/ops/backups.ts b/src/ops/backups.ts
index 0dabd90..1e1176c 100644
--- a/src/ops/backups.ts
+++ b/src/ops/backups.ts
@@ -72,7 +72,10 @@ export function restoreBackup(source: string, destination: string) {
try {
copyFileSync(source, temporary, constants.COPYFILE_EXCL); chmodSync(temporary, 0o600);
const restored = new Database(temporary);
- try { restored.exec("PRAGMA journal_mode=DELETE; DELETE FROM sessions;"); } finally { restored.close(); }
+ try {
+ restored.exec("PRAGMA journal_mode=DELETE; DELETE FROM sessions;");
+ if (restored.query("SELECT name FROM sqlite_master WHERE name = 'reminder_settings'").get()) restored.exec("UPDATE reminder_settings SET enabled=0");
+ } finally { restored.close(); }
verifyBackup(temporary); durable(temporary);
// Link is exclusive: a concurrently-created destination is never overwritten.
linkSync(temporary, destination);
diff --git a/src/reminders/contracts.ts b/src/reminders/contracts.ts
new file mode 100644
index 0000000..29be852
--- /dev/null
+++ b/src/reminders/contracts.ts
@@ -0,0 +1,10 @@
+import { z } from "zod";
+const clock = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/, "Use HH:MM time");
+export const reminderInput = z.object({ enabled: z.boolean(), time: clock, quietStart: clock, quietEnd: clock }).strict();
+export const defaultReminder = { enabled: false, time: "20:00", quietStart: "22:00", quietEnd: "08:00" };
+export type ReminderPreferences = z.infer;
+export type ReminderResponse = ReminderPreferences & { available: boolean; lastDelivery: { date: string; status: string } | null };
+export function inQuietHours(time: string, start: string, end: string) {
+ if (start === end) return false;
+ return start < end ? time >= start && time < end : time >= start || time < end;
+}
diff --git a/src/reminders/routes.ts b/src/reminders/routes.ts
new file mode 100644
index 0000000..ab4cf00
--- /dev/null
+++ b/src/reminders/routes.ts
@@ -0,0 +1,28 @@
+import { Hono } from "hono";
+import { bodyLimit } from "hono/body-limit";
+import { desc, eq } from "drizzle-orm";
+import type { AppDatabase, AuthEnv, createAuth } from "../auth";
+import { reminderDeliveries, reminderSettings } from "../db/schema";
+import { defaultReminder, reminderInput } from "./contracts";
+
+export function createReminderRoutes(db: AppDatabase, auth: ReturnType, available: boolean, now: () => number) {
+ const app = new Hono();
+ app.use("*", auth.requireAuth);
+ app.get("/", c => {
+ const userId = c.get("user").id;
+ const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, userId)).get() ?? defaultReminder;
+ const last = db.select().from(reminderDeliveries).where(eq(reminderDeliveries.userId, userId)).orderBy(desc(reminderDeliveries.updatedAt)).get();
+ return c.json({ enabled: prefs.enabled, time: prefs.time, quietStart: prefs.quietStart, quietEnd: prefs.quietEnd, available,
+ lastDelivery: last ? { date: last.date, status: last.status === "pending" ? "uncertain" : last.status } : null });
+ });
+ app.put("/", auth.requireSameOrigin, bodyLimit({ maxSize: 2048 }), async c => {
+ const parsed = reminderInput.safeParse(await c.req.json().catch(() => null));
+ if (!parsed.success) return c.json({ error: "Choose valid reminder and quiet-hour times." }, 422);
+ if (parsed.data.enabled && !available) return c.json({ error: "Discord reminders are not configured on this server." }, 422);
+ const row = { ...parsed.data, updatedAt: now() };
+ db.insert(reminderSettings).values({ userId: c.get("user").id, ...row })
+ .onConflictDoUpdate({ target: reminderSettings.userId, set: row }).run();
+ return c.json(parsed.data);
+ });
+ return app;
+}
diff --git a/src/reminders/worker.test.ts b/src/reminders/worker.test.ts
new file mode 100644
index 0000000..5687cd4
--- /dev/null
+++ b/src/reminders/worker.test.ts
@@ -0,0 +1,100 @@
+import { expect, test } from "bun:test";
+import { eq } from "drizzle-orm";
+import { fixture } from "../habits/test-fixture";
+import { reminderDeliveries, reminderSettings } from "../db/schema";
+import { sendDueReminders } from "./worker";
+import { createApi } from "../api";
+import { defaultReminder, inQuietHours } from "./contracts";
+import type { DiscordFetch } from "../sharing/routes";
+const config = { token: "test-only-bot", origin: "https://minabot.example" };
+async function setup() {
+ const f = fixture();
+ const habit = await f.json("/habits", "POST", { name: "Private habit name", method: "manual" }, 201);
+ f.db.insert(reminderSettings).values({ userId: "alice", ...defaultReminder, enabled: true, updatedAt: 0 }).run();
+ return { f, habit };
+}
+function transport(sent: string[]): DiscordFetch {
+ return async (url, init) => {
+ if (url.endsWith("/users/@me/channels")) return Response.json({ id: "223456789012345678" });
+ sent.push(String(init?.body)); return Response.json({ id: "323456789012345678" });
+ };
+}
+
+test("reminders follow local time, send only unfinished habits, and deduplicate concurrent workers", async () => {
+ const { f, habit } = await setup(); const sent: string[] = [];
+ try {
+ await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T17:59:00Z")); expect(sent).toHaveLength(0);
+ const now = () => Date.parse("2026-09-04T18:00:00Z");
+ await Promise.all([sendDueReminders(f.db, config, transport(sent), now), sendDueReminders(f.db, config, transport(sent), now)]);
+ expect(sent).toHaveLength(1); expect(sent[0]).not.toContain("Private habit name");
+ expect(JSON.parse(sent[0]!).allowed_mentions.parse).toEqual([]);
+ await sendDueReminders(f.db, config, transport(sent), now); expect(sent).toHaveLength(1);
+ f.setTime("2026-09-05T18:00:00Z");
+ await f.json(`/habits/${habit.id}/days/2026-09-05/progress`, "PUT", { done: true });
+ await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-05T18:00:00Z")); expect(sent).toHaveLength(1);
+ } finally { f.close(); }
+});
+
+test("quiet hours defer until their end and DST overlap sends once", async () => {
+ const { f } = await setup(); const sent: string[] = [];
+ try {
+ expect(inQuietHours("23:00", "22:00", "08:00")).toBe(true);
+ expect(inQuietHours("07:59", "22:00", "08:00")).toBe(true);
+ expect(inQuietHours("08:00", "22:00", "08:00")).toBe(false);
+ f.db.update(reminderSettings).set({ quietStart: "19:00", quietEnd: "21:00" }).run();
+ await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T18:00:00Z")); expect(sent).toHaveLength(0);
+ await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T19:00:00Z")); expect(sent).toHaveLength(1);
+ f.db.update(reminderSettings).set({ time: "02:30", quietStart: "00:00", quietEnd: "00:00" }).run();
+ await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-10-25T00:30:00Z"));
+ await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-10-25T01:30:00Z")); expect(sent).toHaveLength(2);
+ } finally { f.close(); }
+});
+
+test("opt-out during channel creation cancels the message", async () => {
+ const { f } = await setup(); let posts = 0;
+ try {
+ await sendDueReminders(f.db, config, async url => {
+ if (!url.endsWith("/users/@me/channels")) posts++;
+ f.db.update(reminderSettings).set({ enabled: false }).run();
+ return Response.json({ id: "223456789012345678" });
+ }, () => Date.parse("2026-09-04T18:00:00Z"));
+ expect(posts).toBe(0);
+ expect(f.db.select().from(reminderDeliveries).get()?.status).toBe("skipped");
+ } finally { f.close(); }
+});
+
+test("rate limits persist their retry deadline; ambiguous sends are not retried", async () => {
+ const { f } = await setup(); let attempts = 0;
+ let timestamp = Date.parse("2026-09-04T18:00:00Z");
+ try {
+ const request: DiscordFetch = async url => {
+ if (url.endsWith("/users/@me/channels")) return Response.json({ id: "223456789012345678" });
+ attempts++;
+ if (attempts === 1) return Response.json({ retry_after: 600 }, { status: 429 });
+ throw new Error("Ambiguous connection failure");
+ };
+ await sendDueReminders(f.db, config, request, () => timestamp);
+ timestamp += 60000; await sendDueReminders(f.db, config, request, () => timestamp); expect(attempts).toBe(1);
+ timestamp += 540000; await sendDueReminders(f.db, config, request, () => timestamp); expect(attempts).toBe(2);
+ expect(f.db.select().from(reminderDeliveries).get()?.status).toBe("uncertain");
+ timestamp += 60000; await sendDueReminders(f.db, config, request, () => timestamp); expect(attempts).toBe(2);
+ } finally { f.close(); }
+});
+
+test("reminder preferences require authentication, origin, valid clocks, and configured delivery", async () => {
+ const f = fixture();
+ try {
+ expect((await f.request("/reminders", "PUT", { ...defaultReminder, enabled: true })).status).toBe(422);
+ expect((await f.request("/reminders", "PUT", defaultReminder, "a", { Origin: "https://evil.test" })).status).toBe(403);
+ expect((await f.request("/reminders", "GET", undefined, "x")).status).toBe(401);
+ const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-at-least-32-characters" }, undefined, undefined, undefined, { token: "test", channelId: "" });
+ const call = (body: unknown) => app.request(`${f.origin}/api/reminders`, { method: "PUT", headers: { Cookie: `minabot_session=${"a".repeat(43)}`, Origin: f.origin, "Content-Type": "application/json" }, body: JSON.stringify(body) });
+ expect((await call({ ...defaultReminder, time: "25:00" })).status).toBe(422);
+ expect((await call({ ...defaultReminder, enabled: true })).status).toBe(200);
+ expect(f.db.select().from(reminderSettings).where(eq(reminderSettings.userId, "alice")).get()?.enabled).toBe(true);
+ expect((await f.json("/reminders", "GET", undefined, 200, "b")).enabled).toBe(false);
+ const exported = await f.json("/account/export"); expect(exported.reminderSettings[0].enabled).toBe(true);
+ await f.request("/account", "DELETE", { confirmation: "DELETE" });
+ expect(f.db.select().from(reminderSettings).all()).toEqual([]);
+ } finally { f.close(); }
+});
diff --git a/src/reminders/worker.ts b/src/reminders/worker.ts
new file mode 100644
index 0000000..12bb105
--- /dev/null
+++ b/src/reminders/worker.ts
@@ -0,0 +1,86 @@
+import { createHash } from "node:crypto";
+import { and, eq, lt } from "drizzle-orm";
+import type { AppDatabase } from "../auth";
+import { reminderDeliveries, reminderSettings, reminderWorkerState, users } from "../db/schema";
+import { HabitService } from "../habits/service";
+import { localDate } from "../habits/calendar";
+import { inQuietHours } from "./contracts";
+import type { DiscordFetch } from "../sharing/routes";
+
+export type ReminderConfig = { token: string; origin: string };
+function localClock(timestamp: number, timezone: string) {
+ const parts = new Intl.DateTimeFormat("en-GB", { timeZone: timezone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(timestamp);
+ return `${parts.find(part => part.type === "hour")!.value}:${parts.find(part => part.type === "minute")!.value}`;
+}
+export async function sendDueReminders(db: AppDatabase, config: ReminderConfig, request: DiscordFetch = fetch, now: () => number = Date.now) {
+ if (!config.token) return;
+ const cooldown = db.select().from(reminderWorkerState).where(eq(reminderWorkerState.id, "discord")).get();
+ if (cooldown && cooldown.blockedUntil > now()) return;
+ const headers = { Authorization: `Bot ${config.token}`, "Content-Type": "application/json" };
+ const rows = db.select({ prefs: reminderSettings, user: users }).from(reminderSettings).innerJoin(users, eq(users.id, reminderSettings.userId)).where(eq(reminderSettings.enabled, true)).all();
+ // Retain recent delivery history and deduplication records without unbounded growth.
+ db.delete(reminderDeliveries).where(lt(reminderDeliveries.updatedAt, now() - 90 * 86400000)).run();
+ for (const { user } of rows) {
+ function due() {
+ const currentUser = db.select().from(users).where(eq(users.id, user.id)).get();
+ const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get();
+ if (!currentUser || !prefs?.enabled) return null;
+ const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone);
+ if (clock < prefs.time || inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null;
+ const service = new HabitService(db, { id: currentUser.id, discordId: currentUser.discordId, username: currentUser.username, displayName: currentUser.globalName ?? currentUser.username, avatarUrl: null, timezone: currentUser.timezone }, timestamp);
+ service.sync();
+ // Avoid sending an artificial "today" after a backwards timezone change.
+ if (service.today !== localDate(timestamp, currentUser.timezone)) return null;
+ const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length;
+ return remaining ? { date: service.today, remaining } : null;
+ }
+ const initial = due(); if (!initial) continue;
+ const id = db.transaction(tx => {
+ const previous = tx.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, initial.date))).get();
+ if (previous && (previous.status !== "deferred" || (previous.retryAt ?? 0) > now())) return null;
+ const id = previous?.id ?? crypto.randomUUID();
+ if (previous) tx.update(reminderDeliveries).set({ status: "pending", retryAt: null, updatedAt: now() }).where(eq(reminderDeliveries.id, id)).run();
+ else tx.insert(reminderDeliveries).values({ id, userId: user.id, date: initial.date, status: "pending", updatedAt: now() }).run();
+ return id;
+ }, { behavior: "immediate" });
+ if (!id) continue;
+ const update = (status: string, retryAt: number | null = null) => db.update(reminderDeliveries).set({ status, retryAt, updatedAt: now() }).where(eq(reminderDeliveries.id, id)).run();
+ const defer = async (response?: Response) => {
+ const body = response ? await response.json().catch(() => null) as { retry_after?: number } | null : null;
+ const seconds = Math.min(86400, Math.max(60, Number(body?.retry_after) || 60));
+ update("deferred", now() + seconds * 1000);
+ if (response?.status === 429) db.insert(reminderWorkerState).values({ id: "discord", blockedUntil: now() + seconds * 1000 })
+ .onConflictDoUpdate({ target: reminderWorkerState.id, set: { blockedUntil: now() + seconds * 1000 } }).run();
+ };
+ let channel: Response;
+ try { channel = await request("https://discord.com/api/v10/users/@me/channels", {
+ method: "POST", headers, body: JSON.stringify({ recipient_id: user.discordId }), redirect: "error", signal: AbortSignal.timeout(10000),
+ }); } catch { await defer(); continue; }
+ if (channel.status === 429) { await defer(channel); return; }
+ if (channel.status >= 500) { await defer(channel); continue; }
+ const destination = channel.ok ? await channel.json().catch(() => null) as { id?: string } | null : null;
+ if (!destination?.id || !/^\d{17,20}$/.test(destination.id)) { update("failed"); continue; }
+ // A user may opt out or finish a habit while Discord opens the DM channel.
+ const latest = due();
+ if (!latest || latest.date !== initial.date) { update("skipped"); continue; }
+ let response: Response;
+ try { response = await request(`https://discord.com/api/v10/channels/${destination.id}/messages`, {
+ method: "POST", headers, redirect: "error", signal: AbortSignal.timeout(15000),
+ body: JSON.stringify({ content: `A little time for yourself: ${latest.remaining} ${latest.remaining === 1 ? "habit remains" : "habits remain"} today. Check in: ${config.origin}/`,
+ allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(id).digest("hex").slice(0, 24), enforce_nonce: true }),
+ }); } catch { update("uncertain"); continue; }
+ if (response.status === 429) { await defer(response); return; }
+ update(response.ok ? "sent" : response.status >= 500 ? "uncertain" : "failed");
+ }
+}
+export function startReminders(db: AppDatabase, config: ReminderConfig) {
+ let running = false;
+ const tick = async () => {
+ if (running) return; running = true;
+ try { await sendDueReminders(db, config); }
+ catch { console.error(JSON.stringify({ event: "reminder_worker_failed", timestamp: new Date().toISOString() })); }
+ finally { running = false; }
+ };
+ const timer = setInterval(() => void tick(), 60000); timer.unref();
+ return { stop: () => clearInterval(timer) };
+}