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 ` + + `; + } + 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 `
Automation price trail
-
${latest.displayPrice || formatCurrency(latest.price, latest.currency) || 'Tracked price'}
+
${escapeHtml(sourceLabel)} · ${escapeHtml(latest.displayPrice || formatCurrency(latest.price, latest.currency) || 'Tracked price')}
${series.length} point${series.length === 1 ? '' : 's'}${checkedLabel ? ` · ${checkedLabel}` : ''}
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)],