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

@@ -1,10 +1,10 @@
import { createHash } from "node:crypto";
import { and, eq, lt } from "drizzle-orm";
import { and, eq, inArray, lt } from "drizzle-orm";
import type { AppDatabase } from "../auth";
import { reminderDeliveries, reminderSettings, reminderWorkerState, users } from "../db/schema";
import { HabitService } from "../habits/service";
import { localDate } from "../habits/calendar";
import { inQuietHours } from "./contracts";
import { entriesForDate, inQuietHours, timesForDate } from "./contracts";
import type { DiscordFetch } from "../sharing/routes";
export type ReminderConfig = { token: string; origin: string };
@@ -26,25 +26,38 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig,
const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get();
if (!currentUser || !prefs?.enabled) return null;
const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone);
if (clock < prefs.time || inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null;
if (inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null;
const times = timesForDate(prefs, localDate(timestamp, currentUser.timezone)).filter(time => time <= clock);
if (!times.length) return null;
const service = new HabitService(db, { id: currentUser.id, discordId: currentUser.discordId, username: currentUser.username, displayName: currentUser.globalName ?? currentUser.username, avatarUrl: null, timezone: currentUser.timezone }, timestamp);
service.sync();
// Avoid sending an artificial "today" after a backwards timezone change.
if (service.today !== localDate(timestamp, currentUser.timezone)) return null;
const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length;
return remaining ? { date: service.today, remaining } : null;
return remaining ? { date: service.today, remaining, times, entries: entriesForDate(prefs, service.today) } : null;
}
const initial = due(); if (!initial) continue;
const id = db.transaction(tx => {
const previous = tx.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, initial.date))).get();
if (previous && (previous.status !== "deferred" || (previous.retryAt ?? 0) > now())) return null;
const id = previous?.id ?? crypto.randomUUID();
if (previous) tx.update(reminderDeliveries).set({ status: "pending", retryAt: null, updatedAt: now() }).where(eq(reminderDeliveries.id, id)).run();
else tx.insert(reminderDeliveries).values({ id, userId: user.id, date: initial.date, status: "pending", updatedAt: now() }).run();
return id;
const claims = db.transaction(tx => {
// A crashed/aborted send must not block later times indefinitely.
tx.update(reminderDeliveries).set({ status: "uncertain" }).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.status, "pending"), lt(reminderDeliveries.updatedAt, now() - 60000))).run();
const previous = tx.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, initial.date))).all();
// Legacy deliveries cover the whole day. An in-flight batch owns this user's turn.
const legacy = previous.find(row => row.slot === "*");
if ((legacy && legacy.status !== "deferred") || previous.some(row => row.status === "pending")) return [];
const claims: { id: string; slot: string }[] = [];
for (const slot of legacy ? ["*"] : initial.times) {
const prior = previous.find(row => row.slot === slot);
if (prior && (prior.status !== "deferred" || (prior.retryAt ?? 0) > now())) continue;
const id = prior?.id ?? crypto.randomUUID();
if (prior) tx.update(reminderDeliveries).set({ status: "pending", retryAt: null, updatedAt: now() }).where(eq(reminderDeliveries.id, id)).run();
else tx.insert(reminderDeliveries).values({ id, userId: user.id, date: initial.date, slot, status: "pending", updatedAt: now() }).run();
claims.push({ id, slot });
}
return claims;
}, { behavior: "immediate" });
if (!id) continue;
const update = (status: string, retryAt: number | null = null) => db.update(reminderDeliveries).set({ status, retryAt, updatedAt: now() }).where(eq(reminderDeliveries.id, id)).run();
if (!claims.length) continue;
const ids = claims.map(claim => claim.id);
const update = (status: string, retryAt: number | null = null) => db.update(reminderDeliveries).set({ status, retryAt, updatedAt: now() }).where(inArray(reminderDeliveries.id, ids)).run();
const defer = async (response?: Response) => {
const body = response ? await response.json().catch(() => null) as { retry_after?: number } | null : null;
const seconds = Math.min(86400, Math.max(60, Number(body?.retry_after) || 60));
@@ -62,12 +75,16 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig,
if (!destination?.id || !/^\d{17,20}$/.test(destination.id)) { update("failed"); continue; }
// A user may opt out or finish a habit while Discord opens the DM channel.
const latest = due();
if (!latest || latest.date !== initial.date) { update("skipped"); continue; }
if (!latest || latest.date !== initial.date || !claims.some(claim => claim.slot === "*" || latest.times.includes(claim.slot))) { update("skipped"); continue; }
const names = [...new Set(latest.entries.filter(entry => claims.some(claim => claim.slot === "*" ? latest.times.includes(entry.time) : claim.slot === entry.time)).map(entry => entry.name?.trim()).filter((name): name is string => Boolean(name)))];
// Bound the label prefix so the count and link always fit Discord's message limit.
const labels = names.join("; ");
const prefix = labels ? `Reminder: ${labels.length > 1400 ? labels.slice(0, 1399) + "…" : labels}\n` : "";
let response: Response;
try { response = await request(`https://discord.com/api/v10/channels/${destination.id}/messages`, {
method: "POST", headers, redirect: "error", signal: AbortSignal.timeout(15000),
body: JSON.stringify({ content: `A little time for yourself: ${latest.remaining} ${latest.remaining === 1 ? "habit remains" : "habits remain"} today. Check in: ${config.origin}/`,
allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(id).digest("hex").slice(0, 24), enforce_nonce: true }),
body: JSON.stringify({ content: `${prefix}A little time for yourself: ${latest.remaining} ${latest.remaining === 1 ? "habit remains" : "habits remain"} today. Check in: ${config.origin}/`,
allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(ids.sort().join(":")).digest("hex").slice(0, 24), enforce_nonce: true }),
}); } catch { update("uncertain"); continue; }
if (response.status === 429) { await defer(response); return; }
update(response.ok ? "sent" : response.status >= 500 ? "uncertain" : "failed");