import jsPDF from "jspdf"; import autoTable from "jspdf-autotable"; import type { UserReport } from "@fitai/shared"; export class PDFGenerator { private doc: jsPDF; private pageWidth: number; private pageHeight: number; private margin: { top: number; right: number; bottom: number; left: number }; private currentY: number = 0; // Theme colors (from globals.css) private colors = { primary: [60, 130, 225] as [number, number, number], primaryDark: [40, 80, 180] as [number, number, number], success: [46, 160, 67] as [number, number, number], warning: [240, 185, 11] as [number, number, number], destructive: [215, 50, 50] as [number, number, number], text: [30, 30, 30] as [number, number, number], textLight: [100, 100, 100] as [number, number, number], background: [245, 245, 245] as [number, number, number], white: [255, 255, 255] as [number, number, number], }; constructor() { this.doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4", }); this.pageWidth = this.doc.internal.pageSize.getWidth(); this.pageHeight = this.doc.internal.pageSize.getHeight(); this.margin = { top: 20, right: 20, bottom: 20, left: 20 }; } /** * Generate a complete user report PDF */ generateUserReport(report: UserReport): jsPDF { this.addHeader(report); this.addUserInfo(report); this.addReportPeriod(report); this.addWeeklyCheckIns(report.weeklyCheckIns); this.addNutritionSummary(report.nutrition); this.addHydrationSummary(report.hydration); this.addGoalsSummary(report.goals); this.addProfileHistory(report.profileHistory); this.addRecommendations(report.recommendations); this.addFooter(); return this.doc; } /** * Save PDF to file */ save(filename: string): void { this.doc.save(filename); } /** * Get PDF as blob for API response */ toBlob(): Blob { return this.doc.output("blob"); } /** * Get PDF as base64 for API response */ toBase64(): string { return this.doc.output("datauristring").split(",")[1]; } /** * Add header with title and FitAI branding */ private addHeader(report: UserReport): void { this.currentY = this.margin.top; // Header background this.doc.setFillColor(...this.colors.primary); this.doc.rect(0, 0, this.pageWidth, 35, "F"); // Title this.doc.setTextColor(...this.colors.white); this.doc.setFontSize(24); this.doc.setFont("helvetica", "bold"); this.doc.text("FitAI User Report", this.margin.left, 20); // Subtitle this.doc.setFontSize(10); this.doc.setFont("helvetica", "normal"); this.doc.text( `Generated: ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit", })}`, this.margin.left, 28, ); // FitAI logo text this.doc.setFontSize(14); this.doc.setFont("helvetica", "bold"); this.doc.text("FitAI", this.pageWidth - this.margin.right - 20, 20); this.currentY = 45; } /** * Add user information section */ private addUserInfo(report: UserReport): void { this.checkPageBreak(30); this.doc.setTextColor(...this.colors.primary); this.doc.setFontSize(14); this.doc.setFont("helvetica", "bold"); this.doc.text("User Information", this.margin.left, this.currentY); this.currentY += 8; const user = report.user; const client = report.client; const profile = report.fitnessProfile; const userInfo = [ ["Name", `${user.firstName} ${user.lastName}`], ["Email", user.email], ["Phone", user.phone || "N/A"], ["Role", user.role], [ "Member Since", user.createdAt ? new Date(user.createdAt).toLocaleDateString() : "N/A", ], ]; if (client) { userInfo.push([ "Membership", `${client.membershipType} - ${client.membershipStatus}`, ]); userInfo.push([ "Join Date", new Date(client.joinDate).toLocaleDateString(), ]); } if (profile) { userInfo.push([ "Fitness Profile", `Height: ${profile.height || "N/A"}cm, Weight: ${profile.weight || "N/A"}kg`, ]); } autoTable(this.doc, { startY: this.currentY, body: userInfo, theme: "plain", margin: { left: this.margin.left, right: this.margin.right }, columnStyles: { 0: { fontStyle: "bold", cellWidth: 40, fontSize: 10 }, 1: { cellWidth: "auto", fontSize: 10 }, }, styles: { cellPadding: 3, textColor: this.colors.text, }, tableWidth: "auto" as any, }); this.currentY = (this.doc as any).lastAutoTable.finalY + 15; } /** * Add report period section */ private addReportPeriod(report: UserReport): void { this.checkPageBreak(25); this.doc.setTextColor(...this.colors.primary); this.doc.setFontSize(14); this.doc.setFont("helvetica", "bold"); this.doc.text("Report Period", this.margin.left, this.currentY); this.currentY += 8; const periodInfo = [ ["Start Date", report.reportPeriod.startDate], ["End Date", report.reportPeriod.endDate], [ "Duration", `${Math.ceil( (new Date(report.reportPeriod.endDate).getTime() - new Date(report.reportPeriod.startDate).getTime()) / (1000 * 60 * 60 * 24), )} days`, ], ]; autoTable(this.doc, { startY: this.currentY, body: periodInfo, theme: "plain", margin: { left: this.margin.left, right: this.margin.right }, columnStyles: { 0: { fontStyle: "bold", cellWidth: 40, fontSize: 10 }, 1: { cellWidth: "auto", fontSize: 10 }, }, styles: { cellPadding: 3, textColor: this.colors.text, }, tableWidth: "auto" as any, }); this.currentY = (this.doc as any).lastAutoTable.finalY + 15; } /** * Add weekly check-ins section */ private addWeeklyCheckIns( weeklyCheckIns: UserReport["weeklyCheckIns"], ): void { this.checkPageBreak(50); this.doc.setTextColor(...this.colors.primary); this.doc.setFontSize(14); this.doc.setFont("helvetica", "bold"); this.doc.text("Weekly Check-ins", this.margin.left, this.currentY); this.currentY += 8; if (weeklyCheckIns.length === 0) { this.doc.setTextColor(...this.colors.textLight); this.doc.setFontSize(10); this.doc.setFont("helvetica", "italic"); this.doc.text( "No check-in data available for this period.", this.margin.left, this.currentY, ); this.currentY += 15; return; } const tableData = weeklyCheckIns.map((week) => [ week.weekStart, week.weekEnd, week.totalCheckIns.toString(), `${week.totalTimeMinutes} min`, `${week.averageDurationMinutes} min`, ]); autoTable(this.doc, { startY: this.currentY, head: [ ["Week Start", "Week End", "Check-ins", "Total Time", "Avg Duration"], ], body: tableData, theme: "striped", headStyles: { fillColor: this.colors.primary, textColor: this.colors.white, fontStyle: "bold", fontSize: 9, }, bodyStyles: { fontSize: 9, textColor: this.colors.text, }, alternateRowStyles: { fillColor: [250, 250, 250], }, margin: { left: this.margin.left, right: this.margin.right }, }); this.currentY = (this.doc as any).lastAutoTable.finalY + 15; } /** * Add nutrition summary section */ private addNutritionSummary(nutrition: UserReport["nutrition"]): void { this.checkPageBreak(60); this.doc.setTextColor(...this.colors.primary); this.doc.setFontSize(14); this.doc.setFont("helvetica", "bold"); this.doc.text("Nutrition Summary", this.margin.left, this.currentY); this.currentY += 8; const summaryStats = [ ["Total Days Tracked", nutrition.totalDays.toString()], [ "Average Daily Calories", nutrition.averageDailyCalories.toLocaleString(), ], [ "Days Met Goal (±10%)", `${nutrition.daysMetGoal} / ${nutrition.totalDays}`, ], ]; autoTable(this.doc, { startY: this.currentY, body: summaryStats, theme: "plain", margin: { left: this.margin.left, right: this.margin.right }, columnStyles: { 0: { fontStyle: "bold", cellWidth: 60, fontSize: 10 }, 1: { cellWidth: "auto", fontSize: 10 }, }, styles: { cellPadding: 3, textColor: this.colors.text, }, tableWidth: "auto" as any, }); this.currentY = (this.doc as any).lastAutoTable.finalY + 10; // Add daily breakdown if available if ( nutrition.dailySummaries.length > 0 && nutrition.dailySummaries.length <= 14 ) { this.doc.setTextColor(...this.colors.textLight); this.doc.setFontSize(10); this.doc.setFont("helvetica", "italic"); this.doc.text("Daily Breakdown", this.margin.left, this.currentY); this.currentY += 5; const dailyData = nutrition.dailySummaries.map((day) => [ day.date, day.totalCalories.toLocaleString(), day.calorieGoal.toLocaleString(), day.caloriesDelta > 0 ? `+${day.caloriesDelta}` : day.caloriesDelta.toString(), day.mealsCount.toString(), ]); autoTable(this.doc, { startY: this.currentY, head: [["Date", "Calories", "Goal", "Delta", "Meals"]], body: dailyData, theme: "striped", headStyles: { fillColor: this.colors.primary, textColor: this.colors.white, fontStyle: "bold", fontSize: 8, }, bodyStyles: { fontSize: 8, textColor: this.colors.text, }, alternateRowStyles: { fillColor: [250, 250, 250], }, margin: { left: this.margin.left, right: this.margin.right }, }); this.currentY = (this.doc as any).lastAutoTable.finalY + 15; } else if (nutrition.dailySummaries.length > 14) { this.doc.setTextColor(...this.colors.textLight); this.doc.setFontSize(9); this.doc.text( `Daily breakdown (${nutrition.dailySummaries.length} days) - See dashboard for detailed view.`, this.margin.left, this.currentY, ); this.currentY += 15; } } /** * Add hydration summary section */ private addHydrationSummary(hydration: UserReport["hydration"]): void { this.checkPageBreak(50); this.doc.setTextColor(...this.colors.primary); this.doc.setFontSize(14); this.doc.setFont("helvetica", "bold"); this.doc.text("Hydration Summary", this.margin.left, this.currentY); this.currentY += 8; const summaryStats = [ ["Total Days Tracked", hydration.totalDays.toString()], ["Average Daily Water", `${hydration.averageDailyWater} ml`], ["Days Met Goal", `${hydration.daysMetGoal} / ${hydration.totalDays}`], ]; autoTable(this.doc, { startY: this.currentY, body: summaryStats, theme: "plain", margin: { left: this.margin.left, right: this.margin.right }, columnStyles: { 0: { fontStyle: "bold", cellWidth: 60, fontSize: 10 }, 1: { cellWidth: "auto", fontSize: 10 }, }, styles: { cellPadding: 3, textColor: this.colors.text, }, tableWidth: "auto" as any, }); this.currentY = (this.doc as any).lastAutoTable.finalY + 10; // Add daily breakdown if available if ( hydration.dailySummaries.length > 0 && hydration.dailySummaries.length <= 14 ) { this.doc.setTextColor(...this.colors.textLight); this.doc.setFontSize(10); this.doc.setFont("helvetica", "italic"); this.doc.text("Daily Breakdown", this.margin.left, this.currentY); this.currentY += 5; const dailyData = hydration.dailySummaries.map((day) => [ day.date, `${day.totalWater} ml`, `${day.waterGoal} ml`, `${day.hydrationPercentage}%`, ]); autoTable(this.doc, { startY: this.currentY, head: [["Date", "Total Water", "Goal", "Achievement"]], body: dailyData, theme: "striped", headStyles: { fillColor: this.colors.primary, textColor: this.colors.white, fontStyle: "bold", fontSize: 8, }, bodyStyles: { fontSize: 8, textColor: this.colors.text, }, alternateRowStyles: { fillColor: [250, 250, 250], }, margin: { left: this.margin.left, right: this.margin.right }, }); this.currentY = (this.doc as any).lastAutoTable.finalY + 15; } else if (hydration.dailySummaries.length > 14) { this.doc.setTextColor(...this.colors.textLight); this.doc.setFontSize(9); this.doc.text( `Daily breakdown (${hydration.dailySummaries.length} days) - See dashboard for detailed view.`, this.margin.left, this.currentY, ); this.currentY += 15; } } /** * Add goals summary section */ private addGoalsSummary(goals: UserReport["goals"]): void { this.checkPageBreak(60); this.doc.setTextColor(...this.colors.primary); this.doc.setFontSize(14); this.doc.setFont("helvetica", "bold"); this.doc.text("Fitness Goals", this.margin.left, this.currentY); this.currentY += 8; const summaryStats = [ ["Active Goals", goals.totalActive.toString()], ["Completed Goals", goals.totalCompleted.toString()], ["Average Progress", `${goals.averageProgress}%`], ]; autoTable(this.doc, { startY: this.currentY, body: summaryStats, theme: "plain", margin: { left: this.margin.left, right: this.margin.right }, columnStyles: { 0: { fontStyle: "bold", cellWidth: 60, fontSize: 10 }, 1: { cellWidth: "auto", fontSize: 10 }, }, styles: { cellPadding: 3, textColor: this.colors.text, }, tableWidth: "auto" as any, }); this.currentY = (this.doc as any).lastAutoTable.finalY + 10; // Add active goals if (goals.active.length > 0) { this.doc.setTextColor(...this.colors.success); this.doc.setFontSize(10); this.doc.setFont("helvetica", "bold"); this.doc.text("Active Goals", this.margin.left, this.currentY); this.currentY += 5; const activeGoals = goals.active.map((goal) => [ goal.title, goal.goalType, `${goal.progress}%`, goal.priority, ]); autoTable(this.doc, { startY: this.currentY, head: [["Title", "Type", "Progress", "Priority"]], body: activeGoals, theme: "striped", headStyles: { fillColor: this.colors.success, textColor: this.colors.white, fontStyle: "bold", fontSize: 8, }, bodyStyles: { fontSize: 8, textColor: this.colors.text, }, alternateRowStyles: { fillColor: [250, 250, 250], }, margin: { left: this.margin.left, right: this.margin.right }, }); this.currentY = (this.doc as any).lastAutoTable.finalY + 10; } // Add completed goals if (goals.completed.length > 0) { this.doc.setTextColor(...this.colors.primary); this.doc.setFontSize(10); this.doc.setFont("helvetica", "bold"); this.doc.text("Completed Goals", this.margin.left, this.currentY); this.currentY += 5; const completedGoals = goals.completed.map((goal) => [ goal.title, goal.goalType, goal.completedDate ? new Date(goal.completedDate).toLocaleDateString() : "N/A", ]); autoTable(this.doc, { startY: this.currentY, head: [["Title", "Type", "Completed Date"]], body: completedGoals, theme: "striped", headStyles: { fillColor: this.colors.primary, textColor: this.colors.white, fontStyle: "bold", fontSize: 8, }, bodyStyles: { fontSize: 8, textColor: this.colors.text, }, alternateRowStyles: { fillColor: [250, 250, 250], }, margin: { left: this.margin.left, right: this.margin.right }, }); this.currentY = (this.doc as any).lastAutoTable.finalY + 15; } } /** * Add profile history section */ private addProfileHistory(history: UserReport["profileHistory"]): void { this.checkPageBreak(50); this.doc.setTextColor(...this.colors.primary); this.doc.setFontSize(14); this.doc.setFont("helvetica", "bold"); this.doc.text("Fitness Profile Changes", this.margin.left, this.currentY); this.currentY += 8; if (history.length === 0) { this.doc.setTextColor(...this.colors.textLight); this.doc.setFontSize(10); this.doc.setFont("helvetica", "italic"); this.doc.text( "No profile changes recorded during this period.", this.margin.left, this.currentY, ); this.currentY += 15; return; } const historyData = history.map((item) => [ item.fieldName, item.changeType, item.previousValue || "N/A", item.newValue || "N/A", new Date(item.changedAt).toLocaleDateString(), ]); autoTable(this.doc, { startY: this.currentY, head: [["Field", "Change Type", "Previous", "New", "Date"]], body: historyData, theme: "striped", headStyles: { fillColor: this.colors.primary, textColor: this.colors.white, fontStyle: "bold", fontSize: 8, }, bodyStyles: { fontSize: 8, textColor: this.colors.text, }, alternateRowStyles: { fillColor: [250, 250, 250], }, margin: { left: this.margin.left, right: this.margin.right }, }); this.currentY = (this.doc as any).lastAutoTable.finalY + 15; } /** * Add recommendations section */ private addRecommendations( recommendations: UserReport["recommendations"], ): void { this.checkPageBreak(60); this.doc.setTextColor(...this.colors.primary); this.doc.setFontSize(14); this.doc.setFont("helvetica", "bold"); this.doc.text("AI Recommendations", this.margin.left, this.currentY); this.currentY += 8; const summaryStats = [ ["Accepted", recommendations.totalAccepted.toString()], ["Rejected", recommendations.totalRejected.toString()], ["Pending", recommendations.totalPending.toString()], ]; autoTable(this.doc, { startY: this.currentY, body: summaryStats, theme: "plain", margin: { left: this.margin.left, right: this.margin.right }, columnStyles: { 0: { fontStyle: "bold", cellWidth: 60, fontSize: 10 }, 1: { cellWidth: "auto", fontSize: 10 }, }, styles: { cellPadding: 3, textColor: this.colors.text, }, tableWidth: "auto" as any, }); this.currentY = (this.doc as any).lastAutoTable.finalY + 10; // Add accepted recommendations if (recommendations.accepted.length > 0) { this.doc.setTextColor(...this.colors.success); this.doc.setFontSize(10); this.doc.setFont("helvetica", "bold"); this.doc.text( "Accepted Recommendations", this.margin.left, this.currentY, ); this.currentY += 5; const acceptedRecs = recommendations.accepted.map((rec) => [ rec.recommendationText.substring(0, 80) + (rec.recommendationText.length > 80 ? "..." : ""), new Date(rec.generatedAt).toLocaleDateString(), ]); autoTable(this.doc, { startY: this.currentY, head: [["Recommendation", "Generated"]], body: acceptedRecs, theme: "striped", headStyles: { fillColor: this.colors.success, textColor: this.colors.white, fontStyle: "bold", fontSize: 8, }, bodyStyles: { fontSize: 8, textColor: this.colors.text, }, alternateRowStyles: { fillColor: [250, 250, 250], }, margin: { left: this.margin.left, right: this.margin.right }, }); this.currentY = (this.doc as any).lastAutoTable.finalY + 10; } // Add pending recommendations if (recommendations.pending.length > 0) { this.doc.setTextColor(...this.colors.warning); this.doc.setFontSize(10); this.doc.setFont("helvetica", "bold"); this.doc.text("Pending Recommendations", this.margin.left, this.currentY); this.currentY += 5; const pendingRecs = recommendations.pending.map((rec) => [ rec.recommendationText.substring(0, 80) + (rec.recommendationText.length > 80 ? "..." : ""), new Date(rec.generatedAt).toLocaleDateString(), ]); autoTable(this.doc, { startY: this.currentY, head: [["Recommendation", "Generated"]], body: pendingRecs, theme: "striped", headStyles: { fillColor: this.colors.warning, textColor: this.colors.text, fontStyle: "bold", fontSize: 8, }, bodyStyles: { fontSize: 8, textColor: this.colors.text, }, alternateRowStyles: { fillColor: [250, 250, 250], }, margin: { left: this.margin.left, right: this.margin.right }, }); this.currentY = (this.doc as any).lastAutoTable.finalY + 15; } } /** * Add footer to each page */ private addFooter(): void { const pageCount = this.doc.getNumberOfPages(); for (let i = 1; i <= pageCount; i++) { this.doc.setPage(i); // Footer line this.doc.setDrawColor(...this.colors.primary); this.doc.setLineWidth(0.5); this.doc.line( this.margin.left, this.pageHeight - 15, this.pageWidth - this.margin.right, this.pageHeight - 15, ); // Footer text this.doc.setTextColor(...this.colors.textLight); this.doc.setFontSize(8); this.doc.setFont("helvetica", "normal"); this.doc.text( `FitAI User Report - Page ${i} of ${pageCount}`, this.margin.left, this.pageHeight - 10, ); this.doc.text( "Generated by FitAI", this.pageWidth - this.margin.right - 30, this.pageHeight - 10, ); } } /** * Check if we need a page break */ private checkPageBreak(requiredSpace: number): void { if (this.currentY + requiredSpace > this.pageHeight - this.margin.bottom) { this.doc.addPage(); this.currentY = this.margin.top; } } } export default PDFGenerator;