435 lines
17 KiB
TypeScript
435 lines
17 KiB
TypeScript
import fs from 'fs';
|
|
import PDFDocument from 'pdfkit';
|
|
import QRCode from 'qrcode';
|
|
|
|
import type { BirdRow, FlockNoteRow, VetVisitRow, WeightRow } from '../types.js';
|
|
|
|
type AdoptionReportInput = {
|
|
bird: BirdRow;
|
|
weights: WeightRow[];
|
|
vetVisits: VetVisitRow[];
|
|
notes: FlockNoteRow[];
|
|
transferCode: string;
|
|
birdPhotoBuffer?: Buffer | null;
|
|
assets: {
|
|
logoPath: string;
|
|
wordmarkPath: string;
|
|
defaultBirdPhotoPath: string;
|
|
};
|
|
printFriendly?: boolean;
|
|
};
|
|
|
|
const page = { width: 612, height: 792, margin: 42 };
|
|
|
|
const colors = {
|
|
ink: '#1f2a2a',
|
|
muted: '#5d5f59',
|
|
red: '#cb3a35',
|
|
green: '#238a5a',
|
|
blue: '#2769b3',
|
|
border: '#cfe0d5',
|
|
panel: '#fbf7ee',
|
|
paper: '#fffdf9',
|
|
};
|
|
|
|
const formatDate = (value: string | null) => {
|
|
if (!value) {
|
|
return 'Not recorded';
|
|
}
|
|
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(
|
|
new Date(`${value.slice(0, 10)}T00:00:00Z`),
|
|
);
|
|
};
|
|
|
|
const formatDateTime = (value: string | null) => {
|
|
if (!value) {
|
|
return 'Not recorded';
|
|
}
|
|
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(value));
|
|
};
|
|
|
|
const formatShortDate = (value: string | null) => {
|
|
if (!value) {
|
|
return 'No data yet';
|
|
}
|
|
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }).format(new Date(`${value.slice(0, 10)}T00:00:00Z`));
|
|
};
|
|
|
|
const formatWeight = (value: string | number | null) => {
|
|
const numericValue = value === null ? null : Number(value);
|
|
return numericValue && Number.isFinite(numericValue) ? `${numericValue.toFixed(1)} g` : 'Pending';
|
|
};
|
|
|
|
const genderLabel = (value: string) => {
|
|
if (value === 'female') {
|
|
return 'Female';
|
|
}
|
|
if (value === 'male') {
|
|
return 'Male';
|
|
}
|
|
return 'Unknown';
|
|
};
|
|
|
|
const parseList = (value: string | null) =>
|
|
(value ?? '')
|
|
.split(/\r?\n|,/)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
|
|
const dataUrlToBuffer = (value: string | null) => {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
const match = value.match(/^data:image\/(?:png|jpeg|jpg);base64,(.+)$/);
|
|
return match ? Buffer.from(match[1], 'base64') : null;
|
|
};
|
|
|
|
const collectPdf = (doc: PDFKit.PDFDocument) =>
|
|
new Promise<Buffer>((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
|
doc.on('error', reject);
|
|
});
|
|
|
|
const fitText = (doc: PDFKit.PDFDocument, text: string, x: number, y: number, width: number, options: PDFKit.Mixins.TextOptions = {}) => {
|
|
doc.text(text, x, y, { width, lineGap: 1.5, ...options });
|
|
return doc.y;
|
|
};
|
|
|
|
const measureFactHeight = (doc: PDFKit.PDFDocument, value: string, width: number, minHeight = 43) => {
|
|
doc.font('Helvetica-Bold').fontSize(10);
|
|
const textHeight = doc.heightOfString(value, {
|
|
width: width - 16,
|
|
lineGap: 1,
|
|
});
|
|
return Math.max(minHeight, 27 + Math.min(textHeight, 38));
|
|
};
|
|
|
|
const drawFact = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number, height?: number) => {
|
|
const cardHeight = height ?? measureFactHeight(doc, value, width);
|
|
doc.roundedRect(x, y, width, cardHeight, 6).fillAndStroke(colors.panel, colors.border);
|
|
doc.fillColor(colors.muted).fontSize(7).font('Helvetica-Bold').text(label.toUpperCase(), x + 8, y + 8, { width: width - 16 });
|
|
doc.fillColor(colors.ink).fontSize(10).font('Helvetica-Bold').text(value, x + 8, y + 21, {
|
|
width: width - 16,
|
|
height: cardHeight - 27,
|
|
lineGap: 1,
|
|
ellipsis: true,
|
|
});
|
|
return cardHeight;
|
|
};
|
|
|
|
const drawTextCard = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number, height = 58) => {
|
|
doc.roundedRect(x, y, width, height, 6).fillAndStroke(colors.panel, colors.border);
|
|
doc.fillColor(colors.blue).fontSize(8).font('Helvetica-Bold').text(label.toUpperCase(), x + 8, y + 8, { width: width - 16 });
|
|
doc.fillColor(colors.ink).fontSize(9.2).font('Helvetica').text(value, x + 8, y + 23, {
|
|
width: width - 16,
|
|
height: height - 31,
|
|
ellipsis: true,
|
|
lineGap: 1.2,
|
|
});
|
|
};
|
|
|
|
const drawSectionTitle = (doc: PDFKit.PDFDocument, title: string, y: number) => {
|
|
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(14).text(title, page.margin, y);
|
|
doc.moveTo(page.margin, y + 19).lineTo(page.width - page.margin, y + 19).strokeColor(colors.border).lineWidth(1).stroke();
|
|
return y + 27;
|
|
};
|
|
|
|
const drawSimpleWeightChart = (doc: PDFKit.PDFDocument, weights: WeightRow[], birdColor: string, x: number, y: number, width: number, height: number) => {
|
|
const plottedWeights = weights
|
|
.slice()
|
|
.sort((left, right) => left.recorded_on.localeCompare(right.recorded_on))
|
|
.map((entry) => ({ ...entry, numericWeight: Number(entry.weight_grams) }))
|
|
.filter((entry) => Number.isFinite(entry.numericWeight));
|
|
|
|
doc.roundedRect(x, y, width, height, 8).fillAndStroke('#fffdf9', colors.border);
|
|
|
|
if (!plottedWeights.length) {
|
|
doc.fillColor(colors.muted).fontSize(10).text('Add more weight records to show a trend graph.', x + 14, y + height / 2 - 6, {
|
|
width: width - 28,
|
|
align: 'center',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const latestDate = new Date(`${plottedWeights[plottedWeights.length - 1].recorded_on.slice(0, 10)}T00:00:00Z`);
|
|
const earliestDate = new Date(`${plottedWeights[0].recorded_on.slice(0, 10)}T00:00:00Z`);
|
|
const startDate = new Date(latestDate);
|
|
startDate.setUTCDate(startDate.getUTCDate() - 13);
|
|
if (earliestDate > startDate) {
|
|
startDate.setTime(earliestDate.getTime());
|
|
}
|
|
const visibleWeights = plottedWeights.filter((entry) => {
|
|
const recordedOn = new Date(`${entry.recorded_on.slice(0, 10)}T00:00:00Z`);
|
|
return recordedOn >= startDate && recordedOn <= latestDate;
|
|
});
|
|
const rawMinWeight = Math.min(...visibleWeights.map((entry) => entry.numericWeight));
|
|
const rawMaxWeight = Math.max(...visibleWeights.map((entry) => entry.numericWeight));
|
|
const rangePadding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2);
|
|
const minWeight = Math.max(0, rawMinWeight - rangePadding);
|
|
const maxWeight = rawMaxWeight + rangePadding;
|
|
const weightRange = Math.max(1, maxWeight - minWeight);
|
|
const padding = { top: 16, right: 18, bottom: 32, left: 48 };
|
|
const plotWidth = width - padding.left - padding.right;
|
|
const plotHeight = height - padding.top - padding.bottom;
|
|
const startMs = startDate.getTime();
|
|
const endMs = latestDate.getTime();
|
|
const dateRange = Math.max(endMs - startMs, 24 * 60 * 60 * 1000);
|
|
const chartColor = /^#[0-9a-fA-F]{6}$/.test(birdColor) ? birdColor : colors.green;
|
|
const midWeight = minWeight + (maxWeight - minWeight) / 2;
|
|
const midDate = new Date((startMs + endMs) / 2);
|
|
const yTicks = [
|
|
{ label: `${maxWeight.toFixed(0)} g`, y: y + padding.top },
|
|
{ label: `${midWeight.toFixed(0)} g`, y: y + padding.top + plotHeight / 2 },
|
|
{ label: `${minWeight.toFixed(0)} g`, y: y + padding.top + plotHeight },
|
|
];
|
|
const xTicks = [
|
|
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: x + padding.left },
|
|
{ label: formatShortDate(midDate.toISOString().slice(0, 10)), x: x + padding.left + plotWidth / 2 },
|
|
{ label: formatShortDate(latestDate.toISOString().slice(0, 10)), x: x + padding.left + plotWidth },
|
|
];
|
|
|
|
const points = visibleWeights.map((entry) => {
|
|
const recordedOn = new Date(`${entry.recorded_on.slice(0, 10)}T00:00:00Z`);
|
|
return {
|
|
...entry,
|
|
x: x + padding.left + ((recordedOn.getTime() - startMs) / dateRange) * plotWidth,
|
|
y: y + padding.top + (1 - (entry.numericWeight - minWeight) / weightRange) * plotHeight,
|
|
};
|
|
});
|
|
|
|
doc.font('Helvetica').fontSize(7).fillColor(colors.muted);
|
|
yTicks.forEach((tick) => {
|
|
doc.text(tick.label, x + 4, tick.y - 3, { width: padding.left - 12, align: 'right' });
|
|
doc
|
|
.save()
|
|
.dash(4, { space: 6 })
|
|
.strokeColor('#d8e5ef')
|
|
.lineWidth(0.8)
|
|
.moveTo(x + padding.left, tick.y)
|
|
.lineTo(x + width - padding.right, tick.y)
|
|
.stroke()
|
|
.restore();
|
|
});
|
|
doc.strokeColor('#c7cdca').lineWidth(1).moveTo(x + padding.left, y + padding.top + plotHeight).lineTo(x + width - padding.right, y + padding.top + plotHeight).stroke();
|
|
xTicks.forEach((tick) => {
|
|
doc.fillColor(colors.muted).fontSize(7).text(tick.label, tick.x - 28, y + height - 18, { width: 56, align: 'center' });
|
|
});
|
|
|
|
points.forEach((entry, index) => {
|
|
if (index === 0) {
|
|
doc.moveTo(entry.x, entry.y);
|
|
} else {
|
|
doc.lineTo(entry.x, entry.y);
|
|
}
|
|
});
|
|
if (points.length > 1) {
|
|
doc.lineCap('round').strokeColor(chartColor).lineWidth(2.4).stroke();
|
|
}
|
|
|
|
points.forEach((entry) => {
|
|
doc.circle(entry.x, entry.y, 3.5).fillAndStroke(chartColor, '#fffdf9');
|
|
});
|
|
|
|
const latestPoint = points[points.length - 1];
|
|
const calloutOnLeft = latestPoint.x > x + width - padding.right - 84;
|
|
const calloutX = calloutOnLeft ? latestPoint.x - 82 : latestPoint.x + 8;
|
|
const calloutY = latestPoint.y < y + padding.top + 18 ? latestPoint.y + 8 : latestPoint.y - 22;
|
|
doc.roundedRect(calloutX, calloutY, 74, 18, 5).fillAndStroke('#fffdf9', '#d9dedb');
|
|
doc.fillColor(colors.ink).font('Helvetica-Bold').fontSize(7.5).text(`Latest ${formatWeight(latestPoint.numericWeight)}`, calloutX + 5, calloutY + 5, {
|
|
width: 64,
|
|
align: 'center',
|
|
});
|
|
};
|
|
|
|
const drawTable = (doc: PDFKit.PDFDocument, headers: string[], rows: string[][], x: number, y: number, widths: number[], rowHeight = 28) => {
|
|
doc.font('Helvetica-Bold').fontSize(8).fillColor(colors.muted);
|
|
headers.forEach((header, index) => {
|
|
doc.text(header.toUpperCase(), x + widths.slice(0, index).reduce((sum, value) => sum + value, 0), y, { width: widths[index] - 8 });
|
|
});
|
|
y += 15;
|
|
doc.moveTo(x, y - 4).lineTo(x + widths.reduce((sum, value) => sum + value, 0), y - 4).strokeColor(colors.border).stroke();
|
|
|
|
doc.font('Helvetica').fontSize(8.5).fillColor(colors.ink);
|
|
rows.forEach((row) => {
|
|
if (y + rowHeight > page.height - page.margin) {
|
|
doc.addPage();
|
|
y = page.margin;
|
|
}
|
|
row.forEach((value, index) => {
|
|
doc.text(value, x + widths.slice(0, index).reduce((sum, columnWidth) => sum + columnWidth, 0), y, {
|
|
width: widths[index] - 8,
|
|
height: rowHeight - 6,
|
|
ellipsis: true,
|
|
});
|
|
});
|
|
y += rowHeight;
|
|
doc.moveTo(x, y - 4).lineTo(x + widths.reduce((sum, value) => sum + value, 0), y - 4).strokeColor(colors.border).stroke();
|
|
});
|
|
|
|
return y + 6;
|
|
};
|
|
|
|
export const renderAdoptionReportPdf = async ({
|
|
bird,
|
|
weights,
|
|
vetVisits,
|
|
notes,
|
|
transferCode,
|
|
birdPhotoBuffer = null,
|
|
assets,
|
|
printFriendly = false,
|
|
}: AdoptionReportInput) => {
|
|
const doc = new PDFDocument({
|
|
size: 'LETTER',
|
|
margin: page.margin,
|
|
info: { Title: `FlockPal Adoption Report - ${bird.name}`, Author: 'FlockPal', Subject: `Adoption report for ${bird.name}` },
|
|
});
|
|
const output = collectPdf(doc);
|
|
|
|
if (!printFriendly) {
|
|
doc.rect(0, 0, page.width, page.height).fill(colors.paper);
|
|
}
|
|
|
|
const logoPath = fs.existsSync(assets.logoPath) ? assets.logoPath : null;
|
|
const wordmarkPath = fs.existsSync(assets.wordmarkPath) ? assets.wordmarkPath : logoPath;
|
|
const defaultPhotoPath = fs.existsSync(assets.defaultBirdPhotoPath) ? assets.defaultBirdPhotoPath : null;
|
|
const photoBuffer = birdPhotoBuffer ?? dataUrlToBuffer(bird.photo_data_url);
|
|
const contentWidth = page.width - page.margin * 2;
|
|
const headerY = page.margin;
|
|
const headerHeight = 136;
|
|
|
|
doc.roundedRect(page.margin, headerY, contentWidth, headerHeight, 12).fillAndStroke(printFriendly ? '#ffffff' : '#f8f4e8', colors.border);
|
|
if (logoPath) {
|
|
doc.image(logoPath, page.margin + 10, headerY + 18, { fit: [92, 84], align: 'center', valign: 'center' });
|
|
}
|
|
|
|
const photoX = page.margin + 235;
|
|
const photoY = headerY + 13;
|
|
if (photoBuffer) {
|
|
doc.image(photoBuffer, photoX, photoY, { fit: [58, 58], align: 'center', valign: 'center' });
|
|
} else if (defaultPhotoPath) {
|
|
doc.image(defaultPhotoPath, photoX, photoY, { fit: [58, 58], align: 'center', valign: 'center' });
|
|
}
|
|
doc.roundedRect(photoX, photoY, 58, 58, 10).strokeColor('#ffffff').lineWidth(2).stroke();
|
|
doc.fillColor(colors.red).font('Helvetica-Bold').fontSize(22).text(bird.name, page.margin + 140, headerY + 75, { width: 250, align: 'center' });
|
|
doc.fillColor(colors.muted).font('Helvetica').fontSize(9).text('Adoption Report', page.margin + 140, headerY + 98, { width: 250, align: 'center' });
|
|
|
|
const qrDataUrl = await QRCode.toDataURL(transferCode, { margin: 1, width: 96, errorCorrectionLevel: 'H' });
|
|
const qrBuffer = dataUrlToBuffer(qrDataUrl);
|
|
const qrX = page.width - page.margin - 132;
|
|
const qrWidth = 124;
|
|
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(8).text('JOIN', qrX, headerY + 7, { width: qrWidth, align: 'center' });
|
|
if (wordmarkPath) {
|
|
doc.image(wordmarkPath, qrX + 7, headerY + 18, { fit: [110, 34], align: 'center', valign: 'center' });
|
|
}
|
|
doc.fillColor(colors.red).font('Helvetica-Bold').fontSize(7.5).text('Keep my story growing', qrX, headerY + 51, {
|
|
width: qrWidth,
|
|
align: 'center',
|
|
});
|
|
if (qrBuffer) {
|
|
doc.image(qrBuffer, qrX + 37, headerY + 62, { width: 50 });
|
|
}
|
|
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(6.8).text('Scan to continue tracking in FlockPal', qrX, headerY + 114, {
|
|
width: qrWidth,
|
|
align: 'center',
|
|
});
|
|
doc.fillColor(colors.ink).font('Helvetica').fontSize(6.5).text(transferCode, qrX, headerY + 126, { width: qrWidth, align: 'center' });
|
|
|
|
let y = headerY + headerHeight + 16;
|
|
const factGap = 8;
|
|
const factWidth = (contentWidth - factGap) / 2;
|
|
const facts = [
|
|
['Species', bird.species],
|
|
['Band/tag ID', bird.tag_id || 'Not recorded'],
|
|
['Sex', genderLabel(bird.gender)],
|
|
['Hatch day', formatDate(bird.date_of_birth)],
|
|
['Favorite snack', bird.favorite_snack || 'Not recorded'],
|
|
['Latest weight', bird.latest_weight_grams ? `${formatWeight(bird.latest_weight_grams)}${bird.latest_recorded_on ? ` on ${formatDate(bird.latest_recorded_on)}` : ''}` : 'Pending'],
|
|
];
|
|
facts.forEach(([label, value], index) => {
|
|
drawFact(doc, label, value, page.margin + (index % 2) * (factWidth + factGap), y + Math.floor(index / 2) * 50, factWidth);
|
|
});
|
|
y += Math.ceil(facts.length / 2) * 50 + 8;
|
|
|
|
const motivators = parseList(bird.motivators);
|
|
const demotivators = parseList(bird.demotivators);
|
|
drawTextCard(doc, 'Motivators', motivators.length ? motivators.join(', ') : 'Not recorded', page.margin, y, factWidth);
|
|
drawTextCard(
|
|
doc,
|
|
'Demotivators',
|
|
demotivators.length ? demotivators.join(', ') : 'Not recorded',
|
|
page.margin + factWidth + factGap,
|
|
y,
|
|
factWidth,
|
|
);
|
|
y += 72;
|
|
|
|
if (y > 610) {
|
|
doc.addPage();
|
|
y = page.margin;
|
|
}
|
|
y = drawSectionTitle(doc, 'Veterinary Clinic Info', y);
|
|
drawFact(doc, 'Clinic name', bird.vet_clinic_name || 'Not recorded', page.margin, y, factWidth);
|
|
drawFact(doc, 'Account #', bird.vet_account_number || 'Not recorded', page.margin + factWidth + factGap, y, factWidth);
|
|
y += 50;
|
|
const clinicAddressHeight = measureFactHeight(doc, bird.vet_clinic_address || 'Not recorded', contentWidth, 58);
|
|
drawFact(doc, 'Clinic address', bird.vet_clinic_address || 'Not recorded', page.margin, y, contentWidth, clinicAddressHeight);
|
|
y += clinicAddressHeight + 7;
|
|
drawFact(doc, 'Dr. name', bird.vet_doctor_name || 'Not recorded', page.margin, y, factWidth);
|
|
y += 50;
|
|
|
|
y = drawSectionTitle(doc, 'Vet Visit History', y);
|
|
y = drawTable(
|
|
doc,
|
|
['Date', 'Clinic', 'Reason', 'Notes'],
|
|
vetVisits.length ? vetVisits.map((visit) => [formatDate(visit.visited_on), visit.clinic_name, visit.reason, visit.notes || '']) : [['No vet visits recorded.', '', '', '']],
|
|
page.margin,
|
|
y,
|
|
[70, 115, 120, contentWidth - 305],
|
|
28,
|
|
);
|
|
|
|
if (y > 575) {
|
|
doc.addPage();
|
|
y = page.margin;
|
|
}
|
|
y = drawSectionTitle(doc, 'Weight Graph', y);
|
|
drawSimpleWeightChart(doc, weights, bird.chart_color, page.margin, y, contentWidth, 120);
|
|
y += 140;
|
|
|
|
y = drawSectionTitle(doc, 'Weight History', y);
|
|
y = drawTable(
|
|
doc,
|
|
['Date', 'Weight', 'Notes'],
|
|
weights.length ? weights.map((entry) => [formatDate(entry.recorded_on), formatWeight(entry.weight_grams), entry.notes || '']) : [['No weights recorded.', '', '']],
|
|
page.margin,
|
|
y,
|
|
[95, 70, contentWidth - 165],
|
|
24,
|
|
);
|
|
|
|
if (notes.length) {
|
|
if (y > 635) {
|
|
doc.addPage();
|
|
y = page.margin;
|
|
}
|
|
y = drawSectionTitle(doc, 'Notes', y);
|
|
notes.slice(0, 8).forEach((note) => {
|
|
if (y > page.height - page.margin - 48) {
|
|
doc.addPage();
|
|
y = page.margin;
|
|
}
|
|
doc.fillColor(colors.muted).font('Helvetica-Bold').fontSize(8).text(formatDateTime(note.updated_at), page.margin, y);
|
|
y = fitText(doc, note.body, page.margin, y + 12, contentWidth, { height: 44, ellipsis: true });
|
|
y += 8;
|
|
doc.moveTo(page.margin, y).lineTo(page.width - page.margin, y).strokeColor(colors.border).stroke();
|
|
y += 8;
|
|
});
|
|
}
|
|
|
|
doc.end();
|
|
return output;
|
|
};
|