feat: add signed Discord command foundation and daily check-ins

This commit is contained in:
syntaxbullet
2026-09-08 08:15:22 +02:00
parent 8e846fa11b
commit 4f38bf1431
17 changed files with 1556 additions and 3 deletions

50
src/discord/daily.ts Normal file
View File

@@ -0,0 +1,50 @@
import { ApiError } from '../habits/service';
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';
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);
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');
}
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])]) };
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 === '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}` }))));
controls.push(...pages(c, 'daily.detail', data, d.tasks.length));
}
}
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);
}
export function dailyAction(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.detail') return detail(c, data);
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 (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.');
c.service.completeTask(data.habitId!, data.date!, task.taskId, !task.done);
} else {
const amount = fields(c.interaction).amount;
c.service.progress(data.habitId!, data.date!, progressInput.parse(data.done !== undefined ? { done: data.done } : { count: amount === undefined ? data.value : /^\d+$/.test(amount) ? Number(amount) : NaN }));
}
c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return detail(c, data);
}
}