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; 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> | 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'); }