- 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.
139 lines
8.1 KiB
TypeScript
139 lines
8.1 KiB
TypeScript
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 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<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}.` : ""}` };
|
||
}
|