feat: polish Discord daily check-ins and add reminder buttons

This commit is contained in:
syntaxbullet
2026-09-08 08:20:13 +02:00
parent 2671fe0f34
commit 8b9bd75f24
7 changed files with 160 additions and 13 deletions

View File

@@ -28,6 +28,7 @@ test("reminders follow local time, send only unfinished habits, and deduplicate
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([]);
expect(JSON.parse(sent[0]!).components).toBeUndefined();
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 });
@@ -53,7 +54,7 @@ test("quiet hours defer until their end and DST overlap sends once", async () =>
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 => {
await sendDueReminders(f.db, { ...config, commandsAvailable: true }, async url => {
if (!url.endsWith("/users/@me/channels")) posts++;
f.db.update(reminderSettings).set({ enabled: false }).run();
return Response.json({ id: "223456789012345678" });

View File

@@ -1,3 +1,4 @@
import { reminderCheckIn } from "../discord/daily";
import { createHash } from "node:crypto";
import { and, eq, inArray, lt } from "drizzle-orm";
import type { AppDatabase } from "../auth";
@@ -7,7 +8,7 @@ import { localDate } from "../habits/calendar";
import { entriesForDate, inQuietHours, timesForDate } from "./contracts";
import type { DiscordFetch } from "../sharing/routes";
export type ReminderConfig = { token: string; origin: string };
export type ReminderConfig = { token: string; origin: string; commandsAvailable?: boolean };
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}`;
@@ -34,7 +35,7 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig,
// 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, times, entries: entriesForDate(prefs, service.today) } : null;
return remaining ? { date: service.today, remaining, timezone: currentUser.timezone, times, entries: entriesForDate(prefs, service.today) } : null;
}
const initial = due(); if (!initial) continue;
const claims = db.transaction(tx => {
@@ -84,6 +85,7 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig,
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: `${prefix}A little time for yourself: ${latest.remaining} ${latest.remaining === 1 ? "habit remains" : "habits remain"} today. Check in: ${config.origin}/`,
...(config.commandsAvailable ? { components: reminderCheckIn(db, user.id, latest.timezone, now()) } : {}),
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; }