feat: expand price automation contract
This commit is contained in:
12
README.md
12
README.md
@@ -14,10 +14,11 @@ node server.js
|
||||
## Features
|
||||
|
||||
- **Real-time WebSocket voting** — all clients update instantly
|
||||
- **5 categories** — Hotels, Golf, Nightlife, Excursions, Full Itineraries
|
||||
- **Budget planner tab** — quick compare for 8, 10, and 12 guys across Budget, Balanced, and Splurge tracks
|
||||
- **6 planning categories** — Hotels, Flights, Golf, Nightlife, Excursions, and Full Itineraries
|
||||
- **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
|
||||
- **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
|
||||
- **Add suggestions** — anyone can propose new venues
|
||||
- **Admin approval** — pending options require approval before going live
|
||||
@@ -26,11 +27,12 @@ node server.js
|
||||
## Data
|
||||
|
||||
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.
|
||||
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.
|
||||
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. 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.
|
||||
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
|
||||
|
||||
|
||||
@@ -10,35 +10,158 @@
|
||||
},
|
||||
"trackedSources": [
|
||||
{
|
||||
"id": "packages-hotels",
|
||||
"label": "Packages and Hotels",
|
||||
"categories": ["hotel"]
|
||||
"id": "hotel-packages",
|
||||
"label": "Flight + Hotel Packages",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"label": "Budget Tracks",
|
||||
"categories": ["budget"]
|
||||
"id": "derived-itineraries",
|
||||
"label": "Derived Itineraries",
|
||||
"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": [
|
||||
"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.",
|
||||
"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.",
|
||||
"If a source is gated behind login or membership, note that clearly in both outputs."
|
||||
]
|
||||
|
||||
@@ -329,6 +329,7 @@
|
||||
--green: #34d399;
|
||||
--red: #f87171;
|
||||
--hotel: #3b82f6;
|
||||
--flight: #38bdf8;
|
||||
--golf: #22c55e;
|
||||
--nightlife: #a855f7;
|
||||
--excursion: #06b6d4;
|
||||
@@ -835,6 +836,7 @@
|
||||
width: 0%;
|
||||
}
|
||||
.vote-bar-fill.hotel { background: var(--hotel); }
|
||||
.vote-bar-fill.flight { background: var(--flight); }
|
||||
.vote-bar-fill.golf { background: var(--golf); }
|
||||
.vote-bar-fill.nightlife { background: var(--nightlife); }
|
||||
.vote-bar-fill.excursion { background: var(--excursion); }
|
||||
@@ -1430,6 +1432,7 @@
|
||||
<div class="btn-row">
|
||||
<select id="addCategory">
|
||||
<option value="hotel">🏨 Hotel</option>
|
||||
<option value="flight">✈️ Flight</option>
|
||||
<option value="golf">⛳ Golf</option>
|
||||
<option value="nightlife">🎧 Nightlife</option>
|
||||
<option value="excursion">🚤 Excursion</option>
|
||||
@@ -1677,6 +1680,26 @@
|
||||
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) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
@@ -1699,6 +1722,8 @@
|
||||
sourceKey: defaultKey,
|
||||
sourceLabel: defaultLabel,
|
||||
sourceUrl: opt.automationInsights?.sourceUrl || null,
|
||||
bookingType: opt.automationInsights?.bookingType || null,
|
||||
priceBasis: opt.automationInsights?.priceBasis || null,
|
||||
pointCount: Array.isArray(opt.priceHistory) ? opt.priceHistory.length : 0,
|
||||
latestCheckedAt: opt.latestPricePoint?.checkedAt || null,
|
||||
latestPrice: opt.latestPricePoint?.price ?? null,
|
||||
@@ -1838,6 +1863,7 @@
|
||||
const source = sources[0] || null;
|
||||
const sourceLabel = source?.sourceLabel || opt.automationInsights?.source || 'Unknown source';
|
||||
const meta = source ? [
|
||||
formatBookingType(source.bookingType, source.priceBasis),
|
||||
source.latestDisplayPrice || (typeof source.latestPrice === 'number' ? formatCurrency(source.latestPrice, source.currency || 'USD') : ''),
|
||||
source.pointCount ? `${source.pointCount} point${source.pointCount === 1 ? '' : 's'}` : '',
|
||||
].filter(Boolean).join(' · ') : '';
|
||||
@@ -1852,6 +1878,7 @@
|
||||
${sources.map(source => {
|
||||
const labelParts = [
|
||||
source.sourceLabel || 'Unknown source',
|
||||
formatBookingType(source.bookingType, source.priceBasis),
|
||||
source.latestDisplayPrice || (typeof source.latestPrice === 'number' ? formatCurrency(source.latestPrice, source.currency || 'USD') : ''),
|
||||
source.pointCount ? `${source.pointCount} pt${source.pointCount === 1 ? '' : 's'}` : '',
|
||||
].filter(Boolean);
|
||||
@@ -1870,6 +1897,8 @@
|
||||
const insights = selectedPoint ? {
|
||||
source: selectedMeta?.sourceLabel || selectedPoint.source || 'Automation feed',
|
||||
sourceUrl: selectedMeta?.sourceUrl || selectedPoint.sourceUrl || null,
|
||||
bookingType: selectedMeta?.bookingType || selectedPoint.bookingType || null,
|
||||
priceBasis: selectedMeta?.priceBasis || selectedPoint.priceBasis || null,
|
||||
availability: selectedPoint.availability || null,
|
||||
decisionNote: selectedPoint.decisionNote || null,
|
||||
displayPrice: selectedPoint.displayPrice || null,
|
||||
@@ -1878,6 +1907,10 @@
|
||||
const currentPrice = typeof selectedPoint?.price === 'number' ? formatCurrency(selectedPoint.price, insights.currency || 'USD') : '';
|
||||
const priceLabel = selectedPoint?.displayPrice || insights.displayPrice || currentPrice || 'Not yet tracked';
|
||||
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 overviewItems = normalizeTextList(opt.details);
|
||||
const autoHighlights = normalizeTextList(selectedPoint?.highlights || opt.automationInsights?.highlights);
|
||||
@@ -1904,6 +1937,10 @@
|
||||
${renderSourceSelect(opt)}
|
||||
${availableSources.length > 1 && sourceMetaLine ? `<div class="option-source-sub">${escapeHtml(sourceLabel)}${sourceMetaLine ? ` · ${escapeHtml(sourceMetaLine)}` : ''}</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">
|
||||
<span class="option-fact-label">Status</span>
|
||||
<div class="option-fact-value">${escapeHtml(statusLabel)}</div>
|
||||
@@ -2232,15 +2269,17 @@
|
||||
const winner = rank === 1 ? 'winner' : '';
|
||||
const barColor = cat.id === 'hotel'
|
||||
? 'var(--hotel)'
|
||||
: cat.id === 'golf'
|
||||
? 'var(--golf)'
|
||||
: cat.id === 'nightlife'
|
||||
? 'var(--nightlife)'
|
||||
: cat.id === 'excursion'
|
||||
? 'var(--excursion)'
|
||||
: cat.id === 'budget'
|
||||
? 'var(--budget)'
|
||||
: 'var(--itinerary)';
|
||||
: cat.id === 'flight'
|
||||
? 'var(--flight)'
|
||||
: cat.id === 'golf'
|
||||
? 'var(--golf)'
|
||||
: cat.id === 'nightlife'
|
||||
? 'var(--nightlife)'
|
||||
: cat.id === 'excursion'
|
||||
? 'var(--excursion)'
|
||||
: cat.id === 'budget'
|
||||
? 'var(--budget)'
|
||||
: 'var(--itinerary)';
|
||||
return `
|
||||
<div class="results-row">
|
||||
<div class="results-rank ${medalClass}">${medal}</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
const SEED_VERSION = 2;
|
||||
const SEED_VERSION = 3;
|
||||
const PRICE_UPDATED_AT = '2026-04-29';
|
||||
|
||||
const CATEGORY_META = {
|
||||
hotel: { emoji: '🏨', color: '#3b82f6' },
|
||||
flight: { emoji: '✈️', color: '#38bdf8' },
|
||||
golf: { emoji: '⛳', color: '#22c55e' },
|
||||
nightlife: { emoji: '🎧', color: '#a855f7' },
|
||||
excursion: { emoji: '🚤', color: '#06b6d4' },
|
||||
@@ -181,6 +182,7 @@ function buildSeedData() {
|
||||
priceUpdatedAt: PRICE_UPDATED_AT,
|
||||
categories: [
|
||||
{ id: 'hotel', name: 'Hotels', emoji: '🏨' },
|
||||
{ id: 'flight', name: 'Flights', emoji: '✈️' },
|
||||
{ id: 'golf', name: 'Golf', emoji: '⛳' },
|
||||
{ id: 'nightlife', name: 'Nightlife', emoji: '🎧' },
|
||||
{ id: 'excursion', name: 'Excursions', emoji: '🚤' },
|
||||
|
||||
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