Files
minabot/src/sharing/live-card-image.ts
syntaxbullet c7aa5a1c65 feat: add live Discord sharing functionality with dynamic habit progress images
- Implemented live card image generation for user habits using canvas.
- Created API endpoints for starting and managing live Discord sharing sessions.
- Added tests for live Discord sharing to ensure functionality and reliability.
- Introduced CSS styles for the live Discord sharing interface.
- Enhanced error handling and state management for Discord message updates.
2026-09-07 11:36:47 +02:00

128 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createCanvas, GlobalFonts, loadImage } from "@napi-rs/canvas";
import { existsSync } from "node:fs";
import { eq } from "drizzle-orm";
import type { AppDatabase } from "../auth";
import { users } from "../db/schema";
import { HabitService, ApiError } from "../habits/service";
import { addDays } from "../habits/calendar";
import { themePalette, type Theme } from "../lib/theme";
import { cardDate } from "../lib/progress-card";
import { calendarTimeline } from "../components/design-system/calendar-model";
import { themedHabitShade } from "../lib/habit-palette";
const sourceFont = new URL('../assets/fonts/InstrumentSerif-Regular.ttf', import.meta.url);
const builtFont = new URL('./fonts/InstrumentSerif-Regular.ttf', import.meta.url);
GlobalFonts.registerFromPath((existsSync(sourceFont) ? sourceFont : builtFont).pathname, 'Instrument Serif');
export function liveCardData(db: AppDatabase, userId: string, now: number) {
const user = db.select().from(users).where(eq(users.id, userId)).get();
if (!user) throw new ApiError(404, "Account unavailable.");
const service = new HabitService(db, { ...user, displayName: user.globalName ?? user.username, avatarUrl: null }, now);
service.sync();
const from = addDays(service.today, -29);
const avatarUrl = /^\d{17,20}$/.test(user.discordId) && /^(?:a_)?[a-f0-9]{32}$/i.test(user.avatarHash ?? '')
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatarHash}.png?size=128` : null;
return { name: user.globalName ?? user.username, avatarUrl, from, to: service.today, habits: service.list().map(h => {
const chart = service.calendar([h.id], from, service.today, service.settings(h.id), false);
return { name: h.name, color: chart.settings.mainColor,
completed: chart.days.filter(d => d.completed > 0).length, scheduled: chart.days.filter(d => d.due > 0).length,
days: chart.days.map(d => ({ date: d.date, color: d.color, due: d.due > 0, ratio: d.ratio })) };
}) };
}
export type LiveImageData = ReturnType<typeof liveCardData>;
export function liveCardLayout(count: number) {
const columns = count <= 8 ? 2 : Math.ceil(Math.sqrt(count / 2));
const rows = Math.max(1, Math.ceil(count / columns));
return { columns, rows, width: 1920, height: 1080, panelWidth: (1792 - (columns - 1) * 32) / columns, panelHeight: 650 / rows };
}
/** A deterministic 16:9 PNG. Every active habit receives a panel; none are paginated away. */
export async function renderLiveCard(data: LiveImageData, theme: Theme) {
const layout = liveCardLayout(data.habits.length);
const canvas = createCanvas(layout.width, layout.height);
const ctx = canvas.getContext('2d');
const colors = themePalette[theme];
let avatar: Awaited<ReturnType<typeof loadImage>> | null = null;
if (data.avatarUrl) {
try {
const response = await fetch(data.avatarUrl, { redirect: 'error', signal: AbortSignal.timeout(3000) });
if (response.ok) avatar = await loadImage(Buffer.from(await response.arrayBuffer()));
} catch { /* Match the snapshot card's neutral avatar fallback. */ }
}
const rect = (x: number, y: number, w: number, h: number, color: string, radius = 0) => {
ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, Math.max(0, w), Math.max(0, h), radius); ctx.fill();
};
const text = (value: string, x: number, y: number, size: number, width: number, color: string = colors.ink, serif = false, weight = '400') => {
ctx.font = `${weight} ${size}px ${serif ? '"Instrument Serif"' : 'sans-serif'}`; 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);
};
rect(0, 0, 1920, 1080, colors.paper);
rect(0, 0, 1920, 8, data.habits[0]?.color ?? '#285d49');
// The same profile / wordmark hierarchy as the snapshot card.
ctx.save(); ctx.beginPath(); ctx.arc(106, 94, 42, 0, Math.PI * 2); ctx.clip();
rect(64, 52, 84, 84, colors.avatar);
if (avatar) ctx.drawImage(avatar, 64, 52, 84, 84);
else {
ctx.fillStyle = colors.muted; ctx.beginPath(); ctx.arc(106, 85, 14, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(106, 130, 30, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
text(data.name, 172, 109, 44, 1140, colors.ink, false, '600');
ctx.textAlign = 'right'; text('Mina Habits.', 1856, 112, 64, 470, colors.ink, true); ctx.textAlign = 'left';
text('A little, every day.', 64, 228, 96, 1792, colors.ink, true);
text(`${cardDate(data.from)} ${cardDate(data.to)}`, 64, 289, 36, 1600, colors.muted);
data.habits.forEach((habit, index) => {
const x = 64 + index % layout.columns * (layout.panelWidth + 32);
const y = 340 + Math.floor(index / layout.columns) * layout.panelHeight;
const w = layout.panelWidth, h = layout.panelHeight;
const compact = h < 220;
const fontSize = Math.min(compact ? 26 : 38, h * (compact ? .20 : .105), w * .05);
const labelSize = Math.min(28, h * .067, w * .04);
rect(x, y, w, 1, colors.rule);
text(habit.name, x, y + fontSize + h * .035, fontSize, w, colors.ink, false, '600');
if (compact) {
text(`${habit.completed} / ${habit.scheduled}`, x, y + h * .56, Math.min(40, h * .30), w * .46, colors.ink, false, '600');
text('check-ins complete', x, y + h * .75, Math.min(20, h * .14), w * .46, colors.muted);
} else {
text(`${habit.completed}`, x, y + h * .43, Math.min(104, h * .25), w * .43, colors.ink, false, '600');
text('check-ins complete', x, y + h * .54, labelSize, w * .46, colors.ink, false, '500');
text(`of ${habit.scheduled} scheduled`, x, y + h * .64, labelSize, w * .46, colors.muted);
text('over 30 days', x, y + h * .74, labelSize, w * .46, colors.muted);
}
const chart = calendarTimeline(habit.days.map(day => ({ date: day.date, value: day.ratio ?? 0, target: 1, state: day.due ? 'due' : 'not-due', color: day.color })));
const chartWidth = w * .44;
const step = Math.min(44, h * .61 / 7, chartWidth / Math.max(1, chart.weeks));
const cell = step * .86;
const chartX = x + w * .56 + (chartWidth - chart.weeks * step) / 2;
const chartY = y + h * (compact ? .30 : .19);
for (const row of [1, 3, 5]) {
ctx.textAlign = 'right';
text(['S', 'M', 'T', 'W', 'T', 'F', 'S'][row]!, chartX - step * .38, chartY + row * step + cell * .8, Math.min(26, step * .7), step, colors.muted);
ctx.textAlign = 'left';
}
chart.entries.forEach(({ day, column, row }) => {
const dx = chartX + (column - 1) * step, dy = chartY + (row - 1) * step;
if (day.state === 'due') rect(dx, dy, cell, cell, day.value ? themedHabitShade(day.color!, habit.color, theme) : colors.empty, Math.min(3, cell / 6));
else { ctx.fillStyle = colors.dot; ctx.beginPath(); ctx.arc(dx + cell / 2, dy + cell / 2, Math.max(.5, cell * .07), 0, Math.PI * 2); ctx.fill(); }
});
if (compact) return;
const legendY = y + h * .92;
const legendSize = Math.min(23, h * .052, w * .03);
text('0', x, legendY, legendSize, w * .05, colors.muted);
const shadeColors = [colors.empty, ...[...new Set(habit.days.filter(d => d.due && d.ratio && d.ratio < 1).map(d => d.color))].slice(0, 3).map(color => themedHabitShade(color, habit.color, theme)), themedHabitShade(habit.color, habit.color, theme)];
const swatch = legendSize * .72;
shadeColors.forEach((color, i) => rect(x + legendSize * 1.1 + i * legendSize, legendY - swatch, swatch, swatch, color, Math.min(2, swatch / 5)));
text('Complete', x + legendSize * (shadeColors.length + 1.4), legendY, legendSize, w * .26, colors.muted);
ctx.fillStyle = colors.dot; ctx.beginPath(); ctx.arc(x + w * .70, legendY - legendSize * .3, Math.max(.5, legendSize * .1), 0, Math.PI * 2); ctx.fill();
ctx.textAlign = 'right'; text('Not scheduled', x + w, legendY, legendSize, w * .28, colors.muted); ctx.textAlign = 'left';
});
if (!data.habits.length) text('No active habits yet.', 64, 440, 36, 1400, colors.muted);
rect(64, 1010, 1792, 1, colors.rule);
text('Totals cover this entire period', 64, 1050, 28, 1200, colors.muted);
ctx.textAlign = 'right'; text(`${data.habits.length} active habits`, 1856, 1050, 28, 536, colors.muted);
return canvas.encode('png');
}