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 = 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', { 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); 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(...(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}` })))); 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.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); } 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, 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 !== 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.'); 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); } } 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); } }