feat: expand price automation contract
This commit is contained in:
158
server.js
158
server.js
@@ -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 || [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user