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,
};
}