- 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.
128 lines
8.1 KiB
TypeScript
128 lines
8.1 KiB
TypeScript
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');
|
||
}
|