From b7a4386e003e744646360241b4ac5bae1db8da34 Mon Sep 17 00:00:00 2001 From: TopherMayor Date: Thu, 30 Apr 2026 11:07:26 -0700 Subject: [PATCH] feat: surface automation option details --- README.md | 4 +- price-watch/watch-targets.json | 1 + public/index.html | 194 ++++++++++++++++++++++++++++++++- server.js | 62 +++++++++++ 4 files changed, 257 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ac74f7d..9884c86 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 +- **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 - **Responsive** — works on desktop and mobile @@ -25,9 +26,10 @@ node server.js 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. +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. 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. ## Deployment diff --git a/price-watch/watch-targets.json b/price-watch/watch-targets.json index 10fa4fa..2888f3e 100644 --- a/price-watch/watch-targets.json +++ b/price-watch/watch-targets.json @@ -39,6 +39,7 @@ "Use seed-data.js as the current baseline for names, links, and budget assumptions.", "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.", + "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 54d3204..dccbf5f 100644 --- a/public/index.html +++ b/public/index.html @@ -589,6 +589,67 @@ margin-bottom: 8px; line-height: 1.4; } + .option-facts { + display: flex; + flex-direction: column; + gap: 8px; + margin: 8px 0 2px; + } + .option-facts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 6px; + } + .option-fact { + padding: 8px 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); + } + .option-fact-label { + display: block; + font-size: 0.58rem; + text-transform: uppercase; + letter-spacing: 0.9px; + color: var(--text-muted); + margin-bottom: 3px; + } + .option-fact-value { + font-size: 0.73rem; + color: #fff; + line-height: 1.4; + } + .option-fact-note { + font-size: 0.68rem; + color: var(--text-muted); + line-height: 1.4; + } + .option-detail-section { + margin-top: 2px; + padding: 8px 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.025); + border: 1px solid rgba(255, 255, 255, 0.05); + } + .option-detail-title { + font-size: 0.58rem; + text-transform: uppercase; + letter-spacing: 0.9px; + color: var(--text-muted); + margin-bottom: 5px; + } + .option-detail-list { + display: flex; + flex-wrap: wrap; + gap: 4px; + } + .option-detail-chip { + background: var(--surface2); + border-radius: 999px; + padding: 3px 8px; + font-size: 0.65rem; + color: var(--text-muted); + } .option-link { display: inline-block; font-size: 0.7rem; @@ -1492,6 +1553,135 @@ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } + function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function normalizeTextList(value) { + const items = Array.isArray(value) ? value : value == null ? [] : [value]; + return [...new Set(items.flatMap(item => { + if (Array.isArray(item)) return item; + if (item && typeof item === 'object') { + return [ + item.label, + item.name, + item.text, + item.title, + item.value, + item.summary, + item.description, + ].filter(Boolean); + } + return [item]; + }) + .map(item => String(item).trim()) + .filter(Boolean))]; + } + + function renderTextChips(items) { + const chips = normalizeTextList(items); + if (!chips.length) return ''; + return ` +
+ ${chips.map(item => `${escapeHtml(item)}`).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 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 sections = []; + + sections.push(` +
+
+ Current price +
${escapeHtml(priceLabel)}
+
+
+ Source +
${escapeHtml(sourceLabel)}
+
+
+ Status +
${escapeHtml(statusLabel)}
+
+
+ `); + + if (overviewItems.length) { + sections.push(` +
+
Decision summary
+ ${renderTextChips([...overviewItems, ...autoHighlights])} +
+ `); + } else if (autoHighlights.length) { + sections.push(` +
+
Decision summary
+ ${renderTextChips(autoHighlights)} +
+ `); + } + + if (features.length) { + sections.push(` +
+
Features
+ ${renderTextChips(features)} +
+ `); + } + + if (amenities.length) { + sections.push(` +
+
Amenities
+ ${renderTextChips(amenities)} +
+ `); + } + + if (inclusions.length) { + sections.push(` +
+
Inclusions
+ ${renderTextChips(inclusions)} +
+ `); + } + + if (limitations.length) { + sections.push(` +
+
Tradeoffs
+ ${renderTextChips(limitations)} +
+ `); + } + + return sections.length + ? `
${sections.join('')}
` + : ''; + } + function renderPriceTrend(opt) { const series = Array.isArray(opt.priceHistory) ? opt.priceHistory.filter(point => typeof point.price === 'number' && !Number.isNaN(point.price)) @@ -1753,9 +1943,7 @@ ${opt.desc ? `
${opt.desc}
` : ''} ${linkPills} - ${opt.details && opt.details.length ? ` -
${opt.details.map(d => `${d}`).join('')}
- ` : ''} + ${renderOptionFacts(opt)} ${renderPriceTrend(opt)}
diff --git a/server.js b/server.js index d6e2e5b..be47572 100644 --- a/server.js +++ b/server.js @@ -63,6 +63,32 @@ function normalizeKey(value) { .replace(/^-+|-+$/g, ''); } +function toTextList(value) { + const items = Array.isArray(value) ? value : value == null ? [] : [value]; + + return [...new Set(items.flatMap((item) => { + if (Array.isArray(item)) { + return item; + } + + if (item && typeof item === 'object') { + return [ + item.label, + item.name, + item.text, + item.title, + item.value, + item.summary, + item.description, + ].filter(Boolean); + } + + return [item]; + }) + .map((item) => String(item).trim()) + .filter(Boolean))]; +} + function readJsonLines(filePath) { if (!fs.existsSync(filePath)) return []; @@ -162,6 +188,13 @@ function loadPriceHistoryState() { source: point.source || null, 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, []); @@ -196,12 +229,41 @@ function getPriceHistoryForOption(option, priceHistoryState) { function decorateOptionWithPriceHistory(option, priceHistoryState) { const priceHistory = getPriceHistoryForOption(option, priceHistoryState); const latestPricePoint = priceHistory.at(-1) || null; + const optionDetails = toTextList(option.details); + const automationHighlights = toTextList(latestPricePoint?.highlights); + const automationFeatures = toTextList(latestPricePoint?.features); + const automationAmenities = toTextList(latestPricePoint?.amenities); + const automationInclusions = toTextList(latestPricePoint?.inclusions); + const automationLimitations = toTextList(latestPricePoint?.limitations); + const decisionDetails = [ + ...optionDetails, + ...automationHighlights, + ...automationFeatures, + ...automationAmenities, + ...automationInclusions, + ...automationLimitations, + ]; return { ...option, priceHistory, latestPricePoint, currentPrice: latestPricePoint?.price ?? null, + decisionDetails: [...new Set(decisionDetails)], + automationInsights: latestPricePoint ? { + currentPrice: latestPricePoint.price, + currency: latestPricePoint.currency || 'USD', + displayPrice: latestPricePoint.displayPrice || null, + source: latestPricePoint.source || null, + sourceUrl: latestPricePoint.sourceUrl || null, + availability: latestPricePoint.availability || null, + decisionNote: latestPricePoint.decisionNote || null, + highlights: automationHighlights, + features: automationFeatures, + amenities: automationAmenities, + inclusions: automationInclusions, + limitations: automationLimitations, + } : null, }; }