feat: surface automation option details
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, '<')
|
||||||
|
.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 `
|
||||||
|
<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>
|
||||||
|
|||||||
62
server.js
62
server.js
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user