From 8b9bd75f24dd09b182220bc108b00649190b0c22 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 08:20:13 +0200 Subject: [PATCH] feat: polish Discord daily check-ins and add reminder buttons --- scripts/dashboard-preview.ts | 2 +- src/discord/README.md | 9 +++ src/discord/daily.test.ts | 112 +++++++++++++++++++++++++++++++++++ src/discord/daily.ts | 39 +++++++++--- src/index.ts | 2 +- src/reminders/worker.test.ts | 3 +- src/reminders/worker.ts | 6 +- 7 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 src/discord/daily.test.ts diff --git a/scripts/dashboard-preview.ts b/scripts/dashboard-preview.ts index ba16d50..770aefb 100644 --- a/scripts/dashboard-preview.ts +++ b/scripts/dashboard-preview.ts @@ -48,7 +48,7 @@ const app = createApi( let reminderRunning = false; if (sharingPreview) setInterval(async () => { if (reminderRunning) return; reminderRunning = true; - try { await sendDueReminders(f.db, { token: "preview-bot-token", origin }, previewDiscord, () => Date.parse("2026-09-04T12:00:00Z")); } + try { await sendDueReminders(f.db, { token: "preview-bot-token", origin, commandsAvailable: false }, previewDiscord, () => Date.parse("2026-09-04T12:00:00Z")); } finally { reminderRunning = false; } }, 1000).unref(); const server = Bun.serve({ diff --git a/src/discord/README.md b/src/discord/README.md index b8638bb..195b7d6 100644 --- a/src/discord/README.md +++ b/src/discord/README.md @@ -24,3 +24,12 @@ Discord validation will be completed during integration. controls and privacy, and links to the website. It works before account setup and does not create an account or synchronize habits. Re-run the registration script with `--apply` after deployment to expose newly added commands in Discord. +Reminder delivery accepts `commandsAvailable`; production enables it when +`DISCORD_PUBLIC_KEY` is a 64-digit hexadecimal key and `DISCORD_CLIENT_ID` is set. +The existing bot token still controls delivery. Configured reminders include an +owner-bound `today` control with `mode: 'reminder'`: it opens a separate private +flow, leaving the reminder unchanged, and expires at the sending account's next +local midnight (including DST). Delivery claims, nonce, opt-out, and retry rules +are unchanged. The dashboard preview explicitly disables buttons because it has +no signed interaction endpoint configured. Global command registration and the +Discord interaction endpoint must be configured separately before production use. diff --git a/src/discord/daily.test.ts b/src/discord/daily.test.ts new file mode 100644 index 0000000..b1a2e74 --- /dev/null +++ b/src/discord/daily.test.ts @@ -0,0 +1,112 @@ +import { afterEach, expect, test } from 'bun:test'; +import { eq } from 'drizzle-orm'; +import { discordFixture, control, submit } from './test-fixture'; +import { discordControls, reminderSettings } from '../db/schema'; +import { defaultReminder } from '../reminders/contracts'; +import { sendDueReminders } from '../reminders/worker'; +import { reminderCheckIn } from './daily'; +import { ControlStore } from './state'; +let f: ReturnType; +afterEach(() => f?.close()); +const click = (id: string, values?: string[]) => ({ type: 3, message: { id: '123' }, data: { custom_id: id, ...(values ? { values } : {}) } }); +async function open(habitId: string, guild = false) { + const t = await f.run({ data: { name: 'today' } }, guild); + return f.run(click(control(t, 'Choose a habit'), [habitId]), guild); +} +for (const guild of [false, true]) { + test(`daily count carry, above-target, reversal and recoverable modal saves (${guild ? 'server' : 'DM'})`, async () => { + f = discordFixture(); + const h = await f.json('/habits', 'POST', { name: 'Read', method: 'count', target: 10, carryPartialProgress: true }, 201); + await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 }); + f.setDiscordTime('2026-09-05T12:00:00Z'); + let d = await open(h.id, guild); expect(d.data.content).toContain('Carried from 2026-09-04'); + d = await f.run(click(control(d, '+1')), guild); + expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(8); + let m = await f.run(click(control(d, 'Enter amount')), guild); + const invalid = await f.run(submit(m.data.custom_id, { amount: '-1' }), guild); + expect(invalid.data.content).toContain('whole number'); expect(invalid.data.flags).toBe(guild ? 64 : undefined); + m = await f.run(click(control(invalid, 'Try again')), guild); + d = await f.run(submit(m.data.custom_id, { amount: '12' }), guild); + expect(d.data.content).toContain('12/10'); + m = await f.run(click(control(d, 'Enter amount')), guild); + d = await f.run(submit(m.data.custom_id, { amount: '0' }), guild); + expect(d.data.content).toContain('Incomplete'); + expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(0); + expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(7); + expect(f.changed).toHaveLength(3); + }); + test(`manual and paginated task completion reverse and reflect website edits (${guild ? 'server' : 'DM'})`, async () => { + f = discordFixture(); + const manual = await f.json('/habits', 'POST', { name: 'Walk', method: 'manual' }, 201); + let d = await open(manual.id, guild); + d = await f.run(click(control(d, 'Mark complete')), guild); + expect((await f.json(`/habits/${manual.id}/days/2026-09-04`)).complete).toBe(true); + d = await f.run(click(control(d, 'Mark incomplete')), guild); + expect((await f.json(`/habits/${manual.id}/days/2026-09-04`)).complete).toBe(false); + const h = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: Array.from({ length: 11 }, (_, n) => ({ name: `Task ${n + 1}` })) }, 201); + d = await open(h.id, guild); + d = await f.run(click(control(d, 'Next')), guild); + expect(d.data.content).toContain('Task 11'); expect(d.data.content).not.toContain('Task 10'); + const day = await f.json(`/habits/${h.id}/days/2026-09-04`); + d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild); + expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(1); + d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild); + expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0); + // Website task changes invalidate an already-open Discord selection. + for (const task of day.tasks) await f.json(`/habits/${h.id}/days/2026-09-04/tasks/${task.taskId}`, 'PUT', { done: true }); + const stale = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild); + expect(stale.data.content).toContain('Progress changed'); + d = await f.run(click(control(stale, 'Refresh')), guild); + expect(d.data.content).toContain('11/11 tasks · Complete'); + d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild); + expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).complete).toBe(false); + await f.json(`/habits/${manual.id}/days/2026-09-04/progress`, 'PUT', { done: true }); + expect((await open(manual.id, guild)).data.content).toContain('Complete'); + }); +} +test('habit pagination clamps after website archives', async () => { + f = discordFixture(); const habits = []; + for (let n = 0; n < 11; n++) habits.push(await f.json('/habits', 'POST', { name: `Habit ${n}`, method: 'manual' }, 201)); + const t = await f.run({ data: { name: 'today' } }); + const next = control(t, 'Next'); + for (const h of habits.slice(1)) await f.json(`/habits/${h.id}`, 'PATCH', { archived: true }); + const refreshed = await f.run(click(next)); + expect(refreshed.data.content).toContain('Habit 0'); +}); +test('daily controls reject midnight rollover even within their fifteen minute lifetime', async () => { + f = discordFixture(); f.setDiscordTime('2026-09-04T21:59:00Z'); + const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201); + const d = await open(h.id); + f.setDiscordTime('2026-09-04T22:00:00Z'); + const expired = await f.run(click(control(d, 'Mark complete'))); + expect(expired.data.content).toContain('day changed'); + const fresh = await f.run(click(control(expired, 'Today'))); + expect(fresh.data.content).toContain('2026-09-05'); + expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0); +}); +test('reminder buttons are owner bound, open a new flow, expire at local midnight, and preserve deduplication', async () => { + f = discordFixture(); f.setDiscordTime('2026-09-04T18:00:00Z'); + await f.json('/habits', 'POST', { name: 'Walk', method: 'manual' }, 201); + f.db.insert(reminderSettings).values({ userId: 'alice', ...defaultReminder, enabled: true, updatedAt: 0 }).run(); + const sent: any[] = []; const now = Date.parse('2026-09-04T18:00:00Z'); + const send = () => sendDueReminders(f.db, { token: 'fake', origin: f.origin, commandsAvailable: true }, async (url, init) => { + if (url.endsWith('/users/@me/channels')) return Response.json({ id: '223456789012345678' }); + sent.push(JSON.parse(String(init?.body))); return Response.json({ id: '323456789012345678' }); + }, () => now); + await Promise.all([send(), send()]); await send(); expect(sent).toHaveLength(1); + const id = control({ data: sent[0] }, 'Check in'); + expect(f.db.select().from(discordControls).where(eq(discordControls.id, id.slice(5))).get()?.expiresAt).toBe(Date.parse('2026-09-04T22:00:00Z')); + expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another user'); + for (const guild of [false, true]) { + const flow = await f.run(click(id), guild); expect(flow.type).toBe(4); expect(flow.data.flags).toBe(guild ? 64 : undefined); + expect(flow.data.content).toContain('Walk'); + } + f.setDiscordTime('2026-09-04T22:00:00Z'); expect((await f.run(click(id))).data.content).toContain('expired'); +}); +test('reminder expiry follows DST boundary', () => { + f = discordFixture(); + const now = Date.parse('2026-10-25T00:30:00Z'); + const components = reminderCheckIn(f.db, 'alice', 'Europe/Belgrade', now); + const id = control({ data: { components } }, 'Check in'); + expect(new ControlStore(f.db, 'alice', now).read(id).expiresAt).toBe(Date.parse('2026-10-25T23:00:00Z')); +}); diff --git a/src/discord/daily.ts b/src/discord/daily.ts index 0ee1a0b..e677ea5 100644 --- a/src/discord/daily.ts +++ b/src/discord/daily.ts @@ -1,24 +1,35 @@ +import { ZodError } from 'zod'; import { ApiError } from '../habits/service'; +import { endOfDay, localDate } from '../habits/calendar'; +import { ControlStore } from './state'; +import type { AppDatabase } from '../auth'; import { progressInput } from '../habits/contracts'; import type { Context, ControlData, Reply } from './types'; import { fields } from './types'; import { button, message, modal, pages, row, select, text } from './ui'; +const pageFor = (page: number | undefined, total: number) => Math.min(Math.max(0, Math.trunc(page ?? 0)), Math.max(0, Math.ceil(total / 10) - 1)); +const snapshot = (d: ReturnType) => JSON.stringify([d.value, d.revisionId, d.loggedCount, d.carriedFrom, d.tasks.map(t => [t.taskId, t.done])]); +export function reminderCheckIn(db: AppDatabase, userId: string, timezone: string, now: number) { + const date = localDate(now, timezone); + return [row({ type: 2, style: 1, label: 'Check in', custom_id: new ControlStore(db, userId, now).create('today', { date, mode: 'reminder' }, endOfDay(date, timezone) - now) })]; +} export function today(c: Context, data: ControlData = {}): Reply { if (data.date && data.date !== c.service.today) throw new ApiError(409, 'That reminder is from an earlier day. Run /today for a fresh check-in.'); const all = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due); - const page = data.page ?? 0, visible = all.slice(page * 10, page * 10 + 10); + const page = pageFor(data.page, all.length), visible = all.slice(page * 10, page * 10 + 10); return message(c, `**Today · ${c.service.today}** · ${text(c.service.user.timezone)}\n${all.filter(h => h.complete).length}/${all.length} complete\n\n${visible.map(h => `${h.complete ? '✓' : '○'} ${text(h.name ?? 'Habit')} — ${h.value}/${h.target} ${text(h.unit ?? '')}`).join('\n') || 'No habits due. Use /habits to create or manage your habits.'}`, - [...(visible.length ? [select(c, 'Choose a habit', 'daily.select', { date: c.service.today, today: true }, visible.map(h => ({ id: h.habitId, name: h.name ?? 'Habit' })))] : []), ...pages(c, 'today', data, all.length), row(button(c, 'Refresh', 'today'))], c.interaction.data?.custom_id?.startsWith('mina:') && data.mode !== 'reminder'); + [...(visible.length ? [select(c, 'Choose a habit', 'daily.select', { date: c.service.today, today: true }, visible.map(h => ({ id: h.habitId, name: h.name ?? 'Habit' })))] : []), ...pages(c, 'today', { date: c.service.today, page }, all.length), row(button(c, 'Refresh', 'today'))], c.interaction.data?.custom_id?.startsWith('mina:') && data.mode !== 'reminder'); } export function detail(c: Context, data: ControlData): Reply { const date = data.date ?? c.service.today; if (data.today && date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today to check in.'); const d = c.service.day(data.habitId!, date); - const state: ControlData = { ...data, date, expected: JSON.stringify([d.value, d.revisionId, d.tasks.map(t => [t.taskId, t.done])]) }; + data = { ...data, date, page: pageFor(data.page, d.tasks.length) }; + const state: ControlData = { ...data, expected: snapshot(d) }; const controls = []; if (d.due && !d.future) { if (d.method === 'manual') controls.push(row(button(c, d.complete ? 'Mark incomplete' : 'Mark complete', 'daily.save', { ...state, done: !d.complete }, 1))); - if (d.method === 'count') controls.push(row(button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1), button(c, 'Enter amount', 'daily.amount', state))); + if (d.method === 'count') controls.push(row(...(d.value < 1_000_000_000 ? [button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1)] : []), ...(d.value > 0 ? [button(c, '−1', 'daily.save', { ...state, value: d.value - 1 })] : []), button(c, 'Enter amount', 'daily.amount', state))); if (d.method === 'tasks') { const visible = d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10); controls.push(select(c, 'Choose a task to change its completion', 'daily.task', state, visible.map(t => ({ id: t.taskId, name: `${t.done ? '✓ ' : '○ '}${t.name}` })))); @@ -26,17 +37,18 @@ export function detail(c: Context, data: ControlData): Reply { } } controls.push(row(button(c, 'Refresh', 'daily.detail', data), button(c, 'Today', 'today'))); - return message(c, `**${text(d.name ?? 'Not tracked')} · ${date}**\n${d.due ? `${d.value}/${d.target} ${text(d.unit ?? '')} · ${d.complete ? 'Complete' : 'Incomplete'}` : 'Not scheduled'}${d.future ? ' · Future dates are read-only' : ''}${d.method === 'tasks' ? '\n\n' + d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10).map(t => `${t.done ? '✓' : '○'} ${text(t.name)}`).join('\n') : ''}`, controls); + return message(c, `**${text(d.name ?? 'Not tracked')} · ${date}**\n${d.due ? `${d.value}/${d.target} ${text(d.unit ?? '')} · ${d.complete ? 'Complete' : 'Incomplete'}` : 'Not scheduled'}${d.carriedFrom ? ` · Carried from ${d.carriedFrom}` : ''}${d.future ? ' · Future dates are read-only' : ''}${d.method === 'tasks' ? '\n\n' + d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10).map(t => `${t.done ? '✓' : '○'} ${text(t.name)}`).join('\n') : ''}`, controls); } -export function dailyAction(c: Context, action: string, data: ControlData): Reply | undefined { +function action(c: Context, action: string, data: ControlData): Reply | undefined { if (action === 'today') return today(c, data); - if (action === 'daily.select') return detail(c, { ...data, habitId: c.interaction.data?.values?.[0] }); + if (action === 'daily.select') return detail(c, { ...data, page: 0, mode: undefined, habitId: c.interaction.data?.values?.[0] }); if (action === 'daily.detail') return detail(c, data); + if (action === 'daily.amount' && data.today && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today again.'); if (action === 'daily.amount') return modal(c, 'Record amount', 'daily.save', data, [{ id: 'amount', label: 'Total for this date', value: String(c.service.day(data.habitId!, data.date!).value), max: 10 }]); if (action === 'daily.save' || action === 'daily.task') { if (data.today && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today again.'); const d = c.service.day(data.habitId!, data.date!); - if (data.expected !== JSON.stringify([d.value, d.revisionId, d.tasks.map(t => [t.taskId, t.done])])) throw new ApiError(409, 'Progress changed since this message was opened. Refresh it before saving.'); + if (data.expected !== snapshot(d)) throw new ApiError(409, 'Progress changed since this message was opened. Refresh it before saving.'); if (action === 'daily.task') { const task = d.tasks.find(t => t.taskId === c.interaction.data?.values?.[0]); if (!task) throw new ApiError(404, 'Task not found.'); @@ -48,3 +60,14 @@ export function dailyAction(c: Context, action: string, data: ControlData): Repl c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return detail(c, data); } } + +export function dailyAction(c: Context, name: string, data: ControlData): Reply | undefined { + try { return action(c, name, data); } + catch (error) { + if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error; + const invalid = error instanceof ZodError; + const recover = invalid ? button(c, 'Try again', 'daily.amount', data) : button(c, 'Refresh', data.habitId ? 'daily.detail' : 'today', data); + return message(c, invalid ? 'Enter a whole number from 0 to 1,000,000,000. This is the total for the date, including any carried progress. Nothing was saved.' : error.message, + [row(recover, button(c, 'Today', 'today'))], false); + } +} diff --git a/src/index.ts b/src/index.ts index 8f52df0..be1e836 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,7 @@ if (process.env.NODE_ENV !== "production") { 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; +const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin, commandsAvailable: /^[a-f\d]{64}$/i.test(process.env.DISCORD_PUBLIC_KEY ?? "") && Boolean(readAuthConfig().clientId) }) : undefined; if (import.meta.hot) import.meta.hot.dispose(() => reminders?.stop()); const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig(), { discord: { publicKey: process.env.DISCORD_PUBLIC_KEY ?? "", applicationId: readAuthConfig().clientId, origin: readAuthConfig().origin }, healthy: () => !backups?.state.enabled || (!backups.state.failed && backups.state.lastSuccess > 0) }); diff --git a/src/reminders/worker.test.ts b/src/reminders/worker.test.ts index 0e3f00d..5df7cb1 100644 --- a/src/reminders/worker.test.ts +++ b/src/reminders/worker.test.ts @@ -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" }); diff --git a/src/reminders/worker.ts b/src/reminders/worker.ts index 57d1616..b987ec3 100644 --- a/src/reminders/worker.ts +++ b/src/reminders/worker.ts @@ -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; }