feat: expand price automation contract

This commit is contained in:
TopherMayor
2026-04-30 11:53:18 -07:00
parent 4ad51ed2c6
commit a3b8e9a4b0
5 changed files with 316 additions and 58 deletions

158
server.js
View File

@@ -67,6 +67,40 @@ function normalizeSourceLabel(value) {
return String(value || 'Unknown source').trim() || 'Unknown source';
}
function normalizeBookingType(value) {
const normalized = normalizeKey(value || '');
if (['package', 'standalone', 'calculated'].includes(normalized)) return normalized;
if (normalized.includes('package') || normalized.includes('bundle')) return 'package';
if (normalized.includes('calculated') || normalized.includes('derived')) return 'calculated';
return 'standalone';
}
function inferBookingType(point, defaults = {}) {
if (point.bookingType || point.booking_type || point.productType || defaults.bookingType) {
return point.bookingType || point.booking_type || point.productType || defaults.bookingType;
}
const haystack = [
point.source,
point.sourceLabel,
point.vendor,
point.displayPrice,
point.displayLabel,
point.priceLabel,
point.label,
].filter(Boolean).join(' ').toLowerCase();
if (haystack.includes('costco') || haystack.includes('apple vacation') || haystack.includes('cheapcaribbean')) {
return 'package';
}
if (haystack.includes('package') || haystack.includes('flight+hotel') || haystack.includes('flight + hotel')) {
return 'package';
}
if (haystack.includes('automation calculation')) return 'calculated';
return 'standalone';
}
const HISTORY_KEY_ALIASES = {
'costco-breathless': 'hotel-breathless',
'costco-grand-fiesta': 'hotel-grand-fiesta',
@@ -118,6 +152,18 @@ function readJsonLines(filePath) {
.filter(Boolean);
}
function latestNonEmptyArray(runs, fieldNames) {
for (let index = runs.length - 1; index >= 0; index -= 1) {
for (const fieldName of fieldNames) {
if (Array.isArray(runs[index][fieldName]) && runs[index][fieldName].length) {
return runs[index][fieldName];
}
}
}
return null;
}
function getOptionHistoryKeys(option) {
const nameKey = normalizeKey(option.name);
const categoryNameKey = option.categoryId && nameKey ? `${option.categoryId}-${nameKey}` : '';
@@ -174,6 +220,47 @@ function loadPriceHistoryState() {
const seriesByKey = new Map();
const addPointToSeries = (run, point, defaults = {}) => {
const key = normalizeKey(
point.optionKey || point.optionId || point.seedKey || point.slug || point.key || point.name,
);
const price = extractNumericPrice({
...defaults,
...point,
});
if (!key || price === null) return;
const nextPoint = {
checkedAt: run.checkedAt,
checkedAtMs: run.checkedAtMs,
runIndex: run.runIndex,
price,
currency: point.currency || defaults.currency || 'USD',
displayPrice: point.displayPrice || point.priceLabel || point.displayLabel || point.label || defaults.displayPrice || null,
source: normalizeSourceLabel(point.source || point.sourceLabel || point.vendor || defaults.source || null),
sourceKey: normalizeKey(point.sourceKey || point.sourceId || point.source || point.sourceLabel || point.vendor || defaults.sourceKey || 'unknown-source'),
sourceUrl: point.sourceUrl || point.url || defaults.sourceUrl || null,
bookingType: normalizeBookingType(inferBookingType(point, defaults)),
priceBasis: point.priceBasis || point.price_basis || point.unit || defaults.priceBasis || null,
includedComponents: toTextList(point.includedComponents || point.includesComponents || point.componentsIncluded || defaults.includedComponents),
excludedComponents: toTextList(point.excludedComponents || point.componentsExcluded || defaults.excludedComponents),
origin: point.origin || point.originAirport || defaults.origin || null,
destination: point.destination || point.destinationAirport || defaults.destination || null,
note: point.note || point.description || null,
availability: point.availability || point.status || null,
decisionNote: point.decisionNote || point.note || point.description || defaults.decisionNote || null,
highlights: toTextList(point.highlights || point.summaryBullets || point.bullets || defaults.highlights),
features: toTextList(point.features || point.featureHighlights || point.featureLabels || defaults.features),
amenities: toTextList(point.amenities || point.amenityHighlights || point.amenityLabels || defaults.amenities),
inclusions: toTextList(point.inclusions || point.includes || point.perks || defaults.inclusions),
limitations: toTextList(point.limitations || point.tradeoffs || point.caveats || defaults.limitations),
};
if (!seriesByKey.has(key)) seriesByKey.set(key, []);
seriesByKey.get(key).push(nextPoint);
};
runs.forEach((run) => {
const pricePoints = Array.isArray(run.optionPrices)
? run.optionPrices
@@ -184,35 +271,27 @@ function loadPriceHistoryState() {
: [];
pricePoints.forEach((point) => {
const key = normalizeKey(
point.optionKey || point.optionId || point.seedKey || point.slug || point.key || point.name,
);
const price = extractNumericPrice(point);
addPointToSeries(run, point);
});
if (!key || price === null) return;
const derivedItineraries = Array.isArray(run.derivedItineraries)
? run.derivedItineraries
: Array.isArray(run.itineraryScenarios)
? run.itineraryScenarios
: [];
const nextPoint = {
checkedAt: run.checkedAt,
checkedAtMs: run.checkedAtMs,
runIndex: run.runIndex,
price,
currency: point.currency || 'USD',
displayPrice: point.displayPrice || point.priceLabel || point.label || null,
source: normalizeSourceLabel(point.source || point.sourceLabel || point.vendor || null),
sourceKey: normalizeKey(point.sourceKey || point.sourceId || point.source || point.sourceLabel || point.vendor || 'unknown-source'),
sourceUrl: point.sourceUrl || point.url || null,
note: point.note || point.description || null,
availability: point.availability || point.status || null,
decisionNote: point.decisionNote || point.note || point.description || null,
highlights: toTextList(point.highlights || point.summaryBullets || point.bullets),
features: toTextList(point.features || point.featureHighlights || point.featureLabels),
amenities: toTextList(point.amenities || point.amenityHighlights || point.amenityLabels),
inclusions: toTextList(point.inclusions || point.includes || point.perks),
limitations: toTextList(point.limitations || point.tradeoffs || point.caveats),
};
if (!seriesByKey.has(key)) seriesByKey.set(key, []);
seriesByKey.get(key).push(nextPoint);
derivedItineraries.forEach((itinerary) => {
addPointToSeries(run, itinerary, {
bookingType: 'calculated',
priceBasis: 'perPerson',
source: 'Automation calculation',
sourceKey: 'automation-calculation',
displayPrice: typeof itinerary.perPerson === 'number' ? `$${itinerary.perPerson.toLocaleString()} pp` : null,
price: itinerary.perPerson,
decisionNote: itinerary.summary,
highlights: itinerary.assumptions,
inclusions: itinerary.components,
});
});
});
@@ -223,6 +302,8 @@ function loadPriceHistoryState() {
return {
runs,
seriesByKey,
budgetScenarios: latestNonEmptyArray(runs, ['budgetScenarios', 'derivedBudgetScenarios']),
derivedItineraries: latestNonEmptyArray(runs, ['derivedItineraries', 'itineraryScenarios']),
latestCheckedAt: runs.at(-1)?.checkedAt || null,
totalRuns: runs.length,
};
@@ -246,9 +327,9 @@ function buildPriceHistoryBySource(priceHistory) {
const bucket = grouped.get(sourceKey);
bucket.points.push({
...point,
sourceKey,
source: sourceLabel,
});
sourceKey,
source: sourceLabel,
});
if (!bucket.sourceUrl && point.sourceUrl) bucket.sourceUrl = point.sourceUrl;
if (bucket.sourceLabel === 'Unknown source' && sourceLabel) bucket.sourceLabel = sourceLabel;
});
@@ -263,6 +344,8 @@ function buildPriceHistoryBySource(priceHistory) {
sourceKey: bucket.sourceKey,
sourceLabel: bucket.sourceLabel,
sourceUrl: bucket.sourceUrl,
bookingType: latestPoint?.bookingType || null,
priceBasis: latestPoint?.priceBasis || null,
pointCount: bucket.points.length,
latestCheckedAt: latestPoint?.checkedAt || null,
latestCheckedAtMs: latestPoint?.checkedAtMs || 0,
@@ -333,6 +416,10 @@ function decorateOptionWithPriceHistory(option, priceHistoryState) {
displayPrice: latestPricePoint.displayPrice || null,
source: latestPricePoint.source || null,
sourceUrl: latestPricePoint.sourceUrl || null,
bookingType: latestPricePoint.bookingType || null,
priceBasis: latestPricePoint.priceBasis || null,
includedComponents: latestPricePoint.includedComponents || [],
excludedComponents: latestPricePoint.excludedComponents || [],
availability: latestPricePoint.availability || null,
decisionNote: latestPricePoint.decisionNote || null,
highlights: automationHighlights,
@@ -368,6 +455,7 @@ function broadcast(payload) {
function buildRealtimeSnapshot() {
const priceHistoryState = loadPriceHistoryState();
const approvedOptions = data.options.filter((option) => option.approved);
const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || [];
return {
type: 'init',
@@ -376,7 +464,7 @@ function buildRealtimeSnapshot() {
options: decorateOptionsWithPriceHistory(approvedOptions, priceHistoryState),
results: approvedOptionsWithVoteSummary(),
totalVoters: data.voters.length,
budgetScenarios: data.budgetScenarios || [],
budgetScenarios,
priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
priceHistoryRunCount: priceHistoryState.totalRuns,
};
@@ -420,6 +508,7 @@ app.get('/api/options', (req, res) => {
app.get('/api/results', (req, res) => {
const priceHistoryState = loadPriceHistoryState();
const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || [];
const results = data.categories.map((category) => ({
...category,
options: data.options
@@ -434,7 +523,7 @@ app.get('/api/results', (req, res) => {
pollsOpen: data.pollsOpen,
results,
totalVoters: data.voters.length,
budgetScenarios: data.budgetScenarios || [],
budgetScenarios,
priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
priceHistoryRunCount: priceHistoryState.totalRuns,
});
@@ -444,7 +533,8 @@ app.get('/api/budgets', (req, res) => {
const priceHistoryState = loadPriceHistoryState();
res.json({
updatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
scenarios: data.budgetScenarios || [],
scenarios: priceHistoryState.budgetScenarios || data.budgetScenarios || [],
derivedItineraries: priceHistoryState.derivedItineraries || [],
});
});
@@ -458,6 +548,8 @@ app.get('/api/price-history', (req, res) => {
latestCheckedAt: priceHistoryState.latestCheckedAt,
totalRuns: priceHistoryState.totalRuns,
seriesByOption,
budgetScenarios: priceHistoryState.budgetScenarios || [],
derivedItineraries: priceHistoryState.derivedItineraries || [],
});
});