feat: add Discord reminder and timezone settings

This commit is contained in:
syntaxbullet
2026-09-08 08:23:27 +02:00
parent 3487488f7b
commit 5dbec04b04
12 changed files with 324 additions and 28 deletions

View File

@@ -6,7 +6,8 @@ export const reminderEntry = z.discriminatedUnion("type", [
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()
export const reminderInput = z.object({ enabled: z.boolean(), time: clock.optional(), reminders: z.array(reminderEntry).max(20).optional(), quietStart: clock, quietEnd: clock }).strict()
.refine(value => !value.enabled || value.reminders === undefined || value.reminders.length > 0, "Add a reminder before enabling delivery")
.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 }];

View File

@@ -1,28 +1,15 @@
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, entries, reminderInput } from "./contracts";
import { ReminderService } from "./service";
export function createReminderRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, available: boolean, now: () => number) {
const app = new Hono<AuthEnv>();
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, reminders: entries(prefs), quietStart: prefs.quietStart, quietEnd: prefs.quietEnd, available,
lastDelivery: last ? { date: last.date, status: last.status === "pending" ? "uncertain" : last.status } : null });
});
app.get("/", c => c.json(new ReminderService(db, c.get("user").id, available, now()).read()));
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 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, 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);
const input = await c.req.json().catch(() => null);
return c.json(new ReminderService(db, c.get("user").id, available, now()).save(input));
});
return app;
}

31
src/reminders/service.ts Normal file
View File

@@ -0,0 +1,31 @@
import { desc, eq } from 'drizzle-orm';
import type { AppDatabase } from '../auth';
import { reminderDeliveries, reminderSettings } from '../db/schema';
import { ApiError } from '../habits/service';
import { defaultReminder, entries, reminderInput } from './contracts';
/** Shared website/Discord persistence. The monotonic revision also catches A → B → A edits. */
export class ReminderService {
constructor(readonly db: AppDatabase, readonly userId: string, readonly available: boolean, readonly now: number) {}
read() {
const prefs = this.db.select().from(reminderSettings).where(eq(reminderSettings.userId, this.userId)).get() ?? defaultReminder;
const last = this.db.select().from(reminderDeliveries).where(eq(reminderDeliveries.userId, this.userId)).orderBy(desc(reminderDeliveries.updatedAt)).get();
return { enabled: prefs.enabled, time: prefs.time, reminders: entries(prefs), quietStart: prefs.quietStart, quietEnd: prefs.quietEnd, available: this.available,
lastDelivery: last ? { date: last.date, status: last.status === 'pending' ? 'uncertain' : last.status } : null };
}
version() {
return JSON.stringify(this.db.select().from(reminderSettings).where(eq(reminderSettings.userId, this.userId)).get() ?? null);
}
save(input: unknown, expected?: string) {
return this.db.transaction(() => {
if (expected !== undefined && expected !== this.version()) throw new ApiError(409, 'Reminders changed since this form was opened. Run /reminders to refresh.');
const parsed = reminderInput.safeParse(input);
if (!parsed.success) throw new ApiError(422, 'Choose valid reminder times, days or dates, and quiet hours (up to 20 reminders).');
if (parsed.data.enabled && !this.available) throw new ApiError(422, 'Discord reminders are not configured on this server. You can save schedules while disabled.');
const previous = this.db.select().from(reminderSettings).where(eq(reminderSettings.userId, this.userId)).get();
const row = { ...parsed.data, reminders: entries(parsed.data), updatedAt: Math.max(this.now, (previous?.updatedAt ?? 0) + 1) };
this.db.insert(reminderSettings).values({ userId: this.userId, ...row }).onConflictDoUpdate({ target: reminderSettings.userId, set: row }).run();
return parsed.data;
});
}
}