Files
minabot/src/lib/progress-card.ts
syntaxbullet ecc2620a4c feat: add Home page with user dashboard and habit tracking features
- Implemented Home component with user authentication and loading states.
- Created Welcome component for unauthenticated users with a sign-in option.
- Developed Dashboard component to display user's habits and progress.
- Added functionality for habit management, including adding, updating, and deleting habits.
- Integrated HabitChart and HabitHistory components for visual representation of habits.
- Introduced sharing functionality for progress via Discord integration.

feat: establish Discord sharing configuration and routes

- Added DiscordSharingConfig type and readDiscordSharingConfig function for environment variable management.
- Created sharing contracts for input validation and data structure.
- Implemented sharing routes for previewing and sending progress images to Discord.
- Added tests for sharing routes to ensure authentication and proper error handling.
2026-09-04 17:48:54 +02:00

139 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 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<HTMLImageElement | null> {
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 dont 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<Blob>((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}.` : ""}` };
}