Files
minabot/src/sharing/live-card-image.ts
2026-09-07 11:57:23 +02:00

137 lines
8.4 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";
for (const [file, family] of [
['InstrumentSerif-Regular.ttf', 'Instrument Serif'],
['NotoSans-Regular.ttf', 'Mina Sans'],
['NotoSans-Medium.ttf', 'Mina Sans'],
['NotoSans-SemiBold.ttf', 'Mina Sans'],
]) {
const sourceFont = new URL(`../assets/fonts/${file}`, import.meta.url);
const builtFont = new URL(`./fonts/${file}`, import.meta.url);
if (!GlobalFonts.registerFromPath((existsSync(sourceFont) ? sourceFont : builtFont).pathname, family)) {
throw new Error(`Could not load bundled font: ${file}`);
}
}
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"' : '"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);
};
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');
}