feat: render focused 30-day Discord progress cards with Canvas

This commit is contained in:
syntaxbullet
2026-09-08 08:27:51 +02:00
parent a1ae65634b
commit 2c135398d7
4 changed files with 109 additions and 6 deletions

View File

@@ -72,3 +72,9 @@ never receipts or controls. A process interruption after acknowledgement can
leave the private response unfinished; restarting never replays the public send.
Run `/progress` again to inspect status. Public snapshot and live deliveries
retain their existing persistent quotas, nonce and uncertain-delivery guards.
`progress-image.ts` renders the focused 30-day snapshot at 1200×860 with bundled
Mina Sans / Instrument Serif, shared theme colors, and existing habit shades.
The 30 cells retain fixed readable sizes; symbols are Canvas paths. Text summaries
and buttons accompany the PNG. To produce review images without sending messages:
`PROGRESS_IMAGE_REVIEW_DIR=/tmp/minabot-progress-review bun test src/discord/progress-image.test.ts`.

View File

@@ -0,0 +1,34 @@
import { expect, test } from 'bun:test';
import { createCanvas, loadImage } from '@napi-rs/canvas';
import { renderProgressImage } from './progress-image';
import { addDays } from '../habits/calendar';
import type { LiveImageData } from '../sharing/live-card-image';
import { themePalette } from '../lib/theme';
const sample: LiveImageData = {
name: 'Mina', avatarUrl: null, from: '2026-08-06', to: '2026-09-04',
habits: [{ name: 'Read a little, every day', color: '#285d49', completed: 7, scheduled: 23,
days: Array.from({ length: 30 }, (_, i) => ({ date: addDays('2026-08-06', i), color: ['#eeeeee', '#a9beb5', '#285d49'][i % 3]!, due: i % 4 !== 0, ratio: i % 4 === 0 ? null : [0, .5, 1][i % 3]! })) }],
};
for (const theme of ['light', 'dark'] as const) test(`focused 30-day ${theme} PNG fits upload limits, has all cells and deterministic pixels`, async () => {
const png = await renderProgressImage(sample, theme);
const bytes = Buffer.from(png);
expect(bytes.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a');
expect(bytes.readUInt32BE(16)).toBe(1200); expect(bytes.readUInt32BE(20)).toBe(860);
expect(bytes.length).toBeLessThan(4 * 1024 * 1024);
expect(await renderProgressImage(sample, theme)).toEqual(png);
const image = await loadImage(png), canvas = createCanvas(1200, 860), ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0);
const rgb = (hex: string) => [1,3,5].map(i => parseInt(hex.slice(i, i + 2), 16));
for (let i = 0; i < 30; i++) {
const pixel = [...ctx.getImageData(70 + (i % 10) * 108, 360 + Math.floor(i / 10) * 130, 1, 1).data];
expect(pixel[3]).toBe(255);
expect(pixel.slice(0, 3)).not.toEqual(rgb(themePalette[theme].paper));
}
// Opt-in artifact generation only; normal tests never write output files.
if (process.env.PROGRESS_IMAGE_REVIEW_DIR) await Bun.write(`${process.env.PROGRESS_IMAGE_REVIEW_DIR}/progress-${theme}.png`, png);
});
test('long unicode names render safely and invalid ranges are rejected', async () => {
const long = { ...sample, name: '🌸'.repeat(100), habits: [{ ...sample.habits[0]!, name: '読む 📚 '.repeat(100) }] };
expect((await renderProgressImage(long)).length).toBeLessThan(4 * 1024 * 1024);
expect(renderProgressImage({ ...sample, habits: [] })).rejects.toThrow('Choose one habit');
});

View File

@@ -0,0 +1,66 @@
import { createCanvas } from '@napi-rs/canvas';
// Import the existing renderer's bundled font registration and data contract.
import { type LiveImageData } from '../sharing/live-card-image';
import '../sharing/live-card-image';
import { themePalette, type Theme } from '../lib/theme';
import { themedHabitShade, habitProgressColor } from '../lib/habit-palette';
import { cardDate } from '../lib/progress-card';
export const progressImageSize = { width: 1200, height: 860 };
/** Focused, chronological 30-day view; constant font/cell sizes at any habit count. */
export async function renderProgressImage(data: LiveImageData, theme: Theme = 'light') {
const habit = data.habits[0];
if (!habit || data.habits.length !== 1 || habit.days.length !== 30) throw new Error('Choose one habit and a 30-day range.');
const canvas = createCanvas(progressImageSize.width, progressImageSize.height);
const ctx = canvas.getContext('2d'), palette = themePalette[theme];
const rect = (x: number, y: number, width: number, height: number, color: string, radius = 0) => {
ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, width, height, radius); ctx.fill();
};
const text = (value: string, x: number, y: number, size: number, width: number, color: string = palette.ink, serif = false) => {
ctx.font = `400 ${size}px "${serif ? 'Instrument Serif' : 'Mina Sans'}"`; ctx.fillStyle = color;
const chars = Array.from(value); let fitted = value;
while (chars.length && ctx.measureText(fitted).width > width) { chars.pop(); fitted = `${chars.join('')}…`; }
ctx.fillText(fitted, x, y);
};
const color = (ratio: number | null, due: boolean, serverShade?: string) => !due ? palette.avatar : !ratio ? palette.empty : serverShade ? themedHabitShade(serverShade, habit.color, theme) : habitProgressColor(habit.color, ratio, theme);
const contrast = (hex: string) => {
const [r, g, b] = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
return .299 * r! + .587 * g! + .114 * b! > 150 ? '#202a26' : '#ffffff';
};
rect(0, 0, 1200, 860, palette.paper);
rect(0, 0, 1200, 8, habit.color);
text(data.name, 64, 80, 28, 720);
text('Mina Habits.', 940, 82, 40, 200, palette.ink, true);
text(habit.name, 64, 175, 64, 1060, palette.ink, true);
text(`${cardDate(data.from)} – ${cardDate(data.to)}`, 64, 228, 26, 720, palette.muted);
text(`${habit.completed}/${habit.scheduled} days complete`, 800, 228, 26, 350);
rect(64, 261, 1072, 1, palette.rule);
habit.days.forEach((day, index) => {
const x = 64 + (index % 10) * 108, y = 325 + Math.floor(index / 10) * 130;
const label = new Date(`${day.date}T12:00:00Z`).toLocaleDateString('en', { month: 'short', day: 'numeric', timeZone: 'UTC' });
text(label, x, y, 20, 96, palette.muted);
rect(x, y + 15, 96, 70, color(day.ratio, day.due, day.color), 10);
// Draw symbols as paths: bundled text fonts need not contain checkmark glyphs.
const cx = x + 48, cy = y + 50;
ctx.strokeStyle = contrast(color(day.ratio, day.due, day.color));
ctx.fillStyle = ctx.strokeStyle; ctx.lineWidth = 3; ctx.lineCap = 'round';
ctx.beginPath();
if (!day.due) { ctx.moveTo(cx - 7, cy); ctx.lineTo(cx + 7, cy); ctx.stroke(); }
else if ((day.ratio ?? 0) >= 1) { ctx.moveTo(cx - 9, cy); ctx.lineTo(cx - 2, cy + 7); ctx.lineTo(cx + 11, cy - 8); ctx.stroke(); }
else { ctx.arc(cx, cy, day.ratio ? 4 : 8, 0, Math.PI * 2); if (day.ratio) ctx.fill(); else ctx.stroke(); }
});
rect(64, 716, 1072, 1, palette.rule);
const legend = [
{ label: 'Not scheduled', ratio: null, due: false },
{ label: 'No progress', ratio: 0, due: true },
{ label: 'Partial', ratio: 0.5, due: true },
{ label: 'Complete', ratio: 1, due: true },
];
legend.forEach((item, index) => {
const x = 64 + index * 276;
rect(x, 750, 24, 24, color(item.ratio, item.due), 5);
text(item.label, x + 36, 772, 21, 230, palette.muted);
});
text('A little, every day.', 64, 822, 27, 1072, palette.muted, true);
return new Uint8Array(await canvas.encode('png'));
}

View File

@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import { createCanvas, loadImage } from '@napi-rs/canvas';
import { renderProgressImage } from './progress-image';
import { ApiError } from '../habits/service';
import { liveCardData, renderLiveCard } from '../sharing/live-card-image';
import { liveCardData } from '../sharing/live-card-image';
import type { createSharingRoutes } from '../sharing/routes';
import type { createLiveDiscord } from '../sharing/live-discord';
import type { Context, ControlData, Message, Reply } from './types';
@@ -24,10 +24,7 @@ function imageData(c: Context, data: ControlData) {
return { ...result, avatarUrl: null };
}
async function render(c: Context, data: ControlData, source = imageData(c, data)) {
const original = await loadImage(await renderLiveCard(source, data.theme ?? 'light'));
const canvas = createCanvas(1200, 675);
canvas.getContext('2d').drawImage(original, 0, 0, 1200, 675);
return new Uint8Array(await canvas.encode('png'));
return renderProgressImage(source, data.theme ?? 'light');
}
function output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; }
function defer(c: Context, job: () => Promise<DeferredMessage>): Reply {