feat: render focused 30-day Discord progress cards with Canvas
This commit is contained in:
@@ -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.
|
leave the private response unfinished; restarting never replays the public send.
|
||||||
Run `/progress` again to inspect status. Public snapshot and live deliveries
|
Run `/progress` again to inspect status. Public snapshot and live deliveries
|
||||||
retain their existing persistent quotas, nonce and uncertain-delivery guards.
|
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`.
|
||||||
|
|||||||
34
src/discord/progress-image.test.ts
Normal file
34
src/discord/progress-image.test.ts
Normal 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');
|
||||||
|
});
|
||||||
66
src/discord/progress-image.ts
Normal file
66
src/discord/progress-image.ts
Normal 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'));
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { createCanvas, loadImage } from '@napi-rs/canvas';
|
import { renderProgressImage } from './progress-image';
|
||||||
import { ApiError } from '../habits/service';
|
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 { createSharingRoutes } from '../sharing/routes';
|
||||||
import type { createLiveDiscord } from '../sharing/live-discord';
|
import type { createLiveDiscord } from '../sharing/live-discord';
|
||||||
import type { Context, ControlData, Message, Reply } from './types';
|
import type { Context, ControlData, Message, Reply } from './types';
|
||||||
@@ -24,10 +24,7 @@ function imageData(c: Context, data: ControlData) {
|
|||||||
return { ...result, avatarUrl: null };
|
return { ...result, avatarUrl: null };
|
||||||
}
|
}
|
||||||
async function render(c: Context, data: ControlData, source = imageData(c, data)) {
|
async function render(c: Context, data: ControlData, source = imageData(c, data)) {
|
||||||
const original = await loadImage(await renderLiveCard(source, data.theme ?? 'light'));
|
return renderProgressImage(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'));
|
|
||||||
}
|
}
|
||||||
function output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; }
|
function output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; }
|
||||||
function defer(c: Context, job: () => Promise<DeferredMessage>): Reply {
|
function defer(c: Context, job: () => Promise<DeferredMessage>): Reply {
|
||||||
|
|||||||
Reference in New Issue
Block a user