import type { ShareData } from "../sharing/contracts"; import type { PublicUser } from "../shared/user"; import { dayNumber } from "../habits/calendar"; export type CardPrivacy = { name: boolean; avatar: boolean; habitNames: boolean; timezone: boolean }; export const cardDate = (date: string) => new Date(`${date}T12:00:00Z`).toLocaleDateString("en", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); export const cardStats = (data: ShareData) => { const completed = data.habits.reduce((n, h) => n + h.completed, 0); const scheduled = data.habits.reduce((n, h) => n + h.scheduled, 0); return { completed, scheduled, percent: scheduled ? Math.round(completed / scheduled * 100) : null }; }; async function loadAvatar(url: string): Promise { return new Promise(resolve => { const img = new Image(); const finish = (value: HTMLImageElement | null) => { clearTimeout(timer); img.onload = null; img.onerror = null; resolve(value); }; const timer = setTimeout(() => finish(null), 5000); img.crossOrigin = "anonymous"; img.onload = () => finish(img); img.onerror = () => finish(null); img.src = url; }); } /** Render once: this exact PNG is previewed, downloaded, and sent to Discord. */ export async function renderProgressCard(data: ShareData, user: PublicUser, privacy: CardPrivacy): Promise<{ blob: Blob; alt: string; avatarMissing: boolean }> { await document.fonts.load('76px "Instrument Serif"'); const [avatar, botAvatar] = await Promise.all([ privacy.avatar && user.avatarUrl ? loadAvatar(user.avatarUrl) : null, data.botAvatarUrl ? loadAvatar(data.botAvatarUrl) : null, ]); const canvas = document.createElement("canvas"); const longRange = data.habits[0]!.days.length > 42; const titleGraphGap = 12; const rowHeight = (longRange ? 280 : 250) + titleGraphGap; canvas.width = 1200; canvas.height = 380 + data.habits.length * rowHeight + 76; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Your browser could not create the image. Try another browser."); const left = 56; const right = 1144; const chartLeft = 80; const chartWidth = right - chartLeft; const legendColumns = [chartLeft, chartLeft + 266, chartLeft + 532, chartLeft + 798] as const; const sans = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; const text = (value: string, x: number, y: number, size = 22, color = "#666", serif = false, maxWidth = 1088, align: CanvasTextAlign = "left") => { ctx.font = `${size}px ${serif ? '"Instrument Serif", Georgia, serif' : sans}`; ctx.fillStyle = color; ctx.textAlign = align; let fitted = value; if (ctx.measureText(fitted).width > maxWidth) { const chars = Array.from(value); while (chars.length && ctx.measureText(`${chars.join("")}…`).width > maxWidth) chars.pop(); fitted = `${chars.join("")}…`; } ctx.fillText(fitted, x, y); }; const rect = (x: number, y: number, w: number, h: number, color: string) => { ctx.fillStyle = color; ctx.fillRect(x, y, w, h); }; rect(0, 0, 1200, canvas.height, "#fff"); rect(0, 0, 1200, 8, data.habits[0]?.color ?? "#111"); let profileX = 56; if (privacy.avatar) { ctx.save(); ctx.beginPath(); ctx.arc(88, 84, 32, 0, Math.PI * 2); ctx.clip(); rect(56, 52, 64, 64, "#f1f1f1"); if (avatar) ctx.drawImage(avatar, 56, 52, 64, 64); else { ctx.fillStyle = "#888"; ctx.beginPath(); ctx.arc(88, 77, 10, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(88, 109, 22, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); profileX = 138; } text(privacy.name ? user.displayName || user.username : "A little, every day.", profileX, 94, 26, "#111", false, 700); if (botAvatar) { ctx.font = '34px "Instrument Serif", Georgia, serif'; const avatarX = right - ctx.measureText("minabot.").width - 12 - 40; ctx.save(); ctx.beginPath(); ctx.roundRect(avatarX, 64, 40, 40, 4); ctx.clip(); ctx.drawImage(botAvatar, avatarX, 64, 40, 40); ctx.restore(); } text("minabot.", right, 94, 34, "#111", true, 112, "right"); text("Small steps, adding up.", left, 213, 76, "#111", true); text(`${cardDate(data.from)} — ${cardDate(data.to)}`, left, 260, 23); const stats = cardStats(data); text(`${stats.completed} / ${stats.scheduled} scheduled check-ins complete`, left, 317, 24, "#111", false, 800); text(stats.percent === null ? "No days scheduled" : `${stats.percent}% complete`, right, 317, 24, "#111", false, 240, "right"); data.habits.forEach((habit, index) => { const top = 358 + index * rowHeight; rect(left, top, right - left, 1, "#dedede"); rect(left, top + 27, 6, 24, habit.color); text(privacy.habitNames ? habit.name : `Habit ${index + 1}`, chartLeft, top + 48, 24, "#111", false, 760); text(`${habit.completed} / ${habit.scheduled} days`, right, top + 48, 21, "#666", false, 185, "right"); ctx.save(); ctx.translate(0, titleGraphGap); if (habit.days.length <= 42) { const step = (chartWidth + 5) / habit.days.length; habit.days.forEach((day, i) => { const x = chartLeft + i * step; if (day.due) rect(x, top + 78, Math.max(8, step - 5), 62, day.color); else { ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(x + (step - 5) / 2, top + 109, 3, 0, Math.PI * 2); ctx.fill(); } if (habit.days.length <= 7 || i % 7 === 0) text(day.date.slice(8), x + (step - 5) / 2, top + 169, 17, "#666", false, step - 5, "center"); }); } else { const offset = (new Date(`${data.from}T12:00:00Z`).getUTCDay() + 6) % 7; const columns = Math.ceil((offset + habit.days.length) / 7); const step = (chartWidth + 4) / columns; const startX = chartLeft; text("M", left, top + 95, 14); text("W", left, top + 135, 14); text("F", left, top + 175, 14); let previousMonthX = -100; habit.days.forEach((day, i) => { const n = offset + i, x = startX + Math.floor(n / 7) * step, y = top + 82 + n % 7 * 20; if ((i === 0 || day.date.endsWith("-01")) && x - previousMonthX > 45 && x < 1108) { text(new Date(`${day.date}T12:00:00Z`).toLocaleDateString("en", { month: "short", timeZone: "UTC" }), x, top + 70, 14); previousMonthX = x; } if (day.due) rect(x, y, step - 4, 16, day.color); else { ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(x + (step - 4) / 2, y + 8, 2, 0, Math.PI * 2); ctx.fill(); } }); } const legendY = top + (longRange ? 241 : 209); rect(legendColumns[0], legendY, 16, 16, habit.legend.empty); text("No progress", legendColumns[0] + 26, legendY + 15, 18); if (habit.legend.partial.length) { habit.legend.partial.forEach((color, i) => rect(legendColumns[1] + i * 19, legendY, 16, 16, color)); text("Partial", legendColumns[1] + habit.legend.partial.length * 19 + 10, legendY + 15, 18); } rect(legendColumns[2], legendY, 16, 16, habit.legend.complete); text("Complete", legendColumns[2] + 26, legendY + 15, 18); ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(legendColumns[3] + 8, legendY + 8, 3, 0, Math.PI * 2); ctx.fill(); text("Not scheduled", legendColumns[3] + 26, legendY + 15, 18); ctx.restore(); }); const footerY = canvas.height - 46; text("Days off don’t count against you.", left, footerY, 19); text(privacy.timezone ? data.timezone : `${dayNumber(data.to) - dayNumber(data.from) + 1} days of small steps`, right, footerY, 19, "#666", false, 350, "right"); const blob = await new Promise((resolve, reject) => canvas.toBlob(value => value ? resolve(value) : reject(new Error("Could not export the card.")), "image/png")); const names = privacy.habitNames ? data.habits.map(h => h.name).join(", ") : `${data.habits.length} habits`; return { blob, avatarMissing: privacy.avatar && !!user.avatarUrl && !avatar, alt: `${privacy.name ? `${user.displayName || user.username}: ` : ""}${names}. ${cardDate(data.from)} to ${cardDate(data.to)}. ${stats.completed} of ${stats.scheduled} scheduled check-ins complete. Legend: empty squares mean no progress, intermediate shades mean partial progress, full color means complete, and dots mean not scheduled.${privacy.timezone ? ` ${data.timezone}.` : ""}` }; }