From 2c135398d7460658b0865a5ac9fe15237accfba0 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 08:27:51 +0200 Subject: [PATCH] feat: render focused 30-day Discord progress cards with Canvas --- src/discord/README.md | 6 +++ src/discord/progress-image.test.ts | 34 +++++++++++++++ src/discord/progress-image.ts | 66 ++++++++++++++++++++++++++++++ src/discord/progress.ts | 9 ++-- 4 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 src/discord/progress-image.test.ts create mode 100644 src/discord/progress-image.ts diff --git a/src/discord/README.md b/src/discord/README.md index 3d290a6..90d603e 100644 --- a/src/discord/README.md +++ b/src/discord/README.md @@ -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`. diff --git a/src/discord/progress-image.test.ts b/src/discord/progress-image.test.ts new file mode 100644 index 0000000..49789af --- /dev/null +++ b/src/discord/progress-image.test.ts @@ -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'); +}); diff --git a/src/discord/progress-image.ts b/src/discord/progress-image.ts new file mode 100644 index 0000000..c00822a --- /dev/null +++ b/src/discord/progress-image.ts @@ -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')); +} diff --git a/src/discord/progress.ts b/src/discord/progress.ts index 1413b9a..421f06c 100644 --- a/src/discord/progress.ts +++ b/src/discord/progress.ts @@ -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): Reply {