feat: surface automation option details

This commit is contained in:
TopherMayor
2026-04-30 11:07:26 -07:00
parent 899c9cb30b
commit b7a4386e00
4 changed files with 257 additions and 4 deletions

View File

@@ -17,6 +17,7 @@ node server.js
- **5 categories** — Hotels, Golf, Nightlife, Excursions, Full Itineraries - **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 - **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 - **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 - **Add suggestions** — anyone can propose new venues
- **Admin approval** — pending options require approval before going live - **Admin approval** — pending options require approval before going live
- **Responsive** — works on desktop and mobile - **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. 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. 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. 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 ## Deployment

View File

@@ -39,6 +39,7 @@
"Use seed-data.js as the current baseline for names, links, and budget assumptions.", "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.", "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 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." "If a source is gated behind login or membership, note that clearly in both outputs."
] ]
} }

View File

@@ -589,6 +589,67 @@
margin-bottom: 8px; margin-bottom: 8px;
line-height: 1.4; 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 { .option-link {
display: inline-block; display: inline-block;
font-size: 0.7rem; font-size: 0.7rem;
@@ -1492,6 +1553,135 @@
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} }
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 `
<div class="option-detail-list">
${chips.map(item => `<span class="option-detail-chip">${escapeHtml(item)}</span>`).join('')}
</div>
`;
}
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(`
<div class="option-facts-grid">
<div class="option-fact">
<span class="option-fact-label">Current price</span>
<div class="option-fact-value">${escapeHtml(priceLabel)}</div>
</div>
<div class="option-fact">
<span class="option-fact-label">Source</span>
<div class="option-fact-value">${escapeHtml(sourceLabel)}</div>
</div>
<div class="option-fact">
<span class="option-fact-label">Status</span>
<div class="option-fact-value">${escapeHtml(statusLabel)}</div>
</div>
</div>
`);
if (overviewItems.length) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">Decision summary</div>
${renderTextChips([...overviewItems, ...autoHighlights])}
</div>
`);
} else if (autoHighlights.length) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">Decision summary</div>
${renderTextChips(autoHighlights)}
</div>
`);
}
if (features.length) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">Features</div>
${renderTextChips(features)}
</div>
`);
}
if (amenities.length) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">Amenities</div>
${renderTextChips(amenities)}
</div>
`);
}
if (inclusions.length) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">Inclusions</div>
${renderTextChips(inclusions)}
</div>
`);
}
if (limitations.length) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">Tradeoffs</div>
${renderTextChips(limitations)}
</div>
`);
}
return sections.length
? `<div class="option-facts">${sections.join('')}</div>`
: '';
}
function renderPriceTrend(opt) { function renderPriceTrend(opt) {
const series = Array.isArray(opt.priceHistory) const series = Array.isArray(opt.priceHistory)
? opt.priceHistory.filter(point => typeof point.price === 'number' && !Number.isNaN(point.price)) ? opt.priceHistory.filter(point => typeof point.price === 'number' && !Number.isNaN(point.price))
@@ -1753,9 +1943,7 @@
</div> </div>
${opt.desc ? `<div class="option-desc">${opt.desc}</div>` : ''} ${opt.desc ? `<div class="option-desc">${opt.desc}</div>` : ''}
${linkPills} ${linkPills}
${opt.details && opt.details.length ? ` ${renderOptionFacts(opt)}
<div class="itin-details">${opt.details.map(d => `<span class="itin-tag">${d}</span>`).join('')}</div>
` : ''}
${renderPriceTrend(opt)} ${renderPriceTrend(opt)}
<div class="vote-bar-bg"> <div class="vote-bar-bg">
<div class="vote-bar-fill ${catClass}" style="width:${votePct}%"></div> <div class="vote-bar-fill ${catClass}" style="width:${votePct}%"></div>

View File

@@ -63,6 +63,32 @@ function normalizeKey(value) {
.replace(/^-+|-+$/g, ''); .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) { function readJsonLines(filePath) {
if (!fs.existsSync(filePath)) return []; if (!fs.existsSync(filePath)) return [];
@@ -162,6 +188,13 @@ function loadPriceHistoryState() {
source: point.source || null, source: point.source || null,
sourceUrl: point.sourceUrl || point.url || null, sourceUrl: point.sourceUrl || point.url || null,
note: point.note || point.description || 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, []); if (!seriesByKey.has(key)) seriesByKey.set(key, []);
@@ -196,12 +229,41 @@ function getPriceHistoryForOption(option, priceHistoryState) {
function decorateOptionWithPriceHistory(option, priceHistoryState) { function decorateOptionWithPriceHistory(option, priceHistoryState) {
const priceHistory = getPriceHistoryForOption(option, priceHistoryState); const priceHistory = getPriceHistoryForOption(option, priceHistoryState);
const latestPricePoint = priceHistory.at(-1) || null; 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 { return {
...option, ...option,
priceHistory, priceHistory,
latestPricePoint, latestPricePoint,
currentPrice: latestPricePoint?.price ?? null, 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,
}; };
} }