74 lines
6.3 KiB
TypeScript
74 lines
6.3 KiB
TypeScript
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<Context['service']['day']>) => 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);
|
||
}
|
||
}
|