Fix adoption report QR and chart layout

This commit is contained in:
Corey Blais
2026-06-02 18:19:01 -04:00
parent 60eadf0847
commit 5b57cdd6bf
2 changed files with 94 additions and 33 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 234 KiB

+94 -33
View File
@@ -48,6 +48,13 @@ const formatDateTime = (value: string | null) => {
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';
@@ -120,9 +127,9 @@ const drawSimpleWeightChart = (doc: PDFKit.PDFDocument, weights: WeightRow[], bi
.map((entry) => ({ ...entry, numericWeight: Number(entry.weight_grams) }))
.filter((entry) => Number.isFinite(entry.numericWeight));
doc.roundedRect(x, y, width, height, 8).fillAndStroke('#ffffff', colors.border);
doc.roundedRect(x, y, width, height, 8).fillAndStroke('#fffdf9', colors.border);
if (plottedWeights.length < 2) {
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',
@@ -130,35 +137,89 @@ const drawSimpleWeightChart = (doc: PDFKit.PDFDocument, weights: WeightRow[], bi
return;
}
const minWeight = Math.min(...plottedWeights.map((entry) => entry.numericWeight));
const maxWeight = Math.max(...plottedWeights.map((entry) => entry.numericWeight));
const latestDate = new Date(`${plottedWeights[plottedWeights.length - 1].recorded_on.slice(0, 10)}T00:00:00Z`);
const startDate = new Date(latestDate);
startDate.setUTCDate(startDate.getUTCDate() - 29);
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 = 24;
const plotWidth = width - padding * 2;
const plotHeight = height - padding * 2;
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 },
];
doc.strokeColor('#d9e6dc').lineWidth(0.8);
for (let index = 0; index < 4; index += 1) {
const gridY = y + padding + (plotHeight / 3) * index;
doc.moveTo(x + padding, gridY).lineTo(x + width - padding, gridY).stroke();
}
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,
};
});
plottedWeights.forEach((entry, index) => {
const pointX = x + padding + (plotWidth / Math.max(1, plottedWeights.length - 1)) * index;
const pointY = y + padding + plotHeight - ((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(pointX, pointY);
doc.moveTo(entry.x, entry.y);
} else {
doc.lineTo(pointX, pointY);
doc.lineTo(entry.x, entry.y);
}
});
doc.strokeColor(chartColor).lineWidth(2).stroke();
if (points.length > 1) {
doc.lineCap('round').strokeColor(chartColor).lineWidth(2.4).stroke();
}
plottedWeights.forEach((entry, index) => {
const pointX = x + padding + (plotWidth / Math.max(1, plottedWeights.length - 1)) * index;
const pointY = y + padding + plotHeight - ((entry.numericWeight - minWeight) / weightRange) * plotHeight;
doc.circle(pointX, pointY, 2.6).fillAndStroke(chartColor, '#ffffff');
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',
});
};
@@ -217,7 +278,7 @@ export const renderAdoptionReportPdf = async ({
const photoBuffer = dataUrlToBuffer(bird.photo_data_url);
const contentWidth = page.width - page.margin * 2;
const headerY = page.margin;
const headerHeight = 120;
const headerHeight = 136;
doc.roundedRect(page.margin, headerY, contentWidth, headerHeight, 12).fillAndStroke(printFriendly ? '#ffffff' : '#f8f4e8', colors.border);
if (logoPath) {
@@ -237,23 +298,24 @@ export const renderAdoptionReportPdf = async ({
const qrDataUrl = await QRCode.toDataURL(transferCode, { margin: 1, width: 96, errorCorrectionLevel: 'H' });
const qrBuffer = dataUrlToBuffer(qrDataUrl);
const qrX = page.width - page.margin - 110;
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(8).text('JOIN', qrX - 8, headerY + 5, { width: 112, align: 'center' });
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 - 20, headerY + 7, { fit: [136, 52], align: 'center', valign: 'center' });
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 - 12, headerY + 40, {
width: 120,
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 + 18, headerY + 51, { width: 58 });
doc.image(qrBuffer, qrX + 37, headerY + 62, { width: 50 });
}
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(7).text('Scan to continue tracking in FlockPal', qrX - 12, headerY + 110, {
width: 120,
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 - 8, headerY + 119, { width: 112, 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;
@@ -265,7 +327,6 @@ export const renderAdoptionReportPdf = async ({
['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'],
['Source flock', workspace.name],
];
facts.forEach(([label, value], index) => {
drawFact(doc, label, value, page.margin + (index % 2) * (factWidth + factGap), y + Math.floor(index / 2) * 50, factWidth);