fitaiProto/apps/admin/src/lib/pdf/pdf-generator.ts
2026-03-19 03:37:15 +01:00

822 lines
22 KiB
TypeScript

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;