Improve flight-finder report links and cron workflow

This commit is contained in:
2026-03-30 18:26:09 -05:00
parent fb868b9e5f
commit 809a3955e5
5 changed files with 224 additions and 15 deletions

View File

@@ -83,6 +83,50 @@ function drawBulletList(doc: PDFKit.PDFDocument, items: string[] | undefined, fa
doc.moveDown(0.2);
}
type QuoteBookingLink = {
label: string;
text: string;
url: string | null;
};
export function describeQuoteBookingLinks(quote: FlightQuote): QuoteBookingLink[] {
return [
{
label: "Search / book this fare",
text: `Search / book this fare: ${quote.displayPriceUsd} via ${quote.source}`,
url: quote.bookingLink
},
{
label: "Direct airline booking",
text: quote.directBookingUrl
? `Direct airline booking: ${quote.airlineName || "Airline site"}`
: "Direct airline booking: not captured in this run",
url: quote.directBookingUrl || null
}
];
}
function drawQuoteBookingLinks(doc: PDFKit.PDFDocument, links: QuoteBookingLink[], left: number, width: number): void {
for (const linkItem of links) {
const startY = doc.y;
doc
.fillColor("#1E2329")
.font("Helvetica-Bold")
.fontSize(9.5)
.text(`${linkItem.label}:`, left, startY, { width: 130 });
doc
.fillColor(linkItem.url ? "#0B5EA8" : "#4F5B66")
.font("Helvetica")
.fontSize(9.5)
.text(linkItem.text.replace(`${linkItem.label}: `, ""), left + 132, startY, {
width: width - 132,
lineGap: 1,
...(linkItem.url ? { link: linkItem.url, underline: true } : {})
});
doc.moveDown(0.25);
}
}
function drawKeyValueTable(doc: PDFKit.PDFDocument, rows: Array<[string, string]>): void {
const left = doc.page.margins.left;
const totalWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
@@ -155,26 +199,57 @@ function drawItineraryCard(
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const top = doc.y;
const badge = option.backup ? "Backup" : "Primary";
const quoteLines = option.quoteIds.flatMap((quoteId) => {
const quoteBlocks = option.quoteIds.flatMap((quoteId) => {
const quote = quotesById.get(quoteId);
if (!quote) return [`Missing quote reference: ${quoteId}`];
if (!quote) {
return [
{
heading: `Missing quote reference: ${quoteId}`,
details: [],
links: []
}
];
}
const passengerLine = quote.passengerGroupIds
.map((groupId) => passengerLabels.get(groupId) || groupId)
.join(", ");
const legLine = legLabels.get(quote.legId) || quote.legId;
return [
`${legLine}: ${quote.itinerarySummary} (${quote.displayPriceUsd})`,
`Passengers: ${passengerLine}`,
`Timing: ${quote.departureTimeLocal || "?"} -> ${quote.arrivalTimeLocal || "?"}; ${quote.stopsText || "stops?"}; ${quote.totalDurationText || "duration?"}`,
...(quote.notes || []).map((note) => `Note: ${note}`)
{
heading: `${legLine}: ${quote.itinerarySummary} (${quote.displayPriceUsd})`,
details: [
`Passengers: ${passengerLine}`,
`Timing: ${quote.departureTimeLocal || "?"} -> ${quote.arrivalTimeLocal || "?"}`,
`Routing: ${quote.stopsText || "stops?"}; ${quote.layoverText || "layover?"}; ${quote.totalDurationText || "duration?"}`,
...(quote.notes || []).map((note) => `Note: ${note}`)
],
links: describeQuoteBookingLinks(quote)
}
];
});
const bodyLines = [
`Why it ranked here: ${option.rationale}`,
...quoteLines
];
const bodyText = bodyLines.join("\n");
const bodyHeight = doc.heightOfString(bodyText, { width: width - 20, lineGap: 2 }) + 24;
const estimateHeight = quoteBlocks.reduce((sum, block) => {
const headingHeight = doc.heightOfString(block.heading, { width: width - 20, lineGap: 2 });
const detailsHeight = block.details.reduce(
(acc, line) => acc + doc.heightOfString(line, { width: width - 44, lineGap: 2 }) + 4,
0
);
const linksHeight = block.links.reduce(
(acc, link) =>
acc +
Math.max(
doc.heightOfString(`${link.label}:`, { width: 130 }),
doc.heightOfString(link.text.replace(`${link.label}: `, ""), { width: width - 152, lineGap: 1 })
) +
4,
0
);
return sum + headingHeight + detailsHeight + linksHeight + 18;
}, 0);
const rationaleHeight = doc.heightOfString(`Why it ranked here: ${option.rationale}`, {
width: width - 20,
lineGap: 2
});
const bodyHeight = rationaleHeight + estimateHeight + 24;
doc.save();
doc.roundedRect(left, top, width, 24, 4).fill(option.backup ? "#586E75" : "#1E6B52");
@@ -192,10 +267,37 @@ function drawItineraryCard(
.fillColor("#1E2329")
.font("Helvetica")
.fontSize(10)
.text(bodyText, left + 10, top + 34, {
.text(`Why it ranked here: ${option.rationale}`, left + 10, top + 34, {
width: width - 20,
lineGap: 2
});
doc.y = top + 34 + rationaleHeight + 10;
for (const block of quoteBlocks) {
doc
.fillColor("#10384E")
.font("Helvetica-Bold")
.fontSize(10)
.text(block.heading, left + 10, doc.y, {
width: width - 20,
lineGap: 2
});
doc.moveDown(0.2);
for (const line of block.details) {
const startY = doc.y;
doc.circle(left + 16, startY + 6, 1.4).fill("#10384E");
doc
.fillColor("#1E2329")
.font("Helvetica")
.fontSize(9.5)
.text(line, left + 24, startY, {
width: width - 44,
lineGap: 2
});
doc.moveDown(0.2);
}
drawQuoteBookingLinks(doc, block.links, left + 10, width - 20);
doc.moveDown(0.35);
}
doc.restore();
doc.y = top + 24 + bodyHeight + 10;
}