From 9cd7b53fdc558bff5ca4c3d74a14e120f7a7192f Mon Sep 17 00:00:00 2001 From: KrutagyaKaneria Date: Fri, 3 Jul 2026 14:21:42 +0530 Subject: [PATCH] feat: add printable productivity PDF reports --- src/components/DashboardHeader.tsx | 2 + src/components/DownloadPdfReportButton.tsx | 187 ++++++++++ src/lib/pdf-generator.ts | 375 +++++++++++++++++++++ 3 files changed, 564 insertions(+) create mode 100644 src/components/DownloadPdfReportButton.tsx create mode 100644 src/lib/pdf-generator.ts diff --git a/src/components/DashboardHeader.tsx b/src/components/DashboardHeader.tsx index 2d222a106..47f54725b 100644 --- a/src/components/DashboardHeader.tsx +++ b/src/components/DashboardHeader.tsx @@ -19,6 +19,7 @@ import ThemeToggle from "@/components/ThemeToggle"; import UserAvatar from "@/components/UserAvatar"; import KeyboardShortcuts from "@/components/KeyboardShortcuts"; import OnboardingTour from "@/components/OnboardingTour"; +import DownloadPdfReportButton from "@/components/DownloadPdfReportButton"; import { Moon, Sun } from "lucide-react"; import { toast } from "sonner"; import { useRealtimeSync } from "@/hooks/useRealtimeSync"; @@ -392,6 +393,7 @@ export default function DashboardHeader() { {/* Right Section */}
+ {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); +}