diff --git a/README.md b/README.md
index 1ad4577..902282a 100644
--- a/README.md
+++ b/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
diff --git a/price-watch/watch-targets.json b/price-watch/watch-targets.json
index 2888f3e..afa711f 100644
--- a/price-watch/watch-targets.json
+++ b/price-watch/watch-targets.json
@@ -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."
]
diff --git a/public/index.html b/public/index.html
index eb61934..5513d66 100644
--- a/public/index.html
+++ b/public/index.html
@@ -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 @@
+
+
Booking type
+
${escapeHtml(bookingLabel || 'Not classified')}
+
Status
${escapeHtml(statusLabel)}
@@ -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 `
${medal}
diff --git a/seed-data.js b/seed-data.js
index a4676c0..0f33682 100644
--- a/seed-data.js
+++ b/seed-data.js
@@ -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: '🚤' },
diff --git a/server.js b/server.js
index 6019066..7f856d1 100644
--- a/server.js
+++ b/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 || [],
});
});