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

View File

@@ -14,10 +14,11 @@ node server.js
## Features ## Features
- **Real-time WebSocket voting** — all clients update instantly - **Real-time WebSocket voting** — all clients update instantly
- **5 categories** — Hotels, Golf, Nightlife, Excursions, Full Itineraries - **6 planning categories** — Hotels, Flights, Golf, Nightlife, Excursions, and Full Itineraries
- **Budget planner tab** — quick compare for 8, 10, and 12 guys across Budget, Balanced, and Splurge tracks - **Budget planner tab** — compares 8, 10, and 12 guys across Budget, Balanced, and Splurge tracks
- **Price trend graphs** — each option shows a live line graph from price-watch automation runs - **Price trend graphs** — each option shows a live line graph from price-watch automation runs
- **Source-selectable price tracking** — switch each option between Apple, Costco, KAYAK, and other tracked sources - **Source-selectable price tracking** — switch each option between Apple, Costco, KAYAK, and other tracked sources
- **Package vs standalone labels** — bundled flight+hotel quotes stay distinct from room-only, flight-only, tee-time, table, charter, and excursion prices
- **Decision detail cards** — automation-enriched pricing, features, amenities, and tradeoffs appear on each option - **Decision detail cards** — automation-enriched pricing, features, amenities, and tradeoffs appear on each option
- **Add suggestions** — anyone can propose new venues - **Add suggestions** — anyone can propose new venues
- **Admin approval** — pending options require approval before going live - **Admin approval** — pending options require approval before going live
@@ -26,11 +27,12 @@ node server.js
## Data ## Data
Votes are stored in `data/votes.json` (created on first run). Edit directly or use the admin panel. Votes are stored in `data/votes.json` (created on first run). Edit directly or use the admin panel.
System seed data auto-refreshes researched package options and budget scenarios while preserving existing votes and user-added options. System seed data auto-refreshes researched options while preserving existing votes and user-added options.
Price-watch automation runs append time-series snapshots in `price-watch/history.jsonl`, which the app turns into per-option trend lines and decision detail cards. Price-watch automation runs append time-series snapshots in `price-watch/history.jsonl`, which the app turns into per-option trend lines and decision detail cards. Automation output should cover hotels, flights, golf, nightlife, and excursions, with `bookingType` and `priceBasis` separating package quotes from standalone booking prices.
When a run includes calculated `budgetScenarios` or `derivedItineraries`, the app uses those fresh automation calculations instead of the static seed budget scenarios.
For hosted deployments, set `DATA_DIR` or `DATA_FILE` so mutable vote data lives outside the Git checkout. For hosted deployments, set `DATA_DIR` or `DATA_FILE` so mutable vote data lives outside the Git checkout.
When price-watch automation updates tracked data files in the repository, refresh the Ubuntu deployment so the hosted app picks up the latest option details and price history. When price-watch automation updates tracked data files in the repository, commit/push those changes and refresh the Ubuntu deployment so the hosted app picks up the latest option details, price history, itinerary calculations, and budget scenarios.
## Deployment ## Deployment

View File

@@ -10,35 +10,158 @@
}, },
"trackedSources": [ "trackedSources": [
{ {
"id": "packages-hotels", "id": "hotel-packages",
"label": "Packages and Hotels", "label": "Flight + Hotel Packages",
"categories": ["hotel"] "categories": ["hotel"],
"bookingType": "package",
"requiredChecks": [
"Costco Travel package results",
"Apple Vacations package search",
"CheapCaribbean package search",
"other date-matched package providers found during research"
]
},
{
"id": "standalone-hotels",
"label": "Standalone Hotels",
"categories": ["hotel"],
"bookingType": "standalone",
"requiredChecks": [
"KAYAK hotel search",
"official hotel booking engine when public rates are visible",
"other OTA hotel-only rates found during research"
]
},
{
"id": "flights",
"label": "Standalone Flights",
"categories": ["flight"],
"bookingType": "standalone",
"requiredChecks": [
"LAX to SJD date-matched round trip",
"ONT to SJD date-matched round trip",
"capture airline, stops, schedule window, baggage caveats, and total price per traveler"
]
}, },
{ {
"id": "golf", "id": "golf",
"label": "Golf", "label": "Golf",
"categories": ["golf"] "categories": ["golf"],
"bookingType": "standalone",
"requiredChecks": [
"official course tee-time pages when available",
"public tee-time marketplaces",
"resort/package golf inclusions when attached to hotel packages"
]
}, },
{ {
"id": "nightlife", "id": "nightlife",
"label": "Nightlife and Day Clubs", "label": "Nightlife and Day Clubs",
"categories": ["nightlife"] "categories": ["nightlife"],
"bookingType": "standalone",
"requiredChecks": [
"VIP table packages",
"bottle service minimums",
"day-club and beach-club package pricing",
"cover charges or ticketed events when visible"
]
}, },
{ {
"id": "excursions", "id": "excursions",
"label": "Excursions and Water Activities", "label": "Excursions and Water Activities",
"categories": ["excursion"] "categories": ["excursion"],
"bookingType": "standalone",
"requiredChecks": [
"yacht and private charter quotes",
"whale-watch and sunset sail pricing",
"ATV/off-road packages",
"bachelor-party-relevant group excursions"
]
}, },
{ {
"id": "budget", "id": "derived-itineraries",
"label": "Budget Tracks", "label": "Derived Itineraries",
"categories": ["budget"] "categories": ["itinerary"],
"bookingType": "calculated",
"requiredChecks": [
"recalculate budget, balanced, and splurge itinerary totals from the current hotel/package, flight, golf, nightlife, and excursion results",
"include component breakdowns and assumptions for each itinerary"
]
},
{
"id": "derived-budgets",
"label": "Derived Budget Tracks",
"categories": ["budget"],
"bookingType": "calculated",
"requiredChecks": [
"recalculate 8, 10, and 12 person totals from current results",
"prefer exact package prices when the itinerary uses a flight+hotel package",
"avoid double-counting flights or hotels already included in package prices",
"include per-person and group-total math plus assumptions"
]
} }
], ],
"bookingTypeRules": {
"package": "Use for bundled products such as flight+hotel packages or hotel+transfer packages. Include included components and do not mix directly with standalone room-only rates.",
"standalone": "Use for individual bookings such as hotel-only rates, flights, golf tee times, nightlife tables, yacht charters, and excursions.",
"calculated": "Use for automation-derived itinerary and budget totals built from current package or standalone components."
},
"outputSchema": {
"optionPrices": [
{
"seedKey": "stable app option key when available",
"price": "numeric price only, or null when unavailable",
"displayLabel": "human-readable price label",
"category": "hotel | flight | golf | nightlife | excursion | itinerary | budget",
"source": "travel site or vendor",
"sourceUrl": "exact result or source URL",
"bookingType": "package | standalone | calculated",
"priceBasis": "perTraveler | perNight | perPerson | perGroup | totalPackage | perRound | perTable",
"includedComponents": ["flight", "hotel", "transfer", "golf", "nightlife", "excursion"],
"excludedComponents": ["components that must be budgeted separately"],
"origin": "airport code for flight/package quotes when applicable",
"destination": "airport or destination code when applicable",
"availability": "available | unavailable | sold-out | login-required | request-quote",
"features": ["structured decision features"],
"amenities": ["hotel or venue amenities"],
"inclusions": ["included items"],
"limitations": ["tradeoffs, caveats, restrictions"],
"decisionNote": "short decision note"
}
],
"derivedItineraries": [
{
"seedKey": "itinerary-budget | itinerary-balanced | itinerary-splurge | itinerary-concierge or new stable key",
"tier": "Budget | Balanced | Splurge | Concierge",
"perPerson": "numeric calculated per-person total",
"groupTotal": "numeric calculated group total when group size is known",
"groupSize": "8 | 10 | 12 or selected party size",
"components": ["component price keys used"],
"assumptions": ["calculation assumptions"],
"summary": "short recommendation summary"
}
],
"budgetScenarios": [
{
"id": "stable scenario id",
"tier": "Budget | Balanced | Splurge",
"groupSize": "numeric group size",
"perPerson": "numeric calculated per-person total",
"groupTotal": "numeric calculated group total",
"summary": "short scenario summary",
"notes": ["component breakdown and assumptions"]
}
]
},
"notes": [ "notes": [
"Use seed-data.js as the current baseline for names, links, and budget assumptions.", "Use seed-data.js as the current baseline for names, links, and budget assumptions.",
"Check hotels, flights, golf, nightlife, and excursions on every run before updating itinerary or budget recommendations.",
"Differentiate bundled package prices from standalone booking prices using bookingType and priceBasis on every price point.",
"For package quotes, list the included and excluded components so budgets do not double-count flights, hotels, transfers, or resort credits.",
"For standalone quotes, list the exact unit being priced: per night, per traveler, per person, per group, per round, or per table.",
"Itinerary and budget options are calculated outputs. Recompute them from the freshest current package and standalone component prices instead of treating seed-data.js totals as current.",
"Write a human-readable report to price-watch/latest-report.md on every run.", "Write a human-readable report to price-watch/latest-report.md on every run.",
"Append one machine-readable summary line per run to price-watch/history.jsonl, including per-option price points keyed by stable option ids or seed keys.", "Append one machine-readable summary line per run to price-watch/history.jsonl, including per-option price points, derivedItineraries, and budgetScenarios keyed by stable option ids or seed keys.",
"Capture structured option details when available: current price, availability, source, sourceUrl, highlights, features, amenities, inclusions, limitations, and a short decision note.", "Capture structured option details when available: current price, availability, source, sourceUrl, highlights, features, amenities, inclusions, limitations, and a short decision note.",
"If a source is gated behind login or membership, note that clearly in both outputs." "If a source is gated behind login or membership, note that clearly in both outputs."
] ]

View File

@@ -329,6 +329,7 @@
--green: #34d399; --green: #34d399;
--red: #f87171; --red: #f87171;
--hotel: #3b82f6; --hotel: #3b82f6;
--flight: #38bdf8;
--golf: #22c55e; --golf: #22c55e;
--nightlife: #a855f7; --nightlife: #a855f7;
--excursion: #06b6d4; --excursion: #06b6d4;
@@ -835,6 +836,7 @@
width: 0%; width: 0%;
} }
.vote-bar-fill.hotel { background: var(--hotel); } .vote-bar-fill.hotel { background: var(--hotel); }
.vote-bar-fill.flight { background: var(--flight); }
.vote-bar-fill.golf { background: var(--golf); } .vote-bar-fill.golf { background: var(--golf); }
.vote-bar-fill.nightlife { background: var(--nightlife); } .vote-bar-fill.nightlife { background: var(--nightlife); }
.vote-bar-fill.excursion { background: var(--excursion); } .vote-bar-fill.excursion { background: var(--excursion); }
@@ -1430,6 +1432,7 @@
<div class="btn-row"> <div class="btn-row">
<select id="addCategory"> <select id="addCategory">
<option value="hotel">🏨 Hotel</option> <option value="hotel">🏨 Hotel</option>
<option value="flight">✈️ Flight</option>
<option value="golf">⛳ Golf</option> <option value="golf">⛳ Golf</option>
<option value="nightlife">🎧 Nightlife</option> <option value="nightlife">🎧 Nightlife</option>
<option value="excursion">🚤 Excursion</option> <option value="excursion">🚤 Excursion</option>
@@ -1677,6 +1680,26 @@
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} }
function formatBookingType(bookingType, priceBasis) {
const typeLabels = {
package: 'Package',
standalone: 'Standalone',
calculated: 'Calculated',
};
const basisLabels = {
perTraveler: 'per traveler',
perNight: 'per night',
perPerson: 'per person',
perGroup: 'per group',
totalPackage: 'total package',
perRound: 'per round',
perTable: 'per table',
};
const typeLabel = typeLabels[bookingType] || '';
const basisLabel = basisLabels[priceBasis] || priceBasis || '';
return [typeLabel, basisLabel].filter(Boolean).join(' · ');
}
function normalizeSourceKey(value) { function normalizeSourceKey(value) {
return String(value || '') return String(value || '')
.trim() .trim()
@@ -1699,6 +1722,8 @@
sourceKey: defaultKey, sourceKey: defaultKey,
sourceLabel: defaultLabel, sourceLabel: defaultLabel,
sourceUrl: opt.automationInsights?.sourceUrl || null, sourceUrl: opt.automationInsights?.sourceUrl || null,
bookingType: opt.automationInsights?.bookingType || null,
priceBasis: opt.automationInsights?.priceBasis || null,
pointCount: Array.isArray(opt.priceHistory) ? opt.priceHistory.length : 0, pointCount: Array.isArray(opt.priceHistory) ? opt.priceHistory.length : 0,
latestCheckedAt: opt.latestPricePoint?.checkedAt || null, latestCheckedAt: opt.latestPricePoint?.checkedAt || null,
latestPrice: opt.latestPricePoint?.price ?? null, latestPrice: opt.latestPricePoint?.price ?? null,
@@ -1838,6 +1863,7 @@
const source = sources[0] || null; const source = sources[0] || null;
const sourceLabel = source?.sourceLabel || opt.automationInsights?.source || 'Unknown source'; const sourceLabel = source?.sourceLabel || opt.automationInsights?.source || 'Unknown source';
const meta = source ? [ const meta = source ? [
formatBookingType(source.bookingType, source.priceBasis),
source.latestDisplayPrice || (typeof source.latestPrice === 'number' ? formatCurrency(source.latestPrice, source.currency || 'USD') : ''), source.latestDisplayPrice || (typeof source.latestPrice === 'number' ? formatCurrency(source.latestPrice, source.currency || 'USD') : ''),
source.pointCount ? `${source.pointCount} point${source.pointCount === 1 ? '' : 's'}` : '', source.pointCount ? `${source.pointCount} point${source.pointCount === 1 ? '' : 's'}` : '',
].filter(Boolean).join(' · ') : ''; ].filter(Boolean).join(' · ') : '';
@@ -1852,6 +1878,7 @@
${sources.map(source => { ${sources.map(source => {
const labelParts = [ const labelParts = [
source.sourceLabel || 'Unknown source', source.sourceLabel || 'Unknown source',
formatBookingType(source.bookingType, source.priceBasis),
source.latestDisplayPrice || (typeof source.latestPrice === 'number' ? formatCurrency(source.latestPrice, source.currency || 'USD') : ''), source.latestDisplayPrice || (typeof source.latestPrice === 'number' ? formatCurrency(source.latestPrice, source.currency || 'USD') : ''),
source.pointCount ? `${source.pointCount} pt${source.pointCount === 1 ? '' : 's'}` : '', source.pointCount ? `${source.pointCount} pt${source.pointCount === 1 ? '' : 's'}` : '',
].filter(Boolean); ].filter(Boolean);
@@ -1870,6 +1897,8 @@
const insights = selectedPoint ? { const insights = selectedPoint ? {
source: selectedMeta?.sourceLabel || selectedPoint.source || 'Automation feed', source: selectedMeta?.sourceLabel || selectedPoint.source || 'Automation feed',
sourceUrl: selectedMeta?.sourceUrl || selectedPoint.sourceUrl || null, sourceUrl: selectedMeta?.sourceUrl || selectedPoint.sourceUrl || null,
bookingType: selectedMeta?.bookingType || selectedPoint.bookingType || null,
priceBasis: selectedMeta?.priceBasis || selectedPoint.priceBasis || null,
availability: selectedPoint.availability || null, availability: selectedPoint.availability || null,
decisionNote: selectedPoint.decisionNote || null, decisionNote: selectedPoint.decisionNote || null,
displayPrice: selectedPoint.displayPrice || null, displayPrice: selectedPoint.displayPrice || null,
@@ -1878,6 +1907,10 @@
const currentPrice = typeof selectedPoint?.price === 'number' ? formatCurrency(selectedPoint.price, insights.currency || 'USD') : ''; const currentPrice = typeof selectedPoint?.price === 'number' ? formatCurrency(selectedPoint.price, insights.currency || 'USD') : '';
const priceLabel = selectedPoint?.displayPrice || insights.displayPrice || currentPrice || 'Not yet tracked'; const priceLabel = selectedPoint?.displayPrice || insights.displayPrice || currentPrice || 'Not yet tracked';
const sourceLabel = selectedMeta?.sourceLabel || insights.source || 'Automation feed'; const sourceLabel = selectedMeta?.sourceLabel || insights.source || 'Automation feed';
const bookingLabel = formatBookingType(
selectedMeta?.bookingType || insights.bookingType,
selectedMeta?.priceBasis || insights.priceBasis,
);
const statusLabel = selectedPoint?.availability || selectedPoint?.decisionNote || insights.availability || insights.decisionNote || 'Matched from live search'; const statusLabel = selectedPoint?.availability || selectedPoint?.decisionNote || insights.availability || insights.decisionNote || 'Matched from live search';
const overviewItems = normalizeTextList(opt.details); const overviewItems = normalizeTextList(opt.details);
const autoHighlights = normalizeTextList(selectedPoint?.highlights || opt.automationInsights?.highlights); const autoHighlights = normalizeTextList(selectedPoint?.highlights || opt.automationInsights?.highlights);
@@ -1904,6 +1937,10 @@
${renderSourceSelect(opt)} ${renderSourceSelect(opt)}
${availableSources.length > 1 && sourceMetaLine ? `<div class="option-source-sub">${escapeHtml(sourceLabel)}${sourceMetaLine ? ` · ${escapeHtml(sourceMetaLine)}` : ''}</div>` : ''} ${availableSources.length > 1 && sourceMetaLine ? `<div class="option-source-sub">${escapeHtml(sourceLabel)}${sourceMetaLine ? ` · ${escapeHtml(sourceMetaLine)}` : ''}</div>` : ''}
</div> </div>
<div class="option-fact">
<span class="option-fact-label">Booking type</span>
<div class="option-fact-value">${escapeHtml(bookingLabel || 'Not classified')}</div>
</div>
<div class="option-fact"> <div class="option-fact">
<span class="option-fact-label">Status</span> <span class="option-fact-label">Status</span>
<div class="option-fact-value">${escapeHtml(statusLabel)}</div> <div class="option-fact-value">${escapeHtml(statusLabel)}</div>
@@ -2232,6 +2269,8 @@
const winner = rank === 1 ? 'winner' : ''; const winner = rank === 1 ? 'winner' : '';
const barColor = cat.id === 'hotel' const barColor = cat.id === 'hotel'
? 'var(--hotel)' ? 'var(--hotel)'
: cat.id === 'flight'
? 'var(--flight)'
: cat.id === 'golf' : cat.id === 'golf'
? 'var(--golf)' ? 'var(--golf)'
: cat.id === 'nightlife' : cat.id === 'nightlife'

View File

@@ -1,8 +1,9 @@
const SEED_VERSION = 2; const SEED_VERSION = 3;
const PRICE_UPDATED_AT = '2026-04-29'; const PRICE_UPDATED_AT = '2026-04-29';
const CATEGORY_META = { const CATEGORY_META = {
hotel: { emoji: '🏨', color: '#3b82f6' }, hotel: { emoji: '🏨', color: '#3b82f6' },
flight: { emoji: '✈️', color: '#38bdf8' },
golf: { emoji: '⛳', color: '#22c55e' }, golf: { emoji: '⛳', color: '#22c55e' },
nightlife: { emoji: '🎧', color: '#a855f7' }, nightlife: { emoji: '🎧', color: '#a855f7' },
excursion: { emoji: '🚤', color: '#06b6d4' }, excursion: { emoji: '🚤', color: '#06b6d4' },
@@ -181,6 +182,7 @@ function buildSeedData() {
priceUpdatedAt: PRICE_UPDATED_AT, priceUpdatedAt: PRICE_UPDATED_AT,
categories: [ categories: [
{ id: 'hotel', name: 'Hotels', emoji: '🏨' }, { id: 'hotel', name: 'Hotels', emoji: '🏨' },
{ id: 'flight', name: 'Flights', emoji: '✈️' },
{ id: 'golf', name: 'Golf', emoji: '⛳' }, { id: 'golf', name: 'Golf', emoji: '⛳' },
{ id: 'nightlife', name: 'Nightlife', emoji: '🎧' }, { id: 'nightlife', name: 'Nightlife', emoji: '🎧' },
{ id: 'excursion', name: 'Excursions', emoji: '🚤' }, { id: 'excursion', name: 'Excursions', emoji: '🚤' },

152
server.js
View File

@@ -67,6 +67,40 @@ function normalizeSourceLabel(value) {
return String(value || 'Unknown source').trim() || 'Unknown source'; 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 = { const HISTORY_KEY_ALIASES = {
'costco-breathless': 'hotel-breathless', 'costco-breathless': 'hotel-breathless',
'costco-grand-fiesta': 'hotel-grand-fiesta', 'costco-grand-fiesta': 'hotel-grand-fiesta',
@@ -118,6 +152,18 @@ function readJsonLines(filePath) {
.filter(Boolean); .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) { function getOptionHistoryKeys(option) {
const nameKey = normalizeKey(option.name); const nameKey = normalizeKey(option.name);
const categoryNameKey = option.categoryId && nameKey ? `${option.categoryId}-${nameKey}` : ''; const categoryNameKey = option.categoryId && nameKey ? `${option.categoryId}-${nameKey}` : '';
@@ -174,6 +220,47 @@ function loadPriceHistoryState() {
const seriesByKey = new Map(); 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) => { runs.forEach((run) => {
const pricePoints = Array.isArray(run.optionPrices) const pricePoints = Array.isArray(run.optionPrices)
? run.optionPrices ? run.optionPrices
@@ -184,35 +271,27 @@ function loadPriceHistoryState() {
: []; : [];
pricePoints.forEach((point) => { pricePoints.forEach((point) => {
const key = normalizeKey( addPointToSeries(run, point);
point.optionKey || point.optionId || point.seedKey || point.slug || point.key || point.name, });
);
const price = extractNumericPrice(point);
if (!key || price === null) return; const derivedItineraries = Array.isArray(run.derivedItineraries)
? run.derivedItineraries
: Array.isArray(run.itineraryScenarios)
? run.itineraryScenarios
: [];
const nextPoint = { derivedItineraries.forEach((itinerary) => {
checkedAt: run.checkedAt, addPointToSeries(run, itinerary, {
checkedAtMs: run.checkedAtMs, bookingType: 'calculated',
runIndex: run.runIndex, priceBasis: 'perPerson',
price, source: 'Automation calculation',
currency: point.currency || 'USD', sourceKey: 'automation-calculation',
displayPrice: point.displayPrice || point.priceLabel || point.label || null, displayPrice: typeof itinerary.perPerson === 'number' ? `$${itinerary.perPerson.toLocaleString()} pp` : null,
source: normalizeSourceLabel(point.source || point.sourceLabel || point.vendor || null), price: itinerary.perPerson,
sourceKey: normalizeKey(point.sourceKey || point.sourceId || point.source || point.sourceLabel || point.vendor || 'unknown-source'), decisionNote: itinerary.summary,
sourceUrl: point.sourceUrl || point.url || null, highlights: itinerary.assumptions,
note: point.note || point.description || null, inclusions: itinerary.components,
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);
}); });
}); });
@@ -223,6 +302,8 @@ function loadPriceHistoryState() {
return { return {
runs, runs,
seriesByKey, seriesByKey,
budgetScenarios: latestNonEmptyArray(runs, ['budgetScenarios', 'derivedBudgetScenarios']),
derivedItineraries: latestNonEmptyArray(runs, ['derivedItineraries', 'itineraryScenarios']),
latestCheckedAt: runs.at(-1)?.checkedAt || null, latestCheckedAt: runs.at(-1)?.checkedAt || null,
totalRuns: runs.length, totalRuns: runs.length,
}; };
@@ -263,6 +344,8 @@ function buildPriceHistoryBySource(priceHistory) {
sourceKey: bucket.sourceKey, sourceKey: bucket.sourceKey,
sourceLabel: bucket.sourceLabel, sourceLabel: bucket.sourceLabel,
sourceUrl: bucket.sourceUrl, sourceUrl: bucket.sourceUrl,
bookingType: latestPoint?.bookingType || null,
priceBasis: latestPoint?.priceBasis || null,
pointCount: bucket.points.length, pointCount: bucket.points.length,
latestCheckedAt: latestPoint?.checkedAt || null, latestCheckedAt: latestPoint?.checkedAt || null,
latestCheckedAtMs: latestPoint?.checkedAtMs || 0, latestCheckedAtMs: latestPoint?.checkedAtMs || 0,
@@ -333,6 +416,10 @@ function decorateOptionWithPriceHistory(option, priceHistoryState) {
displayPrice: latestPricePoint.displayPrice || null, displayPrice: latestPricePoint.displayPrice || null,
source: latestPricePoint.source || null, source: latestPricePoint.source || null,
sourceUrl: latestPricePoint.sourceUrl || null, sourceUrl: latestPricePoint.sourceUrl || null,
bookingType: latestPricePoint.bookingType || null,
priceBasis: latestPricePoint.priceBasis || null,
includedComponents: latestPricePoint.includedComponents || [],
excludedComponents: latestPricePoint.excludedComponents || [],
availability: latestPricePoint.availability || null, availability: latestPricePoint.availability || null,
decisionNote: latestPricePoint.decisionNote || null, decisionNote: latestPricePoint.decisionNote || null,
highlights: automationHighlights, highlights: automationHighlights,
@@ -368,6 +455,7 @@ function broadcast(payload) {
function buildRealtimeSnapshot() { function buildRealtimeSnapshot() {
const priceHistoryState = loadPriceHistoryState(); const priceHistoryState = loadPriceHistoryState();
const approvedOptions = data.options.filter((option) => option.approved); const approvedOptions = data.options.filter((option) => option.approved);
const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || [];
return { return {
type: 'init', type: 'init',
@@ -376,7 +464,7 @@ function buildRealtimeSnapshot() {
options: decorateOptionsWithPriceHistory(approvedOptions, priceHistoryState), options: decorateOptionsWithPriceHistory(approvedOptions, priceHistoryState),
results: approvedOptionsWithVoteSummary(), results: approvedOptionsWithVoteSummary(),
totalVoters: data.voters.length, totalVoters: data.voters.length,
budgetScenarios: data.budgetScenarios || [], budgetScenarios,
priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null, priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
priceHistoryRunCount: priceHistoryState.totalRuns, priceHistoryRunCount: priceHistoryState.totalRuns,
}; };
@@ -420,6 +508,7 @@ app.get('/api/options', (req, res) => {
app.get('/api/results', (req, res) => { app.get('/api/results', (req, res) => {
const priceHistoryState = loadPriceHistoryState(); const priceHistoryState = loadPriceHistoryState();
const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || [];
const results = data.categories.map((category) => ({ const results = data.categories.map((category) => ({
...category, ...category,
options: data.options options: data.options
@@ -434,7 +523,7 @@ app.get('/api/results', (req, res) => {
pollsOpen: data.pollsOpen, pollsOpen: data.pollsOpen,
results, results,
totalVoters: data.voters.length, totalVoters: data.voters.length,
budgetScenarios: data.budgetScenarios || [], budgetScenarios,
priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null, priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
priceHistoryRunCount: priceHistoryState.totalRuns, priceHistoryRunCount: priceHistoryState.totalRuns,
}); });
@@ -444,7 +533,8 @@ app.get('/api/budgets', (req, res) => {
const priceHistoryState = loadPriceHistoryState(); const priceHistoryState = loadPriceHistoryState();
res.json({ res.json({
updatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null, 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, latestCheckedAt: priceHistoryState.latestCheckedAt,
totalRuns: priceHistoryState.totalRuns, totalRuns: priceHistoryState.totalRuns,
seriesByOption, seriesByOption,
budgetScenarios: priceHistoryState.budgetScenarios || [],
derivedItineraries: priceHistoryState.derivedItineraries || [],
}); });
}); });