+
{isPublic === true && session?.githubLogin && (
)}
diff --git a/src/components/DownloadPdfReportButton.tsx b/src/components/DownloadPdfReportButton.tsx
new file mode 100644
index 000000000..2e1dcef5c
--- /dev/null
+++ b/src/components/DownloadPdfReportButton.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { useSession } from "next-auth/react";
+import { toast } from "sonner";
+import { downloadDashboardPdf, DashboardReportData, DashboardReportMonthlyCommit, DashboardReportStreak } from "@/lib/pdf-generator";
+
+function formatMonthLabel(monthKey: string): string {
+ const [year, month] = monthKey.split("-");
+ const parsed = new Date(Number(year), Number(month) - 1, 1);
+ if (Number.isNaN(parsed.getTime())) return monthKey;
+ return parsed.toLocaleDateString("en-US", { month: "short", year: "numeric" });
+}
+
+function buildMonthlySummaries(data: Record
): DashboardReportMonthlyCommit[] {
+ const monthly = new Map; bestDay: { date: string; commits: number } | null }>();
+ for (const [date, commits] of Object.entries(data)) {
+ const monthKey = date.slice(0, 7);
+ const current = monthly.get(monthKey) ?? { commits: 0, activeDays: new Set(), bestDay: null };
+ current.commits += commits;
+ if (commits > 0) {
+ current.activeDays.add(date);
+ }
+ if (!current.bestDay || commits > current.bestDay.commits) {
+ current.bestDay = { date, commits };
+ }
+ monthly.set(monthKey, current);
+ }
+
+ return Array.from(monthly.entries())
+ .sort(([a], [b]) => b.localeCompare(a))
+ .slice(0, 3)
+ .map(([monthKey, value]) => ({
+ month: formatMonthLabel(monthKey),
+ commits: value.commits,
+ activeDays: value.activeDays.size,
+ bestDay: value.bestDay ? value.bestDay.date : null,
+ }));
+}
+
+function formatHoursFromSeconds(seconds?: number): number | undefined {
+ if (typeof seconds !== "number") return undefined;
+ return Number((seconds / 3600).toFixed(1));
+}
+
+function formatCommitRows(data: Record): Array<{ date: string; commits: number }> {
+ return Object.entries(data)
+ .map(([date, commits]) => ({ date, commits }))
+ .sort((a, b) => a.date.localeCompare(b.date));
+}
+
+async function fetchDashboardReportData(session: ReturnType['data']): Promise {
+ const fetchOptions: RequestInit = {
+ cache: "no-store",
+ };
+
+ const [weeklyRes, contributionsRes, languagesRes, reposRes, wakatimeRes, goalsRes, achievementsRes] = await Promise.all([
+ fetch("/api/metrics/weekly-summary", fetchOptions),
+ fetch("/api/metrics/contributions?days=90", fetchOptions),
+ fetch("/api/metrics/languages?days=90", fetchOptions),
+ fetch("/api/metrics/repos?days=90", fetchOptions),
+ fetch("/api/wakatime", fetchOptions),
+ fetch("/api/goals", fetchOptions),
+ fetch("/api/metrics/achievements", fetchOptions),
+ ]);
+
+ const weeklyData = weeklyRes.ok ? await weeklyRes.json() : null;
+ const contributionsData = contributionsRes.ok ? await contributionsRes.json() : null;
+ const languagesData = languagesRes.ok ? await languagesRes.json() : null;
+ const reposData = reposRes.ok ? await reposRes.json() : null;
+ const wakatimeData = wakatimeRes.ok ? await wakatimeRes.json() : null;
+ const goalsData = goalsRes.ok ? await goalsRes.json() : null;
+ const achievementsData = achievementsRes.ok ? await achievementsRes.json() : null;
+
+ const contributionData = contributionsData?.data ? contributionsData.data as Record : {};
+ const monthlyCommits = buildMonthlySummaries(contributionData);
+ const streak: DashboardReportStreak = {
+ current: weeklyData?.streak ?? undefined,
+ longest: undefined,
+ totalActiveDays: weeklyData?.activeDays?.thisWeek ?? undefined,
+ lastCommitDate: null,
+ };
+
+ const reportData: DashboardReportData = {
+ userProfile: {
+ name: session?.user?.name ?? undefined,
+ githubLogin: session?.githubLogin ?? undefined,
+ avatarUrl: session?.user?.image ?? null,
+ },
+ weeklySummary: {
+ commitsThisWeek: weeklyData?.commits?.current,
+ commitsLastWeek: weeklyData?.commits?.previous,
+ delta: weeklyData?.commits?.delta,
+ activeDaysThisWeek: weeklyData?.activeDays?.thisWeek,
+ activeDaysLastWeek: weeklyData?.activeDays?.lastWeek,
+ topRepo: weeklyData?.topRepo ?? null,
+ mostActiveDay: weeklyData?.mostActiveDay ?? null,
+ streak: weeklyData?.streak,
+ },
+ streak,
+ wakatime: {
+ hasData: wakatimeData?.hasData === true,
+ todaysHours: formatHoursFromSeconds(wakatimeData?.todaysSeconds),
+ weeklyHours: formatHoursFromSeconds(wakatimeData?.totalSeconds7Days),
+ topLanguage: wakatimeData?.topLanguage ?? null,
+ topProject: wakatimeData?.topProject ?? null,
+ },
+ languages: Array.isArray(languagesData?.languages)
+ ? languagesData.languages.slice(0, 6).map((item: any) => ({
+ name: item.name ?? "Unknown",
+ percentage: Number(item.percentage ?? 0),
+ }))
+ : [],
+ topRepos: Array.isArray(reposData?.repos)
+ ? reposData.repos.slice(0, 8).map((repo: any) => ({
+ name: repo.name ?? "Unknown",
+ commits: Number(repo.commits ?? 0),
+ description: repo.description ?? null,
+ url: repo.url ?? undefined,
+ }))
+ : [],
+ goals: Array.isArray(goalsData?.goals)
+ ? goalsData.goals.slice(0, 8).map((goal: any) => ({
+ title: goal.title ?? "Untitled goal",
+ current: Number(goal.current ?? 0),
+ target: Number(goal.target ?? 0),
+ }))
+ : [],
+ achievements: Array.isArray(achievementsData?.achievements)
+ ? achievementsData.achievements.slice(0, 10).map((achievement: any) => ({
+ title: achievement.title ?? "Achievement",
+ description: achievement.description ?? null,
+ url: achievement.url ?? undefined,
+ }))
+ : [],
+ monthlyCommits,
+ };
+
+ return reportData;
+}
+
+export default function DownloadPdfReportButton() {
+ const { data: session } = useSession();
+ const [isDownloading, setIsDownloading] = useState(false);
+
+ const githubLogin = useMemo(() => (session as any)?.githubLogin as string | undefined, [session]);
+ const hasSession = useMemo(() => !!githubLogin || !!session?.user?.name, [githubLogin, session]);
+
+ const handleDownload = async () => {
+ setIsDownloading(true);
+ try {
+ if (!session) {
+ toast.error("Please sign in to download your PDF report.");
+ return;
+ }
+ const reportData = await fetchDashboardReportData(session);
+ await downloadDashboardPdf(reportData);
+ toast.success("PDF report downloaded.");
+ } catch (error) {
+ console.error("Failed to generate dashboard PDF", error);
+ toast.error("Unable to generate the PDF report. Please try again.");
+ } finally {
+ setIsDownloading(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/lib/pdf-generator.ts b/src/lib/pdf-generator.ts
new file mode 100644
index 000000000..90f2a296c
--- /dev/null
+++ b/src/lib/pdf-generator.ts
@@ -0,0 +1,375 @@
+import jsPDF from "jspdf";
+import autoTable from "jspdf-autotable";
+
+export interface DashboardReportUserProfile {
+ name?: string;
+ githubLogin?: string;
+ avatarUrl?: string | null;
+}
+
+export interface DashboardReportWeeklySummary {
+ commitsThisWeek?: number;
+ commitsLastWeek?: number;
+ delta?: number;
+ activeDaysThisWeek?: number;
+ activeDaysLastWeek?: number;
+ topRepo?: string | null;
+ mostActiveDay?: string | null;
+ streak?: number;
+}
+
+export interface DashboardReportStreak {
+ current?: number;
+ longest?: number;
+ totalActiveDays?: number;
+ lastCommitDate?: string | null;
+}
+
+export interface DashboardReportWakaTime {
+ hasData: boolean;
+ todaysHours?: number;
+ weeklyHours?: number;
+ topLanguage?: string | null;
+ topProject?: string | null;
+}
+
+export interface DashboardReportLanguage {
+ name: string;
+ percentage: number;
+}
+
+export interface DashboardReportRepo {
+ name: string;
+ commits: number;
+ url?: string;
+ description?: string | null;
+}
+
+export interface DashboardReportGoal {
+ title: string;
+ current: number;
+ target: number;
+}
+
+export interface DashboardReportAchievement {
+ title: string;
+ description?: string | null;
+ url?: string;
+}
+
+export interface DashboardReportMonthlyCommit {
+ month: string;
+ commits: number;
+ activeDays: number;
+ bestDay?: string | null;
+}
+
+export interface DashboardReportData {
+ userProfile: DashboardReportUserProfile;
+ weeklySummary?: DashboardReportWeeklySummary;
+ streak?: DashboardReportStreak;
+ wakatime?: DashboardReportWakaTime;
+ languages?: DashboardReportLanguage[];
+ topRepos?: DashboardReportRepo[];
+ goals?: DashboardReportGoal[];
+ achievements?: DashboardReportAchievement[];
+ monthlyCommits?: DashboardReportMonthlyCommit[];
+}
+
+function formatDate(date: Date): string {
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+}
+
+function formatHours(hours?: number): string {
+ if (typeof hours !== "number" || Number.isNaN(hours)) {
+ return "No data available";
+ }
+ return `${hours.toFixed(1)} hrs`;
+}
+
+function formatPercent(value?: number): string {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return "-";
+ }
+ return `${value.toFixed(1)}%`;
+}
+
+function formatDateLabel(dateString?: string | null): string {
+ if (!dateString) return "No data available";
+ const parsed = new Date(dateString);
+ if (Number.isNaN(parsed.getTime())) return dateString;
+ return formatDate(parsed);
+}
+
+function safeText(value: string | number | undefined | null): string {
+ if (value === null || value === undefined || String(value).trim() === "") {
+ return "No data available";
+ }
+
+ return String(value);
+}
+
+async function fetchImageAsDataUrl(url: string): Promise {
+ try {
+ const response = await fetch(url, { cache: "no-store" });
+ if (!response.ok) return null;
+ const blob = await response.blob();
+ return await new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ resolve(reader.result as string);
+ };
+ reader.readAsDataURL(blob);
+ });
+ } catch {
+ return null;
+ }
+}
+
+function addFooter(doc: jsPDF, generatedAt: string) {
+ const pageCount = doc.getNumberOfPages();
+ const pageWidth = doc.internal.pageSize.getWidth();
+ const pageHeight = doc.internal.pageSize.getHeight();
+
+ for (let page = 1; page <= pageCount; page += 1) {
+ doc.setPage(page);
+ doc.setDrawColor(226, 232, 240);
+ doc.line(40, pageHeight - 30, pageWidth - 40, pageHeight - 30);
+ doc.setFontSize(9);
+ doc.setTextColor(100, 116, 139);
+ doc.text("Generated by DevTrack", 40, pageHeight - 16);
+ doc.text(`Exported ${generatedAt}`, pageWidth / 2, pageHeight - 16, {
+ align: "center",
+ });
+ doc.text(`Page ${page} of ${pageCount}`, pageWidth - 40, pageHeight - 16, {
+ align: "right",
+ });
+ }
+}
+
+function drawSectionTitle(doc: jsPDF, title: string, x: number, y: number) {
+ doc.setFontSize(12);
+ doc.setFont("helvetica", "bold");
+ doc.setTextColor(15, 23, 42);
+ doc.text(title, x, y);
+}
+
+function drawSummaryCell(
+ doc: jsPDF,
+ x: number,
+ y: number,
+ width: number,
+ label: string,
+ value: string,
+ color: [number, number, number]
+) {
+ const height = 52;
+ doc.setFillColor(248, 250, 252);
+ doc.roundedRect(x, y, width, height, 8, 8, "FD");
+ doc.setFillColor(...color);
+ doc.roundedRect(x, y, 6, height, 4, 4, "F");
+ doc.setFontSize(9);
+ doc.setFont("helvetica", "normal");
+ doc.setTextColor(100, 116, 139);
+ doc.text(label, x + 14, y + 18);
+ doc.setFontSize(18);
+ doc.setFont("helvetica", "bold");
+ doc.setTextColor(15, 23, 42);
+ doc.text(value, x + 14, y + 38);
+}
+
+function drawTable(
+ doc: jsPDF,
+ title: string,
+ startY: number,
+ head: string[][],
+ body: Array>,
+ columnStyles: Record = {}
+) {
+ drawSectionTitle(doc, title, 40, startY);
+ autoTable(doc, {
+ startY: startY + 10,
+ head,
+ body,
+ margin: { left: 40, right: 40, bottom: 20 },
+ styles: {
+ fontSize: 9,
+ cellPadding: 4,
+ lineColor: [226, 232, 240],
+ lineWidth: 0.2,
+ textColor: [15, 23, 42],
+ overflow: "linebreak",
+ },
+ headStyles: {
+ fillColor: [30, 41, 59],
+ textColor: [255, 255, 255],
+ fontStyle: "bold",
+ halign: "left",
+ },
+ alternateRowStyles: { fillColor: [248, 250, 252] },
+ columnStyles,
+ pageBreak: "auto",
+ rowPageBreak: "avoid",
+ });
+
+ return ((doc as any).lastAutoTable?.finalY ?? startY) + 14;
+}
+
+export async function generateDashboardPdf(reportData: DashboardReportData) {
+ const doc = new jsPDF({ unit: "pt", format: "letter" });
+ const pageWidth = doc.internal.pageSize.getWidth();
+ const nowLabel = formatDate(new Date());
+ const generatedAt = new Date().toLocaleString("en-US", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+
+ let y = 40;
+ const avatarSize = 54;
+
+ if (reportData.userProfile.avatarUrl) {
+ const avatar = await fetchImageAsDataUrl(reportData.userProfile.avatarUrl);
+ if (avatar) {
+ const imgType = avatar.startsWith("data:image/png") ? "PNG" : "JPEG";
+ doc.addImage(avatar, imgType, pageWidth - 40 - avatarSize, y, avatarSize, avatarSize);
+ }
+ }
+
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(22);
+ doc.setTextColor(15, 23, 42);
+ doc.text("DevTrack Productivity Report", 40, y + 20);
+
+ doc.setFontSize(10);
+ doc.setFont("helvetica", "normal");
+ doc.setTextColor(71, 85, 105);
+ doc.text(`Generated ${generatedAt}`, 40, y + 38);
+ doc.text(`Report date: ${nowLabel}`, 40, y + 52);
+
+ const profileName = reportData.userProfile.name ?? reportData.userProfile.githubLogin ?? "Developer";
+ const profileHandle = reportData.userProfile.githubLogin ? `@${reportData.userProfile.githubLogin}` : undefined;
+ doc.setFontSize(12);
+ doc.setFont("helvetica", "bold");
+ doc.text(profileName, 40, y + 80);
+ if (profileHandle) {
+ doc.setFontSize(9);
+ doc.setFont("helvetica", "normal");
+ doc.setTextColor(100, 116, 139);
+ doc.text(profileHandle, 40, y + 96);
+ }
+
+ y += 110;
+
+ const cards = [
+ {
+ label: "Weekly commits",
+ value: safeText(reportData.weeklySummary?.commitsThisWeek ?? reportData.weeklySummary?.commitsThisWeek ?? "No data available"),
+ color: [59, 130, 246] as [number, number, number],
+ },
+ {
+ label: "Current streak",
+ value: safeText(reportData.streak?.current ?? reportData.weeklySummary?.streak ?? "No data available"),
+ color: [16, 185, 129] as [number, number, number],
+ },
+ {
+ label: "Weekly WakaTime",
+ value: reportData.wakatime?.hasData ? formatHours(reportData.wakatime?.weeklyHours) : "No data available",
+ color: [249, 115, 22] as [number, number, number],
+ },
+ ];
+
+ let x = 40;
+ const cardWidth = (pageWidth - 80 - 20) / 3;
+ cards.forEach((card) => {
+ drawSummaryCell(doc, x, y, cardWidth, card.label, card.value, card.color);
+ x += cardWidth + 10;
+ });
+
+ y += 72;
+
+ const insightText = reportData.weeklySummary?.topRepo
+ ? `Top repo this week: ${reportData.weeklySummary.topRepo}`
+ : "Top repository data is not available.";
+
+ doc.setFillColor(248, 250, 252);
+ doc.setDrawColor(226, 232, 240);
+ doc.roundedRect(40, y, pageWidth - 80, 36, 6, 6, "FD");
+ doc.setFontSize(10);
+ doc.setTextColor(15, 23, 42);
+ doc.setFont("helvetica", "bold");
+ doc.text("Productivity Summary", 46, y + 14);
+ doc.setFont("helvetica", "normal");
+ doc.text(insightText, 46, y + 28, { maxWidth: pageWidth - 100 });
+
+ y += 56;
+
+ const monthlyCommits = reportData.monthlyCommits ?? [];
+ const commitStatsBody = monthlyCommits.length > 0
+ ? monthlyCommits.map((entry) => [entry.month, entry.commits, entry.activeDays, entry.bestDay || "-"])
+ : [["No data available", "-", "-", "-"]];
+ y = drawTable(doc, "Month-by-Month Commit Summary", y, [["Month", "Commits", "Active Days", "Best Day"]], commitStatsBody, {
+ 1: { halign: "right" },
+ 2: { halign: "right" },
+ });
+
+ const streakBody = [
+ ["Current streak", safeText(reportData.streak?.current ?? reportData.weeklySummary?.streak ?? "No data available")],
+ ["Longest streak", safeText(reportData.streak?.longest ?? "No data available")],
+ ["Active days", safeText(reportData.streak?.totalActiveDays ?? "No data available")],
+ ["Last commit", formatDateLabel(reportData.streak?.lastCommitDate)],
+ ];
+
+ y = drawTable(doc, "Streak Information", y, [["Metric", "Value"]], streakBody, { 1: { halign: "right" } });
+
+ const wakaBody = reportData.wakatime?.hasData
+ ? [
+ ["Last 7 days (hours)", formatHours(reportData.wakatime.weeklyHours)],
+ ["Today (hours)", formatHours(reportData.wakatime.todaysHours)],
+ ["Top language", safeText(reportData.wakatime.topLanguage ?? "No data available")],
+ ["Top project", safeText(reportData.wakatime.topProject ?? "No data available")],
+ ]
+ : [["No WakaTime data available", ""]];
+
+ y = drawTable(doc, "WakaTime Summary", y, reportData.wakatime?.hasData ? [["Metric", "Value"]] : [["Metric"]], wakaBody, { 1: { halign: "right" } });
+
+ const languageBody = reportData.languages && reportData.languages.length > 0
+ ? reportData.languages.map((lang) => [lang.name, formatPercent(lang.percentage)])
+ : [["No data available", "-"]];
+
+ y = drawTable(doc, "Language Breakdown", y, [["Language", "Percentage"]], languageBody, { 1: { halign: "right" } });
+
+ const reposBody = reportData.topRepos && reportData.topRepos.length > 0
+ ? reportData.topRepos.slice(0, 8).map((repo) => [repo.name, repo.commits, repo.description ?? "-"])
+ : [["No repositories available", "-", "-"]];
+
+ y = drawTable(doc, "Top Repositories", y, [["Repository", "Commits", "Description"]], reposBody, { 1: { halign: "right" } });
+
+ const goalsBody = reportData.goals && reportData.goals.length > 0
+ ? reportData.goals.slice(0, 8).map((goal) => [goal.title, safeText(goal.current), safeText(goal.target)])
+ : [["No goals available", "-", "-"]];
+
+ y = drawTable(doc, "Goals Progress", y, [["Goal", "Current", "Target"]], goalsBody, { 1: { halign: "right" }, 2: { halign: "right" } });
+
+ if (reportData.achievements && reportData.achievements.length > 0) {
+ const achievementRows = reportData.achievements.slice(0, 10).map((achievement) => [achievement.title, achievement.description ?? "-"]);
+ y = drawTable(doc, "Achievements", y, [["Achievement", "Description"]], achievementRows, { 1: { halign: "left" } });
+ }
+
+ addFooter(doc, generatedAt);
+ return doc;
+}
+
+export function buildDashboardPdfFileName() {
+ const date = new Date().toISOString().slice(0, 10);
+ return `devtrack-report-${date}.pdf`;
+}
+
+export async function downloadDashboardPdf(reportData: DashboardReportData) {
+ const doc = await generateDashboardPdf(reportData);
+ const filename = buildDashboardPdfFileName();
+ doc.save(filename);
+}