diff --git a/backend/assets/flockpal-text.png b/backend/assets/flockpal-text.png index 3a42159..15920f7 100644 Binary files a/backend/assets/flockpal-text.png and b/backend/assets/flockpal-text.png differ diff --git a/backend/src/reports/adoptionReport.ts b/backend/src/reports/adoptionReport.ts index ac4d69b..4786a09 100644 --- a/backend/src/reports/adoptionReport.ts +++ b/backend/src/reports/adoptionReport.ts @@ -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);