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