feat: add private day, history and reminder command graphics
This commit is contained in:
@@ -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`.
|
||||||
|
|||||||
98
src/discord/command-graphics.test.ts
Normal file
98
src/discord/command-graphics.test.ts
Normal 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);
|
||||||
|
});
|
||||||
62
src/discord/command-graphics.ts
Normal file
62
src/discord/command-graphics.ts
Normal 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 } : {} };
|
||||||
|
}
|
||||||
65
src/discord/command-image.ts
Normal file
65
src/discord/command-image.ts
Normal 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'));
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user