diff --git a/README.md b/README.md
index 9884c86..1ad4577 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ node server.js
- **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
- **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
- **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
diff --git a/public/index.html b/public/index.html
index dccbf5f..f5ba17d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -619,6 +619,28 @@
color: #fff;
line-height: 1.4;
}
+ .option-source-select {
+ width: 100%;
+ margin-top: 4px;
+ background: rgba(8, 11, 17, 0.9);
+ border: 1px solid rgba(0, 212, 255, 0.18);
+ color: #e6f7ff;
+ border-radius: 8px;
+ padding: 7px 8px;
+ font-size: 0.72rem;
+ outline: none;
+ appearance: none;
+ }
+ .option-source-select:focus {
+ border-color: rgba(0, 212, 255, 0.48);
+ box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.08);
+ }
+ .option-source-sub {
+ margin-top: 4px;
+ font-size: 0.63rem;
+ color: var(--text-muted);
+ line-height: 1.35;
+ }
.option-fact-note {
font-size: 0.68rem;
color: var(--text-muted);
@@ -1347,6 +1369,13 @@
budgetScenarios: [],
priceUpdatedAt: '',
priceHistoryRunCount: 0,
+ priceSourceSelections: (() => {
+ try {
+ return JSON.parse(localStorage.getItem('cabo_price_source_selections') || '{}');
+ } catch {
+ return {};
+ }
+ })(),
pollsOpen: true,
totalVoters: 0,
wsConnected: false,
@@ -1553,6 +1582,72 @@
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
+ function normalizeSourceKey(value) {
+ return String(value || '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '') || 'unknown-source';
+ }
+
+ function getAvailableSources(opt) {
+ if (Array.isArray(opt.availableSources) && opt.availableSources.length) {
+ return opt.availableSources.map(source => ({
+ ...source,
+ sourceKey: normalizeSourceKey(source.sourceKey || source.sourceLabel || source.source),
+ }));
+ }
+
+ const defaultKey = normalizeSourceKey(opt.currentSourceKey || opt.automationInsights?.source || 'unknown-source');
+ const defaultLabel = opt.automationInsights?.source || 'Unknown source';
+ return [{
+ sourceKey: defaultKey,
+ sourceLabel: defaultLabel,
+ sourceUrl: opt.automationInsights?.sourceUrl || null,
+ pointCount: Array.isArray(opt.priceHistory) ? opt.priceHistory.length : 0,
+ latestCheckedAt: opt.latestPricePoint?.checkedAt || null,
+ latestPrice: opt.latestPricePoint?.price ?? null,
+ latestDisplayPrice: opt.latestPricePoint?.displayPrice || null,
+ currency: opt.latestPricePoint?.currency || 'USD',
+ }];
+ }
+
+ function getOptionSelectedSourceKey(opt) {
+ const availableSources = getAvailableSources(opt);
+ const stored = state.priceSourceSelections?.[opt.id];
+ const normalizedStored = stored ? normalizeSourceKey(stored) : '';
+ if (normalizedStored && availableSources.some(source => source.sourceKey === normalizedStored)) {
+ return normalizedStored;
+ }
+
+ const defaultKey = normalizeSourceKey(opt.currentSourceKey || opt.defaultSourceKey || availableSources[0]?.sourceKey);
+ return availableSources.some(source => source.sourceKey === defaultKey)
+ ? defaultKey
+ : availableSources[0]?.sourceKey || 'unknown-source';
+ }
+
+ function getOptionSourceSeries(opt, sourceKey = getOptionSelectedSourceKey(opt)) {
+ const normalizedKey = normalizeSourceKey(sourceKey);
+ if (opt.priceHistoryBySource && Array.isArray(opt.priceHistoryBySource[normalizedKey])) {
+ return opt.priceHistoryBySource[normalizedKey];
+ }
+ return Array.isArray(opt.priceHistory) ? opt.priceHistory : [];
+ }
+
+ function getOptionSourceMeta(opt, sourceKey = getOptionSelectedSourceKey(opt)) {
+ const normalizedKey = normalizeSourceKey(sourceKey);
+ return getAvailableSources(opt).find(source => source.sourceKey === normalizedKey) || null;
+ }
+
+ function setOptionSource(optionId, sourceKey) {
+ state.priceSourceSelections = {
+ ...state.priceSourceSelections,
+ [optionId]: normalizeSourceKey(sourceKey),
+ };
+ localStorage.setItem('cabo_price_source_selections', JSON.stringify(state.priceSourceSelections));
+ render();
+ }
+
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
@@ -1593,18 +1688,64 @@
`;
}
+ function renderSourceSelect(opt) {
+ const sources = getAvailableSources(opt);
+ if (sources.length <= 1) {
+ const source = sources[0] || null;
+ const sourceLabel = source?.sourceLabel || opt.automationInsights?.source || 'Unknown source';
+ const meta = source ? [
+ source.latestDisplayPrice || (typeof source.latestPrice === 'number' ? formatCurrency(source.latestPrice, source.currency || 'USD') : ''),
+ source.pointCount ? `${source.pointCount} point${source.pointCount === 1 ? '' : 's'}` : '',
+ ].filter(Boolean).join(' · ') : '';
+ return `
+
${escapeHtml(sourceLabel)}${meta ? ` · ${escapeHtml(meta)}` : ''}
+ `;
+ }
+
+ const selectedSourceKey = getOptionSelectedSourceKey(opt);
+ return `
+
+ ${sources.map(source => {
+ const labelParts = [
+ source.sourceLabel || 'Unknown source',
+ source.latestDisplayPrice || (typeof source.latestPrice === 'number' ? formatCurrency(source.latestPrice, source.currency || 'USD') : ''),
+ source.pointCount ? `${source.pointCount} pt${source.pointCount === 1 ? '' : 's'}` : '',
+ ].filter(Boolean);
+ return `${escapeHtml(labelParts.join(' · '))} `;
+ }).join('')}
+
+ `;
+ }
+
function renderOptionFacts(opt) {
- const insights = opt.automationInsights || {};
- const currentPrice = typeof opt.currentPrice === 'number' ? formatCurrency(opt.currentPrice, insights.currency || 'USD') : '';
- const priceLabel = insights.displayPrice || currentPrice || 'Not yet tracked';
- const sourceLabel = insights.source || 'Automation feed';
- const statusLabel = insights.availability || insights.decisionNote || 'Matched from live search';
+ const availableSources = getAvailableSources(opt);
+ const selectedSourceKey = getOptionSelectedSourceKey(opt);
+ const selectedSeries = getOptionSourceSeries(opt, selectedSourceKey);
+ const selectedMeta = getOptionSourceMeta(opt, selectedSourceKey);
+ const selectedPoint = selectedSeries.at(-1) || opt.latestPricePoint || null;
+ const insights = selectedPoint ? {
+ source: selectedMeta?.sourceLabel || selectedPoint.source || 'Automation feed',
+ sourceUrl: selectedMeta?.sourceUrl || selectedPoint.sourceUrl || null,
+ availability: selectedPoint.availability || null,
+ decisionNote: selectedPoint.decisionNote || null,
+ displayPrice: selectedPoint.displayPrice || null,
+ currency: selectedPoint.currency || 'USD',
+ } : (opt.automationInsights || {});
+ 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 statusLabel = selectedPoint?.availability || selectedPoint?.decisionNote || insights.availability || insights.decisionNote || 'Matched from live search';
const overviewItems = normalizeTextList(opt.details);
- const autoHighlights = normalizeTextList(insights.highlights);
- const features = normalizeTextList(insights.features);
- const amenities = normalizeTextList(insights.amenities);
- const inclusions = normalizeTextList(insights.inclusions);
- const limitations = normalizeTextList(insights.limitations);
+ const autoHighlights = normalizeTextList(selectedPoint?.highlights || opt.automationInsights?.highlights);
+ const features = normalizeTextList(selectedPoint?.features || opt.automationInsights?.features);
+ const amenities = normalizeTextList(selectedPoint?.amenities || opt.automationInsights?.amenities);
+ const inclusions = normalizeTextList(selectedPoint?.inclusions || opt.automationInsights?.inclusions);
+ const limitations = normalizeTextList(selectedPoint?.limitations || opt.automationInsights?.limitations);
+ const sourceMetaLine = selectedMeta
+ ? [selectedMeta.latestDisplayPrice || priceLabel, selectedMeta.pointCount ? `${selectedMeta.pointCount} point${selectedMeta.pointCount === 1 ? '' : 's'}` : '']
+ .filter(Boolean)
+ .join(' · ')
+ : '';
const sections = [];
@@ -1616,7 +1757,8 @@
Source
-
${escapeHtml(sourceLabel)}
+ ${renderSourceSelect(opt)}
+ ${availableSources.length > 1 && sourceMetaLine ? `
${escapeHtml(sourceLabel)}${sourceMetaLine ? ` · ${escapeHtml(sourceMetaLine)}` : ''}
` : ''}
Status
@@ -1683,14 +1825,15 @@
}
function renderPriceTrend(opt) {
- const series = Array.isArray(opt.priceHistory)
- ? opt.priceHistory.filter(point => typeof point.price === 'number' && !Number.isNaN(point.price))
- : [];
+ const selectedSourceKey = getOptionSelectedSourceKey(opt);
+ const series = getOptionSourceSeries(opt, selectedSourceKey)
+ .filter(point => typeof point.price === 'number' && !Number.isNaN(point.price));
+ const selectedMeta = getOptionSourceMeta(opt, selectedSourceKey);
if (series.length === 0) {
return `
- Price tracking appears after the next automation run.
+ ${selectedMeta?.sourceLabel ? `${escapeHtml(selectedMeta.sourceLabel)} price tracking appears after the next automation run.` : 'Price tracking appears after the next automation run.'}
`;
}
@@ -1733,13 +1876,14 @@
? 'flat from first check'
: `${delta > 0 ? '+' : '−'}${formatCurrency(Math.abs(delta), latest.currency)}`;
const checkedLabel = latest.checkedAt ? formatTrackedDate(latest.checkedAt) : '';
+ const sourceLabel = selectedMeta?.sourceLabel || latest.source || 'Tracked source';
return `
diff --git a/server.js b/server.js
index 3960895..6019066 100644
--- a/server.js
+++ b/server.js
@@ -63,6 +63,10 @@ function normalizeKey(value) {
.replace(/^-+|-+$/g, '');
}
+function normalizeSourceLabel(value) {
+ return String(value || 'Unknown source').trim() || 'Unknown source';
+}
+
const HISTORY_KEY_ALIASES = {
'costco-breathless': 'hotel-breathless',
'costco-grand-fiesta': 'hotel-grand-fiesta',
@@ -194,7 +198,8 @@ function loadPriceHistoryState() {
price,
currency: point.currency || 'USD',
displayPrice: point.displayPrice || point.priceLabel || point.label || null,
- source: point.source || 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,
@@ -223,6 +228,61 @@ function loadPriceHistoryState() {
};
}
+function buildPriceHistoryBySource(priceHistory) {
+ const grouped = new Map();
+
+ priceHistory.forEach((point) => {
+ const sourceKey = normalizeKey(point.sourceKey || point.source || 'unknown-source');
+ const sourceLabel = normalizeSourceLabel(point.source || point.sourceLabel || sourceKey);
+ if (!grouped.has(sourceKey)) {
+ grouped.set(sourceKey, {
+ sourceKey,
+ sourceLabel,
+ sourceUrl: point.sourceUrl || null,
+ points: [],
+ });
+ }
+
+ const bucket = grouped.get(sourceKey);
+ bucket.points.push({
+ ...point,
+ sourceKey,
+ source: sourceLabel,
+ });
+ if (!bucket.sourceUrl && point.sourceUrl) bucket.sourceUrl = point.sourceUrl;
+ if (bucket.sourceLabel === 'Unknown source' && sourceLabel) bucket.sourceLabel = sourceLabel;
+ });
+
+ const seriesBySource = {};
+ const sourceSummaries = [...grouped.values()].map((bucket) => {
+ bucket.points.sort((a, b) => a.runIndex - b.runIndex);
+ seriesBySource[bucket.sourceKey] = bucket.points;
+ const latestPoint = bucket.points.at(-1) || null;
+
+ return {
+ sourceKey: bucket.sourceKey,
+ sourceLabel: bucket.sourceLabel,
+ sourceUrl: bucket.sourceUrl,
+ pointCount: bucket.points.length,
+ latestCheckedAt: latestPoint?.checkedAt || null,
+ latestCheckedAtMs: latestPoint?.checkedAtMs || 0,
+ latestPrice: latestPoint?.price ?? null,
+ latestDisplayPrice: latestPoint?.displayPrice || null,
+ currency: latestPoint?.currency || 'USD',
+ };
+ }).sort((a, b) => {
+ const aMs = a.latestCheckedAtMs || 0;
+ const bMs = b.latestCheckedAtMs || 0;
+ if (aMs !== bMs) return bMs - aMs;
+ return a.sourceLabel.localeCompare(b.sourceLabel);
+ });
+
+ return {
+ seriesBySource,
+ sourceSummaries,
+ };
+}
+
function getPriceHistoryForOption(option, priceHistoryState) {
const optionKeys = getOptionHistoryKeys(option);
for (const key of optionKeys) {
@@ -237,7 +297,11 @@ function getPriceHistoryForOption(option, priceHistoryState) {
function decorateOptionWithPriceHistory(option, priceHistoryState) {
const priceHistory = getPriceHistoryForOption(option, priceHistoryState);
- const latestPricePoint = priceHistory.at(-1) || null;
+ const { seriesBySource, sourceSummaries } = buildPriceHistoryBySource(priceHistory);
+ const defaultSourceSummary = sourceSummaries[0] || null;
+ const defaultSourceKey = defaultSourceSummary?.sourceKey || null;
+ const defaultPriceHistory = defaultSourceKey ? seriesBySource[defaultSourceKey] || [] : priceHistory;
+ const latestPricePoint = defaultPriceHistory.at(-1) || null;
const optionDetails = toTextList(option.details);
const automationHighlights = toTextList(latestPricePoint?.highlights);
const automationFeatures = toTextList(latestPricePoint?.features);
@@ -255,7 +319,11 @@ function decorateOptionWithPriceHistory(option, priceHistoryState) {
return {
...option,
- priceHistory,
+ priceHistory: defaultPriceHistory,
+ priceHistoryBySource: seriesBySource,
+ availableSources: sourceSummaries,
+ defaultSourceKey,
+ currentSourceKey: defaultSourceKey,
latestPricePoint,
currentPrice: latestPricePoint?.price ?? null,
decisionDetails: [...new Set(decisionDetails)],