feat: add private day, history and reminder command graphics

This commit is contained in:
syntaxbullet
2026-09-08 08:58:34 +02:00
parent d088179c5e
commit f79d593a04
8 changed files with 254 additions and 6 deletions

View File

@@ -107,3 +107,25 @@ sudo docker compose exec -T app bun scripts/register-discord-commands.ts --apply
Registration upserts individual global commands and preserves unrelated commands. Registration upserts individual global commands and preserves unrelated commands.
The public key and interactions URL are one-time setup; adding commands requires The public key and interactions URL are one-time setup; adding commands requires
rerunning registration after their handlers have deployed. rerunning registration after their handlers have deployed.
## Additional command graphics
`/today` includes **View day card**, summarizing all due habits in pages of six.
The selected daily check-in and `/history` editor include **View check-in card**,
showing the selected date's requirements, task completion, carryover where
applicable, and a 14-day progress strip ending on that date. `/reminders` includes
**View schedule card**, showing each time slot, weekday/date schedule, quiet-hour
conflicts, and delivery status. Schedule cards retain disabled/unavailable states.
Management forms and `/help` remain text-based because their controls already
communicate the relevant choices.
These are private snapshots: **Refresh card**, **Toggle theme**, and pagination
request a fresh owned snapshot. Regular check-ins keep their existing immediate
text responses. Graphic actions acknowledge before rendering and attach a PNG to
a new private reply; they never publish to the sharing channel or write progress.
Each image has a text equivalent and attachment description. A render failure
retains the text and navigation. No new command registration or configuration is
needed after deploying this change.
Generate local visual review artifacts with
`COMMAND_IMAGE_REVIEW_DIR=/tmp/minabot-command-review bun test src/discord/command-graphics.test.ts`.

View File

@@ -0,0 +1,98 @@
import { afterEach, expect, test } from 'bun:test';
import { eq } from 'drizzle-orm';
import { users, reminderSettings, progressEvents } from '../db/schema';
import { publicUser } from '../auth';
import { HabitService } from '../habits/service';
import { ControlStore } from './state';
import type { Context } from './types';
import type { DeferredMessage } from './progress';
import { commandCard } from './command-graphics';
import { renderCommandCard, commandCardSize } from './command-image';
import { discordFixture, control, submit } from './test-fixture';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
function setup() {
const jobs: (() => Promise<void>)[] = [], outputs: DeferredMessage[] = [];
f = discordFixture(() => ({ schedule: job => jobs.push(job), complete: async (_i, result) => { outputs.push(result); } }));
return { jobs, outputs, drain: async () => { while (jobs.length) await jobs.shift()!(); return { data: outputs.at(-1)!.message }; } };
}
const click = (id: string, values?: string[]) => ({ type: 3, message: { id: '123' }, data: { custom_id: id, values } });
function context(): Context {
const now = Date.parse('2026-09-04T12:00:00Z'), user = publicUser(f.db.select().from(users).where(eq(users.id, 'alice')).get()!);
const service = new HabitService(f.db, user, now); service.sync();
return { db: f.db, service, now, controls: new ControlStore(f.db, user.id, now), interaction: f.payload({}), origin: f.origin, changed: () => {}, remindersAvailable: true };
}
for (const guild of [false, true]) test(`day graphics retain private context and refresh without progress writes (${guild ? 'server' : 'DM'})`, async () => {
const e = setup();
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'count', unit: 'pages', target: 10 }, 201);
let t = await f.run({ data: { name: 'today' } }, guild);
const payload = f.payload(click(control(t, 'View day card')), guild);
expect(await (await f.requestDiscord(payload)).json()).toEqual({ type: 5, data: guild ? { flags: 64 } : {} });
await f.requestDiscord(payload); expect(e.jobs).toHaveLength(1);
let card = await e.drain(); expect(card.data.content).toContain('0 of 1 habits complete');
expect(e.outputs[0]!.image!.slice(0, 4)).toEqual(new Uint8Array([137,80,78,71]));
expect(e.outputs[0]!.imageDescription).toContain('Today, at a glance');
expect(f.db.select().from(progressEvents).all()).toEqual([]);
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 12 });
await f.run(click(control(card, 'Refresh card')), guild); card = await e.drain();
expect(card.data.content).toContain('12/10 pages'); expect(card.data.content).toContain('1 of 1');
t = await f.run({ data: { name: 'today' } }, guild);
const d = await f.run(click(control(t, 'Choose a habit'), [h.id]), guild);
await f.run(click(control(d, 'View check-in card')), guild); card = await e.drain();
expect(card.data.content).toContain('12/10 pages');
expect(f.changed).toHaveLength(0);
});
test('graphics retain ownership, expiry, pagination and selected historical requirements', async () => {
const e = setup(); const h = await f.json('/habits', 'POST', { name: 'Old target', method: 'count', target: 4 }, 201);
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 3 });
f.setDiscordTime('2026-09-05T12:00:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { name: 'New target', target: 10 });
const history = await f.run({ data: { name: 'history' } }, true);
const form = await f.run(click(control(history, 'Choose a habit'), [h.id]), true);
const day = await f.run(submit(form.data.custom_id, { date: '2026-09-04' }), true);
const id = control(day, 'View check-in card');
expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another');
await f.run(click(id), true); const card = await e.drain();
expect(card.data.content).toContain('Old target'); expect(card.data.content).toContain('3/4');
f.setDiscordTime('2026-09-06T12:00:00Z');
expect((await f.run(click(id), true)).data.content).toContain('expired');
});
test('six-row cards paginate complete habit and reminder collections', async () => {
const e = setup();
for (let i = 0; i < 8; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201);
const lastName = (await f.json('/habits')).habits.at(-1).name;
const today = await f.run({ data: { name: 'today' } });
await f.run(click(control(today, 'View day card'))); let card = await e.drain();
expect(card.data.content).toContain('Page 1/2'); expect(card.data.content).not.toContain(lastName);
await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(card.data.content).toContain(lastName);
f.db.insert(reminderSettings).values({ userId: 'alice', enabled: false, quietStart: '22:00', quietEnd: '08:00', updatedAt: 1,
reminders: Array.from({ length: 8 }, (_, i) => ({ type: 'weekly' as const, days: [1, 3, 5], name: `Reminder ${i}`, time: `${String(i + 1).padStart(2, '0')}:00` })) }).run();
const panel = await f.run({ data: { name: 'reminders' } });
await f.run(click(control(panel, 'View schedule card'))); card = await e.drain();
expect(card.data.content).toContain('During quiet hours'); expect(card.data.content).toContain('Mon, Wed, Fri');
await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(card.data.content).toContain('Reminder 7');
});
test('rendered cards include task progress, future/off states, weekly/date reminders and bounded image dimensions', async () => {
setup();
const h = await f.json('/habits', 'POST', { name: 'Evening routine', method: 'tasks', tasks: [{ name: 'Read a chapter' }, { name: 'Stretch' }, { name: 'Prepare for tomorrow' }] }, 201);
const tasks = (await f.json(`/habits/${h.id}/tasks`)).tasks;
await f.json(`/habits/${h.id}/days/2026-09-04/tasks/${tasks[0].id}`, 'PUT', { done: true });
f.db.insert(reminderSettings).values({ userId: 'alice', enabled: true, quietStart: '22:00', quietEnd: '08:00', updatedAt: 1, reminders: [
{ type: 'weekly', name: 'Morning reading', time: '09:00', days: [1,2,3,4,5] },
{ type: 'weekly', name: 'Evening wind-down', time: '21:00', days: [0,1,2,3,4,5,6] },
{ type: 'date', name: 'A little extra encouragement', time: '23:00', date: '2026-09-10' },
] }).run();
for (const [action, data] of [['graphic.today', {}], ['graphic.day', { habitId: h.id, date: '2026-09-04' }], ['graphic.reminders', {}]] as const) {
const model = commandCard(context(), action, data);
for (const theme of ['light','dark'] as const) {
const png = await renderCommandCard(model, theme), b = Buffer.from(png);
expect(b.readUInt32BE(16)).toBe(1200); expect(b.readUInt32BE(20)).toBe(commandCardSize(model).height); expect(b.length).toBeLessThan(4 * 1024 * 1024);
if (process.env.COMMAND_IMAGE_REVIEW_DIR) await Bun.write(`${process.env.COMMAND_IMAGE_REVIEW_DIR}/${action}-${theme}.png`, png);
}
}
const future = commandCard(context(), 'graphic.day', { habitId: h.id, date: '2026-09-05' });
expect(future.summary).toContain('Future date'); expect(future.history!.at(-1)!.future).toBe(true);
expect(commandCard(context(), 'graphic.day', { habitId: h.id, date: '1970-01-01' }).history).toHaveLength(1);
const long = commandCard(context(), 'graphic.today', {}); long.title = '🌸Very long habit name '.repeat(50); long.rows[0]!.label = '漢字'.repeat(200);
expect((await renderCommandCard(long)).length).toBeLessThan(4 * 1024 * 1024);
});

View File

@@ -0,0 +1,62 @@
import { addDays } from '../habits/calendar';
import { dateSchema } from '../habits/contracts';
import { ApiError } from '../habits/service';
import { ReminderService } from '../reminders/service';
import { inQuietHours } from '../reminders/contracts';
import type { Context, ControlData, Reply } from './types';
import { button, message, row, text } from './ui';
import { renderCommandCard, type CommandCard, type CardRow } from './command-image';
const size = 6;
export function commandCard(c: Context, action: string, data: ControlData): CommandCard {
const base = { owner: c.service.user.displayName, subtitle: c.service.user.timezone };
let rows: CardRow[], title: string, summary: string, note: string, history: CommandCard['history'];
if (action === 'graphic.today') {
if (data.date && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today for a new card.');
const days = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due);
title = 'Today, at a glance'; base.subtitle = `${c.service.today} · ${base.subtitle}`;
summary = `${days.filter(d => d.complete).length} of ${days.length} habits complete`;
note = 'Snapshot · Use /today to update progress';
rows = days.map(d => ({ label: d.name ?? 'Habit', detail: `${d.complete ? 'Complete' : d.value > 0 ? 'In progress' : 'Not started'}${d.carriedFrom ? ` · Carried from ${d.carriedFrom}` : ''}`, value: `${d.value}/${d.target} ${d.unit}`, ratio: d.ratio ?? 0, color: c.service.settings(d.habitId).mainColor }));
} else if (action === 'graphic.day') {
const date = dateSchema.parse(data.date ?? c.service.today);
if (data.today && date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today for a new card.');
const d = c.service.day(data.habitId!, date), color = c.service.settings(data.habitId!).mainColor;
title = d.name ?? 'Not tracked yet'; base.subtitle = `${date} · ${d.timezone}`;
summary = d.future ? 'Future date · read-only' : !d.requirements ? 'Not tracked on this date' : d.requirements.archived ? 'Archived on this date' : !d.due ? 'Day off · not scheduled' : `${d.value}/${d.target} ${d.unit} · ${d.complete ? 'Complete' : 'Incomplete'}`;
note = 'Snapshot · Historical requirements for the selected date';
rows = d.method === 'tasks' ? d.tasks.map(t => ({ label: t.name, detail: d.future ? 'Not yet due' : t.done ? 'Complete' : 'Incomplete', ratio: Number(t.done), color })) : [{ label: d.method === 'count' ? 'Recorded amount' : 'Check-in', detail: d.carriedFrom ? `Carried from ${d.carriedFrom}` : d.due ? 'Requirements saved for this date' : 'Read-only date', value: d.due && !d.future ? `${d.value}/${d.target ?? 0}` : '—', ...(!d.future && d.due ? { ratio: d.ratio ?? 0 } : {}), color }];
history = Array.from({ length: 14 }, (_, i) => addDays(date, i - 13)).filter(day => day >= '1970-01-01').map(day => { const value = c.service.day(data.habitId!, day); return { date: day, ratio: value.ratio, future: value.future, color }; });
} else if (action === 'graphic.reminders') {
const prefs = new ReminderService(c.db, c.service.user.id, c.remindersAvailable ?? false, c.now).read();
title = 'Your reminder schedule';
summary = !prefs.available ? 'Delivery not configured' : !prefs.enabled ? 'Delivery disabled' : `${prefs.reminders.length} reminder time slots`;
note = 'DMs send only while habits remain unfinished';
base.subtitle = `${base.subtitle} · Quiet hours ${prefs.quietStart === prefs.quietEnd ? 'off' : `${prefs.quietStart}–${prefs.quietEnd}`}`;
rows = [...prefs.reminders].sort((a, b) => a.time.localeCompare(b.time)).map(e => ({ label: `${e.time} · ${e.name || 'Unnamed reminder'}`, detail: `${e.type === 'date' ? e.date : 'Weekly · Mon to Sun'}${inQuietHours(e.time, prefs.quietStart, prefs.quietEnd) ? ' · During quiet hours' : ''}`, ...(e.type === 'weekly' ? { weekdays: e.days } : {}) }));
} else throw new ApiError(404, 'Unknown graphic.');
const pages = Math.max(1, Math.ceil(rows.length / size)), page = Math.max(0, Math.min(Math.trunc(data.page ?? 0), pages - 1));
return { ...base, title, summary, note, rows: rows.slice(page * size, (page + 1) * size), page, pages, history };
}
export function commandGraphicAction(c: Context, action: string, data: ControlData): Reply | undefined {
if (!action.startsWith('graphic.')) return;
if (!c.defer) throw new ApiError(409, 'Run the command again to open a fresh card.');
// Read one owned, dated snapshot before yielding; these cards never change progress.
const model = commandCard(c, action, data), theme = data.theme ?? 'light';
const content = [ `**${text(model.title)}** · ${text(model.subtitle)}`, model.summary,
...model.rows.map(r => `${text(r.label)}${r.value ? ` · ${text(r.value)}` : ''} — ${text(r.detail)}${r.weekdays ? ` (${r.weekdays.map(d => ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d]).join(', ')})` : ''}`),
...(model.history ? [`Recent days: ${model.history.map(d => `${d.date}: ${d.future ? 'future' : d.ratio === null ? 'off' : `${Math.round(d.ratio * 100)}%`}`).join('; ')}`] : []),
`Page ${model.page + 1}/${model.pages} · ${model.note}` ].join('\n');
const controls = [
...(model.page > 0 ? [button(c, 'Previous card', action, { ...data, page: model.page - 1 })] : []),
...(model.page + 1 < model.pages ? [button(c, 'Next card', action, { ...data, page: model.page + 1 })] : []),
button(c, 'Refresh card', action, { ...data, page: model.page }),
button(c, 'Toggle theme', action, { ...data, page: model.page, theme: theme === 'light' ? 'dark' : 'light' }),
];
const reply = message(c, content, [row(...controls)], false);
c.defer(async () => {
try { return { message: reply.data as import('./types').Message, image: await renderCommandCard(model, theme), imageDescription: `${model.title}. ${model.summary}. ${model.subtitle}. Page ${model.page + 1} of ${model.pages}.` }; }
catch { return { message: { ...(reply.data as import('./types').Message), content: `The graphic could not be rendered.\n${content}`.slice(0, 2000) } }; }
});
return { type: 5, data: c.interaction.guild_id ? { flags: 64 } : {} };
}

View File

@@ -0,0 +1,65 @@
import { createCanvas } from '@napi-rs/canvas';
import '../sharing/live-card-image';
import { themePalette, type Theme } from '../lib/theme';
import { habitProgressColor } from '../lib/habit-palette';
export type CardRow = { label: string; detail: string; value?: string; ratio?: number; color?: string; weekdays?: number[] };
export type CommandCard = { title: string; owner: string; subtitle: string; summary: string; note: string; rows: CardRow[]; page: number; pages: number; history?: { date: string; ratio: number | null; future: boolean; color: string }[] };
export const commandCardSize = (data: CommandCard) => ({ width: 1200, height: 350 + Math.max(1, data.rows.length) * 116 + (data.history ? 164 : 0) + 100 });
/** Fixed-size rows and text keep every page readable, including very large accounts. */
export async function renderCommandCard(data: CommandCard, theme: Theme = 'light') {
if (data.rows.length > 6 || (data.history && data.history.length > 14)) throw new Error('Paginate command cards before rendering.');
const { width, height } = commandCardSize(data), canvas = createCanvas(width, height), ctx = canvas.getContext('2d');
const p = themePalette[theme];
const rect = (x: number, y: number, w: number, h: number, color: string, r = 0) => { ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, w, h, r); ctx.fill(); };
const text = (value: string, x: number, y: number, size: number, maxWidth: number, color: string = p.ink, serif = false) => {
ctx.font = `${size}px "${serif ? 'Instrument Serif' : 'Mina Sans'}"`; ctx.fillStyle = color;
const characters = Array.from(value.replace(/[\r\n\t]/g, ' ')); let fitted = characters.join('');
while (characters.length && ctx.measureText(fitted).width > maxWidth) { characters.pop(); fitted = characters.join('') + '…'; }
ctx.fillText(fitted, x, y);
};
rect(0, 0, width, height, p.paper); rect(0, 0, width, 8, '#285d49');
text(data.owner, 64, 72, 26, 760); text('Mina Habits.', 938, 76, 38, 205, p.ink, true);
text(data.title, 64, 150, 60, 1072, p.ink, true);
text(data.subtitle, 64, 200, 25, 1072, p.muted);
text(data.summary, 64, 270, 38, 1072);
rect(64, 312, 1072, 1, p.rule);
if (!data.rows.length) text('Nothing scheduled here.', 64, 392, 30, 1072, p.muted);
data.rows.forEach((r, index) => {
const y = 346 + index * 116, color = r.color ?? '#285d49';
rect(64, y, 6, 88, color, 3);
const valueWidth = r.weekdays ? 0 : 270;
text(r.label, 92, y + 30, 30, 1016 - valueWidth);
if (r.value) text(r.value, 860, y + 30, 25, 270);
text(r.detail, 92, y + 68, 22, r.weekdays ? 580 : 1016, p.muted);
if (r.weekdays) {
['M', 'T', 'W', 'T', 'F', 'S', 'S'].forEach((label, i) => {
const active = r.weekdays!.includes((i + 1) % 7), x = 732 + i * 58;
rect(x, y + 45, 46, 35, active ? p.avatar : p.empty, 6);
text(label, x + 12, y + 71, 22, 28, active ? p.ink : p.muted);
if (active) rect(x + 12, y + 83, 22, 3, habitProgressColor(color, 1, theme), 1);
});
} else if (r.ratio !== undefined) {
rect(92, y + 87, 1044, 6, p.empty, 3);
if (r.ratio > 0) rect(92, y + 87, 1044 * Math.max(0, Math.min(1, r.ratio)), 6, habitProgressColor(color, 1, theme), 3);
}
});
const bottom = 350 + Math.max(1, data.rows.length) * 116;
if (data.history) {
text(`Recent days · ${data.history[0]?.date ?? ''} to ${data.history.at(-1)?.date ?? ''}`, 64, bottom + 26, 23, 1072, p.muted);
data.history.forEach((day, i) => {
const x = 64 + i * 77;
const fill = day.future || day.ratio === null ? p.avatar : !day.ratio ? p.empty : habitProgressColor(day.color, day.ratio, theme);
rect(x, bottom + 47, 62, 42, fill, 6);
// State symbols retain meaning without color; draw them without font dependence.
ctx.strokeStyle = p.ink; ctx.lineWidth = 2;
if (day.future || day.ratio === null) { ctx.beginPath(); ctx.moveTo(x + 24, bottom + 68); ctx.lineTo(x + 38, bottom + 68); ctx.stroke(); }
text(day.date.slice(8), x + 15, bottom + 119, 20, 45, p.muted);
if (i === data.history!.length - 1) rect(x, bottom + 127, 62, 3, p.ink, 1);
});
}
rect(64, height - 92, 1072, 1, p.rule);
text(data.note, 64, height - 48, 21, 860, p.muted);
text(`${data.page + 1} / ${data.pages}`, 1035, height - 48, 21, 100, p.muted);
return new Uint8Array(await canvas.encode('png'));
}

View File

@@ -18,7 +18,7 @@ export function today(c: Context, data: ControlData = {}): Reply {
const all = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due); 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); 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.'}`, 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'); [...(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'), button(c, 'View day card', 'graphic.today', { date: c.service.today }))], c.interaction.data?.custom_id?.startsWith('mina:') && data.mode !== 'reminder');
} }
export function detail(c: Context, data: ControlData): Reply { export function detail(c: Context, data: ControlData): Reply {
const date = data.date ?? c.service.today; const date = data.date ?? c.service.today;
@@ -36,7 +36,7 @@ export function detail(c: Context, data: ControlData): Reply {
controls.push(...pages(c, 'daily.detail', data, d.tasks.length)); controls.push(...pages(c, 'daily.detail', data, d.tasks.length));
} }
} }
controls.push(row(button(c, 'Refresh', 'daily.detail', data), button(c, 'Today', 'today'))); controls.push(row(button(c, 'Refresh', 'daily.detail', data), button(c, 'Today', 'today'), button(c, 'View check-in card', 'graphic.day', { ...data, page: 0 })));
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); 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 { function action(c: Context, action: string, data: ControlData): Reply | undefined {

View File

@@ -46,7 +46,7 @@ export function preferences(c: Context, command: string, data: ControlData = {})
...(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)}` })))] : []), ...(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), ...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, '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 })), 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 { function edit(c: Context, data: ControlData): Reply {

View File

@@ -11,7 +11,7 @@ export type ProgressOperations = {
sharing: Pick<ReturnType<typeof createSharingRoutes>, 'send' | 'destination'>; sharing: Pick<ReturnType<typeof createSharingRoutes>, 'send' | 'destination'>;
live: Pick<ReturnType<typeof createLiveDiscord>, 'state' | 'start' | 'pause'>; live: Pick<ReturnType<typeof createLiveDiscord>, 'state' | 'start' | 'pause'>;
}; };
export type DeferredMessage = { message: Message; image?: Uint8Array }; export type DeferredMessage = { message: Message; image?: Uint8Array; imageDescription?: string };
const fingerprint = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex'); const fingerprint = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
function imageData(c: Context, data: ControlData) { function imageData(c: Context, data: ControlData) {
const result = liveCardData(c.db, c.service.user.id, c.now); const result = liveCardData(c.db, c.service.user.id, c.now);

View File

@@ -15,6 +15,7 @@ import { preferences, preferencesAction } from './preferences';
import { progress, progressAction, type DeferredMessage, type ProgressOperations } from './progress'; import { progress, progressAction, type DeferredMessage, type ProgressOperations } from './progress';
import { message } from './ui'; import { message } from './ui';
import { help } from './help'; import { help } from './help';
import { commandGraphicAction } from './command-graphics';
export type CommandConfig = { publicKey: string; applicationId: string; origin: string; remindersAvailable?: boolean }; export type CommandConfig = { publicKey: string; applicationId: string; origin: string; remindersAvailable?: boolean };
export type DeferredDispatch = { export type DeferredDispatch = {
@@ -25,7 +26,7 @@ export type DeferredDispatch = {
/** Edit the deferred original exactly once. Never retry an uncertain network result. */ /** Edit the deferred original exactly once. Never retry an uncertain network result. */
export async function completeDeferred(i: import('./types').Interaction, result: DeferredMessage) { export async function completeDeferred(i: import('./types').Interaction, result: DeferredMessage) {
const body = new FormData(); const body = new FormData();
const payload = { ...result.message, flags: undefined, allowed_mentions: { parse: [] }, attachments: result.image ? [{ id: 0, filename: 'mina-progress.png', description: result.message.content?.slice(0, 1024) }] : [] }; const payload = { ...result.message, flags: undefined, allowed_mentions: { parse: [] }, attachments: result.image ? [{ id: 0, filename: 'mina-progress.png', description: (result.imageDescription ?? result.message.content)?.slice(0, 1024) }] : [] };
body.set('payload_json', JSON.stringify(payload)); body.set('payload_json', JSON.stringify(payload));
if (result.image) body.set('files[0]', new Blob([new Uint8Array(result.image)], { type: 'image/png' }), 'mina-progress.png'); if (result.image) body.set('files[0]', new Blob([new Uint8Array(result.image)], { type: 'image/png' }), 'mina-progress.png');
const response = await fetch(`https://discord.com/api/v10/webhooks/${i.application_id}/${encodeURIComponent(i.token!)}/messages/@original`, { method: 'PATCH', body, redirect: 'error', signal: AbortSignal.timeout(15000) }); const response = await fetch(`https://discord.com/api/v10/webhooks/${i.application_id}/${encodeURIComponent(i.token!)}/messages/@original`, { method: 'PATCH', body, redirect: 'error', signal: AbortSignal.timeout(15000) });
@@ -74,7 +75,7 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
else if (i.type === 2 && i.data?.name === 'history') result = history(ctx); else if (i.type === 2 && i.data?.name === 'history') result = history(ctx);
else if ([3, 5].includes(i.type)) { else if ([3, 5].includes(i.type)) {
const state = controls.read(i.data?.custom_id ?? ''); const state = controls.read(i.data?.custom_id ?? '');
result = progressAction(ctx, state.action, state.payload) ?? preferencesAction(ctx, state.action, state.payload) ?? historyAction(ctx, state.action, state.payload) ?? dailyAction(ctx, state.action, state.payload) ?? managementAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false); result = commandGraphicAction(ctx, state.action, state.payload) ?? progressAction(ctx, state.action, state.payload) ?? preferencesAction(ctx, state.action, state.payload) ?? historyAction(ctx, state.action, state.payload) ?? dailyAction(ctx, state.action, state.payload) ?? managementAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false);
} else result = message(ctx, 'Run /help to see available commands.', [], false); } else result = message(ctx, 'Run /help to see available commands.', [], false);
} catch (error) { } catch (error) {
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error; if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;