Fix adoption report QR and chart layout
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 234 KiB |
@@ -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));
|
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 formatWeight = (value: string | number | null) => {
|
||||||
const numericValue = value === null ? null : Number(value);
|
const numericValue = value === null ? null : Number(value);
|
||||||
return numericValue && Number.isFinite(numericValue) ? `${numericValue.toFixed(1)} g` : 'Pending';
|
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) }))
|
.map((entry) => ({ ...entry, numericWeight: Number(entry.weight_grams) }))
|
||||||
.filter((entry) => Number.isFinite(entry.numericWeight));
|
.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, {
|
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,
|
width: width - 28,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
@@ -130,35 +137,89 @@ const drawSimpleWeightChart = (doc: PDFKit.PDFDocument, weights: WeightRow[], bi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const minWeight = Math.min(...plottedWeights.map((entry) => entry.numericWeight));
|
const latestDate = new Date(`${plottedWeights[plottedWeights.length - 1].recorded_on.slice(0, 10)}T00:00:00Z`);
|
||||||
const maxWeight = Math.max(...plottedWeights.map((entry) => entry.numericWeight));
|
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 weightRange = Math.max(1, maxWeight - minWeight);
|
||||||
const padding = 24;
|
const padding = { top: 16, right: 18, bottom: 32, left: 48 };
|
||||||
const plotWidth = width - padding * 2;
|
const plotWidth = width - padding.left - padding.right;
|
||||||
const plotHeight = height - padding * 2;
|
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 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);
|
const points = visibleWeights.map((entry) => {
|
||||||
for (let index = 0; index < 4; index += 1) {
|
const recordedOn = new Date(`${entry.recorded_on.slice(0, 10)}T00:00:00Z`);
|
||||||
const gridY = y + padding + (plotHeight / 3) * index;
|
return {
|
||||||
doc.moveTo(x + padding, gridY).lineTo(x + width - padding, gridY).stroke();
|
...entry,
|
||||||
}
|
x: x + padding.left + ((recordedOn.getTime() - startMs) / dateRange) * plotWidth,
|
||||||
|
y: y + padding.top + (1 - (entry.numericWeight - minWeight) / weightRange) * plotHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
plottedWeights.forEach((entry, index) => {
|
doc.font('Helvetica').fontSize(7).fillColor(colors.muted);
|
||||||
const pointX = x + padding + (plotWidth / Math.max(1, plottedWeights.length - 1)) * index;
|
yTicks.forEach((tick) => {
|
||||||
const pointY = y + padding + plotHeight - ((entry.numericWeight - minWeight) / weightRange) * plotHeight;
|
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) {
|
if (index === 0) {
|
||||||
doc.moveTo(pointX, pointY);
|
doc.moveTo(entry.x, entry.y);
|
||||||
} else {
|
} 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) => {
|
points.forEach((entry) => {
|
||||||
const pointX = x + padding + (plotWidth / Math.max(1, plottedWeights.length - 1)) * index;
|
doc.circle(entry.x, entry.y, 3.5).fillAndStroke(chartColor, '#fffdf9');
|
||||||
const pointY = y + padding + plotHeight - ((entry.numericWeight - minWeight) / weightRange) * plotHeight;
|
});
|
||||||
doc.circle(pointX, pointY, 2.6).fillAndStroke(chartColor, '#ffffff');
|
|
||||||
|
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 photoBuffer = dataUrlToBuffer(bird.photo_data_url);
|
||||||
const contentWidth = page.width - page.margin * 2;
|
const contentWidth = page.width - page.margin * 2;
|
||||||
const headerY = page.margin;
|
const headerY = page.margin;
|
||||||
const headerHeight = 120;
|
const headerHeight = 136;
|
||||||
|
|
||||||
doc.roundedRect(page.margin, headerY, contentWidth, headerHeight, 12).fillAndStroke(printFriendly ? '#ffffff' : '#f8f4e8', colors.border);
|
doc.roundedRect(page.margin, headerY, contentWidth, headerHeight, 12).fillAndStroke(printFriendly ? '#ffffff' : '#f8f4e8', colors.border);
|
||||||
if (logoPath) {
|
if (logoPath) {
|
||||||
@@ -237,23 +298,24 @@ export const renderAdoptionReportPdf = async ({
|
|||||||
|
|
||||||
const qrDataUrl = await QRCode.toDataURL(transferCode, { margin: 1, width: 96, errorCorrectionLevel: 'H' });
|
const qrDataUrl = await QRCode.toDataURL(transferCode, { margin: 1, width: 96, errorCorrectionLevel: 'H' });
|
||||||
const qrBuffer = dataUrlToBuffer(qrDataUrl);
|
const qrBuffer = dataUrlToBuffer(qrDataUrl);
|
||||||
const qrX = page.width - page.margin - 110;
|
const qrX = page.width - page.margin - 132;
|
||||||
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(8).text('JOIN', qrX - 8, headerY + 5, { width: 112, align: 'center' });
|
const qrWidth = 124;
|
||||||
|
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(8).text('JOIN', qrX, headerY + 7, { width: qrWidth, align: 'center' });
|
||||||
if (wordmarkPath) {
|
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, {
|
doc.fillColor(colors.red).font('Helvetica-Bold').fontSize(7.5).text('Keep my story growing', qrX, headerY + 51, {
|
||||||
width: 120,
|
width: qrWidth,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
});
|
});
|
||||||
if (qrBuffer) {
|
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, {
|
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(6.8).text('Scan to continue tracking in FlockPal', qrX, headerY + 114, {
|
||||||
width: 120,
|
width: qrWidth,
|
||||||
align: 'center',
|
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;
|
let y = headerY + headerHeight + 16;
|
||||||
const factGap = 8;
|
const factGap = 8;
|
||||||
@@ -265,7 +327,6 @@ export const renderAdoptionReportPdf = async ({
|
|||||||
['Hatch day', formatDate(bird.date_of_birth)],
|
['Hatch day', formatDate(bird.date_of_birth)],
|
||||||
['Favorite snack', bird.favorite_snack || 'Not recorded'],
|
['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'],
|
['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) => {
|
facts.forEach(([label, value], index) => {
|
||||||
drawFact(doc, label, value, page.margin + (index % 2) * (factWidth + factGap), y + Math.floor(index / 2) * 50, factWidth);
|
drawFact(doc, label, value, page.margin + (index % 2) * (factWidth + factGap), y + Math.floor(index / 2) * 50, factWidth);
|
||||||
|
|||||||
Reference in New Issue
Block a user