Add named reminders with multiple times and day schedules
This commit is contained in:
@@ -1,6 +1,23 @@
|
||||
import { z } from "zod";
|
||||
const clock = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/, "Use HH:MM time");
|
||||
export const reminderInput = z.object({ enabled: z.boolean(), time: clock, quietStart: clock, quietEnd: clock }).strict();
|
||||
export const allWeekdays = [0, 1, 2, 3, 4, 5, 6];
|
||||
export const reminderEntry = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("weekly"), name: z.string().trim().max(80).optional(), time: clock, days: z.array(z.number().int().min(0).max(6)).min(1).max(7).refine(days => new Set(days).size === days.length) }).strict(),
|
||||
z.object({ type: z.literal("date"), name: z.string().trim().max(80).optional(), time: clock, date: z.iso.date() }).strict(),
|
||||
]);
|
||||
export type ReminderEntry = z.infer<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 type ReminderPreferences = z.infer<typeof reminderInput>;
|
||||
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 type { AppDatabase, AuthEnv, createAuth } from "../auth";
|
||||
import { reminderDeliveries, reminderSettings } from "../db/schema";
|
||||
import { defaultReminder, reminderInput } from "./contracts";
|
||||
import { defaultReminder, entries, reminderInput } from "./contracts";
|
||||
|
||||
export function createReminderRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, available: boolean, now: () => number) {
|
||||
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 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,
|
||||
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 });
|
||||
});
|
||||
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));
|
||||
if (!parsed.success) return c.json({ error: "Choose valid reminder and quiet-hour times." }, 422);
|
||||
if (!parsed.success) return c.json({ error: "Choose valid reminder times, days or dates, and quiet hours (up to 20 reminders)." }, 422);
|
||||
if (parsed.data.enabled && !available) return c.json({ error: "Discord reminders are not configured on this server." }, 422);
|
||||
const row = { ...parsed.data, updatedAt: now() };
|
||||
const row = { ...parsed.data, reminders: entries(parsed.data), updatedAt: now() };
|
||||
db.insert(reminderSettings).values({ userId: c.get("user").id, ...row })
|
||||
.onConflictDoUpdate({ target: reminderSettings.userId, set: row }).run();
|
||||
return c.json(parsed.data);
|
||||
|
||||
@@ -98,3 +98,117 @@ test("reminder preferences require authentication, origin, valid clocks, and con
|
||||
expect(f.db.select().from(reminderSettings).all()).toEqual([]);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test("multiple times respect weekdays and dates, deduplicate overlaps, and stop after completion", async () => {
|
||||
const { f, habit } = await setup(); const sent: string[] = [];
|
||||
try {
|
||||
f.db.update(reminderSettings).set({ reminders: [
|
||||
{ type: "weekly", time: "10:00", days: [5] },
|
||||
{ type: "date", time: "10:00", date: "2026-09-04" },
|
||||
{ type: "date", time: "12:00", date: "2026-09-04" },
|
||||
{ type: "weekly", time: "14:00", days: [1] },
|
||||
{ type: "weekly", time: "18:00", days: [5] },
|
||||
] }).run();
|
||||
const tick = (time: string) => sendDueReminders(f.db, config, transport(sent), () => Date.parse(`2026-09-04T${time}:00Z`));
|
||||
await Promise.all([tick("08:00"), tick("08:00")]); expect(sent).toHaveLength(1);
|
||||
await tick("10:00"); expect(sent).toHaveLength(2);
|
||||
await tick("12:00"); expect(sent).toHaveLength(2);
|
||||
f.setTime("2026-09-04T15:00:00Z");
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, "PUT", { done: true });
|
||||
await tick("16:00"); expect(sent).toHaveLength(2);
|
||||
expect(f.db.select().from(reminderDeliveries).all().map(row => row.slot)).toEqual(["10:00", "12:00"]);
|
||||
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-05T18:00:00Z")); expect(sent).toHaveLength(2);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test("overdue reminders coalesce after quiet hours and retain separate delivery records", async () => {
|
||||
const { f } = await setup(); const sent: string[] = [];
|
||||
try {
|
||||
f.db.update(reminderSettings).set({ quietStart: "09:00", quietEnd: "13:00", reminders: [
|
||||
{ type: "weekly", time: "10:00", days: [5] }, { type: "weekly", time: "12:00", days: [5] }, { type: "weekly", time: "16:00", days: [5] },
|
||||
] }).run();
|
||||
const tick = (time: string) => sendDueReminders(f.db, config, transport(sent), () => Date.parse(`2026-09-04T${time}:00Z`));
|
||||
await tick("10:00"); expect(sent).toHaveLength(0);
|
||||
await Promise.all([tick("11:00"), tick("11:00")]); expect(sent).toHaveLength(1);
|
||||
expect(f.db.select().from(reminderDeliveries).all()).toHaveLength(2);
|
||||
await tick("14:00"); expect(sent).toHaveLength(2);
|
||||
await tick("15:00"); expect(sent).toHaveLength(2);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test("removing a due schedule while opening Discord cancels delivery", async () => {
|
||||
const { f } = await setup(); let posts = 0;
|
||||
try {
|
||||
await sendDueReminders(f.db, config, async url => {
|
||||
if (!url.endsWith("/users/@me/channels")) posts++;
|
||||
f.db.update(reminderSettings).set({ reminders: [{ type: "date", time: "20:00", date: "2026-09-05" }] }).run();
|
||||
return Response.json({ id: "223456789012345678" });
|
||||
}, () => Date.parse("2026-09-04T18:00:00Z"));
|
||||
expect(posts).toBe(0);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test("schedule validation rejects empty days, invalid dates, and excessive entries", async () => {
|
||||
const { reminderInput } = await import("./contracts");
|
||||
const parse = (reminders: unknown[]) => reminderInput.safeParse({ enabled: true, quietStart: "22:00", quietEnd: "08:00", reminders }).success;
|
||||
expect(parse([])).toBe(false);
|
||||
expect(parse([{ type: "weekly", time: "12:00", days: [] }])).toBe(false);
|
||||
expect(parse([{ type: "date", time: "12:00", date: "2026-02-30" }])).toBe(false);
|
||||
expect(parse(Array.from({ length: 21 }, () => ({ type: "weekly", time: "12:00", days: [1] })))).toBe(false);
|
||||
expect(parse([{ type: "weekly", time: "12:00", days: [1, 3] }, { type: "date", time: "18:00", date: "2026-09-08" }])).toBe(true);
|
||||
});
|
||||
|
||||
test("named schedules round-trip through the API and account export", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const reminders = [{ type: "weekly", name: "Morning check-in", time: "09:00", days: [1, 2, 3, 4, 5] }, { type: "date", name: "Evening review", time: "18:00", date: "2026-09-08" }];
|
||||
await f.json("/reminders", "PUT", { enabled: false, reminders, quietStart: "22:00", quietEnd: "08:00" });
|
||||
expect((await f.json("/reminders")).reminders).toEqual(reminders);
|
||||
expect((await f.json("/account/export")).reminderSettings[0].reminders).toEqual(reminders);
|
||||
expect((await f.request("/reminders", "PUT", { enabled: false, reminders: [{ ...reminders[0], name: "x".repeat(81) }], quietStart: "22:00", quietEnd: "08:00" })).status).toBe(422);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test("legacy delivery records prevent upgrade duplicates and deferred records still retry", async () => {
|
||||
const { f } = await setup(); const sent: string[] = [];
|
||||
try {
|
||||
const timestamp = Date.parse("2026-09-04T18:00:00Z");
|
||||
f.db.insert(reminderDeliveries).values({ id: "old", userId: "alice", date: "2026-09-04", status: "sent", updatedAt: timestamp }).run();
|
||||
await sendDueReminders(f.db, config, transport(sent), () => timestamp); expect(sent).toHaveLength(0);
|
||||
f.db.update(reminderDeliveries).set({ status: "deferred", retryAt: timestamp }).run();
|
||||
await sendDueReminders(f.db, config, transport(sent), () => timestamp); expect(sent).toHaveLength(1);
|
||||
await sendDueReminders(f.db, config, transport(sent), () => timestamp); expect(sent).toHaveLength(1);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test("a stale pending send does not block a later reminder and is not retried", async () => {
|
||||
const { f } = await setup(); const sent: string[] = [];
|
||||
try {
|
||||
const timestamp = Date.parse("2026-09-04T18:00:00Z");
|
||||
f.db.update(reminderSettings).set({ reminders: [{ type: "weekly", time: "10:00", days: [5] }, { type: "weekly", time: "20:00", days: [5] }] }).run();
|
||||
f.db.insert(reminderDeliveries).values({ id: "stale", userId: "alice", date: "2026-09-04", slot: "10:00", status: "pending", updatedAt: timestamp - 120000 }).run();
|
||||
await sendDueReminders(f.db, config, transport(sent), () => timestamp);
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(f.db.select().from(reminderDeliveries).where(eq(reminderDeliveries.id, "stale")).get()?.status).toBe("uncertain");
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test("messages include names only for the applicable reminders in the delivery batch", async () => {
|
||||
const { f } = await setup(); const sent: string[] = [];
|
||||
try {
|
||||
f.db.update(reminderSettings).set({ reminders: [
|
||||
{ type: "weekly", name: "Morning check-in", time: "09:00", days: [5] },
|
||||
{ type: "date", name: "Midday review", time: "12:00", date: "2026-09-04" },
|
||||
{ type: "date", name: "Tomorrow's review", time: "12:00", date: "2026-09-05" },
|
||||
{ type: "weekly", name: "Evening review", time: "20:00", days: [5] },
|
||||
] }).run();
|
||||
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T10:00:00Z"));
|
||||
const first = JSON.parse(sent[0]!).content;
|
||||
expect(first).toContain("Reminder: Morning check-in; Midday review\n");
|
||||
expect(first).not.toContain("Tomorrow"); expect(first).not.toContain("Evening review"); expect(first).not.toContain("Private habit name");
|
||||
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T18:00:00Z"));
|
||||
const second = JSON.parse(sent[1]!).content;
|
||||
expect(second).toContain("Reminder: Evening review\n"); expect(second).not.toContain("Morning check-in");
|
||||
expect(JSON.parse(sent[1]!).allowed_mentions.parse).toEqual([]);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user