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.
This commit is contained in:
syntaxbullet
2026-09-04 17:48:54 +02:00
parent dde5e77317
commit ecc2620a4c
34 changed files with 4017 additions and 140 deletions

138
src/lib/progress-card.ts Normal file
View File

@@ -0,0 +1,138 @@
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}.` : ""}` };
}