import { eq } from 'drizzle-orm'; import { users } from '../db/schema'; import { timezoneInput } from '../habits/contracts'; import { ApiError } from '../habits/service'; import { ReminderService } from '../reminders/service'; import type { ReminderEntry } from '../reminders/contracts'; import type { Context, ControlData, Reply } from './types'; import { fields } from './types'; import { button, message, modal, pages, row, select, text } from './ui'; const deliveryLabels: Record = { sent: 'Delivered', deferred: 'Waiting to retry', uncertain: 'Delivery unconfirmed; check Discord. This attempt will not be retried.', failed: 'Could not deliver. Check your DM permissions and that you share a server with the bot.', skipped: 'Skipped because check-ins or settings changed', }; const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const service = (c: Context) => new ReminderService(c.db, c.service.user.id, c.remindersAvailable ?? false, c.now); const user = (c: Context) => c.db.select().from(users).where(eq(users.id, c.service.user.id)).get()!; const timezoneVersion = (c: Context) => { const u = user(c); return JSON.stringify([u.timezone, u.updatedAt]); }; const snapshot = (c: Context, data: ControlData = {}): ControlData => ({ ...data, preferenceVersion: service(c).version(), timezoneVersion: timezoneVersion(c) }); function current(c: Context, data: ControlData, timezoneOnly = false) { if (data.timezoneVersion !== timezoneVersion(c) || (!timezoneOnly && data.preferenceVersion !== service(c).version())) throw new ApiError(409, 'Settings changed since this control was opened. Run /settings or /reminders to refresh.'); } function groups(c: Context) { const result: { name: string; entry: ReminderEntry; indexes: number[]; times: string[] }[] = []; service(c).read().reminders.forEach((entry, index) => { const key = (e: ReminderEntry) => JSON.stringify([e.name ?? '', e.type, e.type === 'date' ? e.date : [...e.days].sort()]); let group = result.find(g => key(g.entry) === key(entry)); if (!group) { group = { name: entry.name || 'Unnamed reminder', entry, indexes: [], times: [] }; result.push(group); } group.indexes.push(index); group.times.push(entry.time); }); return result; } function group(c: Context, data: ControlData) { const found = groups(c)[data.index ?? -1]; if (!found) throw new ApiError(404, 'Reminder not found. Run /reminders to refresh.'); return found; } const schedule = (e: ReminderEntry) => e.type === 'date' ? e.date : e.days.length === 7 ? 'Every day' : e.days.map(d => weekdays[d]).join(', '); export function preferences(c: Context, command: string, data: ControlData = {}): Reply { const prefs = service(c).read(), tz = user(c).timezone, state = snapshot(c, data); const status = `Delivery: ${prefs.available ? prefs.enabled ? 'enabled' : 'disabled' : 'not configured on this server'}\nLast delivery: ${prefs.lastDelivery ? `${prefs.lastDelivery.date} · ${deliveryLabels[prefs.lastDelivery.status] ?? 'Unknown status'}` : 'none yet'}\nQuiet hours: ${prefs.quietStart}–${prefs.quietEnd}${prefs.quietStart === prefs.quietEnd ? ' (off)' : ''}`; if (command === 'settings') return message(c, `**Settings**\nTimezone: ${text(tz)}${tz === 'UTC' ? '\nNew accounts start in UTC. Set your timezone for local habit days and reminder times.' : ''}\n${status}`, [row(button(c, 'Change timezone', 'prefs.timezone', state), button(c, 'Manage reminders', 'prefs.list'))]); const all = groups(c), page = Math.max(0, Math.min(data.page ?? 0, Math.ceil(all.length / 10) - 1)), visible = all.slice(page * 10, page * 10 + 10); return message(c, `**Reminders · ${text(tz)}**\n${status}\nReminders arrive by DM only when habits remain unfinished. Times use your timezone.\n${tz === 'UTC' ? 'Use Settings to choose your local timezone.\n' : ''}\n${visible.map(g => `• ${text(g.name)}: ${g.times.join(', ')} · ${schedule(g.entry)}`).join('\n') || 'No reminders. Add one to get started.'}\n${prefs.reminders.length}/20 time slots`, [ ...(visible.length ? [select(c, 'Choose a reminder', 'prefs.select', state, visible.map((g, i) => ({ id: String(page * 10 + i), name: g.name, description: `${g.times.join(', ')} · ${schedule(g.entry)}` })))] : []), ...pages(c, 'prefs.list', { page }, all.length), row(button(c, 'Add reminder', 'prefs.edit', { ...state, index: undefined }), button(c, prefs.enabled ? 'Disable delivery' : 'Enable delivery', 'prefs.toggle', { ...state, done: !prefs.enabled }), button(c, 'Quiet hours', 'prefs.quiet', state)), row(button(c, 'Settings', 'prefs.settings'), button(c, 'Refresh', 'prefs.list', { page }), button(c, 'View schedule card', 'graphic.reminders')), ]); } function edit(c: Context, data: ControlData): Reply { const g = data.index === undefined ? undefined : group(c, data), draft = data.draft; return modal(c, g ? 'Edit reminder' : 'Add reminder', 'prefs.save', data, [ { id: 'name', label: 'Reminder name', value: draft?.name ?? g?.entry.name ?? '', max: 80 }, { id: 'times', label: 'Times (24-hour, separated by commas)', value: draft?.times ?? g?.times.join(', ') ?? '', placeholder: '09:00, 18:30', max: 160 }, { id: 'days', label: 'Weekdays (leave blank for a specific date)', value: draft?.days ?? (g?.entry.type === 'weekly' ? g.entry.days.map(d => weekdays[d]).join(', ') : ''), placeholder: 'Mon, Wed, Fri or every day', required: false, max: 100 }, { id: 'date', label: 'Specific date (leave blank for weekdays)', value: draft?.date ?? (g?.entry.type === 'date' ? g.entry.date : ''), placeholder: '2026-09-30', required: false, max: 10 }, ]); } function parseReminder(input: Record): ReminderEntry[] { const name = input.name?.trim(), times = input.times?.split(',').map(t => t.trim()) ?? [], daysText = input.days?.trim() ?? '', date = input.date?.trim() ?? ''; if (!name || name.length > 80) throw new ApiError(422, 'Enter a reminder name (up to 80 characters).'); if (!times.length || times.some(t => !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(t)) || new Set(times).size !== times.length) throw new ApiError(422, 'Use distinct 24-hour times separated by commas, for example 09:00, 18:30.'); if (!!daysText === !!date) throw new ApiError(422, 'Enter either weekdays or a specific date, leaving the other field blank.'); if (date) return times.map(time => ({ type: 'date', name, time, date })); const days = /^(every day|daily)$/i.test(daysText) ? [0, 1, 2, 3, 4, 5, 6] : daysText.toLowerCase().split(/[\s,]+/).map(day => ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].findIndex(full => day === full || day === full.slice(0, 3))); if (days.some(d => d < 0) || new Set(days).size !== days.length) throw new ApiError(422, 'Choose distinct weekdays such as Mon, Wed, Fri, or enter every day.'); return times.map(time => ({ type: 'weekly', name, time, days })); } function save(c: Context, data: ControlData, changes: Record) { const prefs = service(c).read(); service(c).save({ enabled: prefs.enabled, reminders: prefs.reminders, quietStart: prefs.quietStart, quietEnd: prefs.quietEnd, ...changes }, data.preferenceVersion); c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return preferences(c, 'reminders'); } export function preferencesAction(c: Context, action: string, data: ControlData): Reply | undefined { if (!action.startsWith('prefs.')) return; if (action === 'prefs.list') return preferences(c, 'reminders', data); if (action === 'prefs.settings') return preferences(c, 'settings'); current(c, data, action === 'prefs.timezone' || action === 'prefs.timezoneSave'); if (action === 'prefs.timezone') return modal(c, 'Change timezone', 'prefs.timezoneSave', data, [{ id: 'timezone', label: 'Timezone (region/city)', value: data.draft?.timezone ?? user(c).timezone, placeholder: 'Europe/Belgrade, America/New_York, UTC', max: 100 }]); if (action === 'prefs.quiet') { const p = service(c).read(); return modal(c, 'Quiet hours (same times = off)', 'prefs.quietSave', data, [{ id: 'start', label: 'Start (24-hour HH:MM)', value: data.draft?.start ?? p.quietStart, max: 5 }, { id: 'end', label: 'End (24-hour HH:MM)', value: data.draft?.end ?? p.quietEnd, max: 5 }]); } if (action === 'prefs.edit') return edit(c, data); if (action === 'prefs.select') { const selected = c.interaction.data?.values?.[0] ?? ''; if (!/^\d+$/.test(selected)) throw new ApiError(404, 'Reminder not found.'); const state = { ...data, index: Number(selected) }, g = group(c, state); return message(c, `**${text(g.name)}**\n${g.times.join(', ')} · ${schedule(g.entry)}\nTimezone: ${text(user(c).timezone)}`, [row(button(c, 'Edit reminder', 'prefs.edit', state), button(c, 'Remove reminder', 'prefs.remove', state, 4), button(c, 'Back', 'prefs.list'))]); } if (action === 'prefs.remove') return message(c, `Remove **${text(group(c, data).name)}** and all its times?`, [row(button(c, 'Confirm removal', 'prefs.removeSave', data, 4), button(c, 'Cancel', 'prefs.list'))]); try { if (action === 'prefs.timezoneSave') { const parsed = timezoneInput.safeParse({ timezone: fields(c.interaction).timezone?.trim() }); if (!parsed.success) throw new ApiError(422, 'Use a timezone such as Europe/Belgrade, America/New_York, or UTC.'); c.service.setTimezone(parsed.data.timezone); c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return preferences(c, 'settings'); } if (action === 'prefs.quietSave') { const input = fields(c.interaction); return save(c, data, { quietStart: input.start?.trim(), quietEnd: input.end?.trim() }); } if (action === 'prefs.toggle') return save(c, data, { enabled: data.done }); if (action === 'prefs.save' || action === 'prefs.removeSave') { const removed = data.index === undefined ? [] : group(c, data).indexes; const remaining = service(c).read().reminders.filter((_, i) => !removed.includes(i)); const reminders = [...remaining, ...(action === 'prefs.save' ? parseReminder(fields(c.interaction)) : [])]; return save(c, data, { reminders, ...(!reminders.length ? { enabled: false } : {}) }); } } catch (error) { if (!(error instanceof ApiError) || error.status !== 422) throw error; const retry = { 'prefs.save': 'prefs.edit', 'prefs.quietSave': 'prefs.quiet', 'prefs.timezoneSave': 'prefs.timezone' }[action]; return message(c, error.message, [row(...(retry ? [button(c, 'Try again', retry, { ...data, draft: fields(c.interaction) })] : []), button(c, 'Reminders', 'prefs.list'), button(c, 'Settings', 'prefs.settings'))], false); } }