feat: add price history support

This commit is contained in:
TopherMayor
2026-04-30 10:52:21 -07:00
parent 516bcd0a44
commit 899c9cb30b
4 changed files with 404 additions and 16 deletions

View File

@@ -621,6 +621,70 @@
border-color: rgba(0, 212, 255, 0.45);
}
.price-trend {
margin: 10px 0 8px;
padding: 10px 12px;
border: 1px solid rgba(0, 212, 255, 0.16);
border-radius: 12px;
background:
linear-gradient(135deg, rgba(0, 212, 255, 0.08), rgba(19, 22, 31, 0.94) 62%, rgba(251, 191, 36, 0.04));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.price-trend-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.price-trend-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.9px;
color: var(--text-muted);
}
.price-trend-value {
margin-top: 2px;
font-size: 0.92rem;
font-weight: 700;
color: #fff;
}
.price-trend-sub {
margin-top: 2px;
font-size: 0.66rem;
color: var(--text-muted);
}
.price-trend-svg {
width: 100%;
height: 60px;
display: block;
margin-top: 8px;
overflow: visible;
}
.price-trend-empty {
margin-top: 8px;
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed rgba(255, 255, 255, 0.08);
border-radius: 10px;
color: var(--text-muted);
font-size: 0.7rem;
letter-spacing: 0.1px;
}
.price-trend-points {
fill: #0b0d14;
stroke: rgba(255, 255, 255, 0.85);
stroke-width: 1.5;
}
.price-trend-line {
stroke-width: 2.4;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 6px rgba(0, 212, 255, 0.12));
}
/* Vote bar */
.vote-bar-bg {
height: 4px;
@@ -1221,12 +1285,14 @@
options: [],
budgetScenarios: [],
priceUpdatedAt: '',
priceHistoryRunCount: 0,
pollsOpen: true,
totalVoters: 0,
wsConnected: false,
};
let ws = null;
let activeTab = 'hotel';
let priceRefreshTimer = null;
// ── Init ───────────────────────────────────────────────────
function init() {
@@ -1249,6 +1315,7 @@
connectWS();
renderTabs();
render();
schedulePriceRefresh();
if (!viewResults) {
document.getElementById('voterNameInput').addEventListener('keydown', e => {
@@ -1265,6 +1332,10 @@
setTab(state.categories[num - 1].id);
}
});
document.addEventListener('visibilitychange', () => {
if (!document.hidden) refreshPriceState();
});
}
// ── WebSocket ──────────────────────────────────────────────
@@ -1300,14 +1371,15 @@
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'init') {
state.categories = msg.categories;
state.options = msg.options;
state.budgetScenarios = msg.budgetScenarios || [];
state.priceUpdatedAt = msg.priceUpdatedAt || '';
state.pollsOpen = msg.pollsOpen;
state.totalVoters = msg.totalVoters;
renderTabs();
render();
state.categories = msg.categories;
state.options = msg.options;
state.budgetScenarios = msg.budgetScenarios || [];
state.priceUpdatedAt = msg.priceUpdatedAt || '';
state.priceHistoryRunCount = msg.priceHistoryRunCount || 0;
state.pollsOpen = msg.pollsOpen;
state.totalVoters = msg.totalVoters;
renderTabs();
render();
} else if (msg.type === 'vote_update') {
msg.results.forEach(r => {
const opt = state.options.find(o => o.id === r.id);
@@ -1368,12 +1440,143 @@
badge.className = 'polls-badge ' + (state.pollsOpen ? 'open' : 'closed');
}
function schedulePriceRefresh() {
if (priceRefreshTimer) clearInterval(priceRefreshTimer);
priceRefreshTimer = setInterval(refreshPriceState, 10 * 60 * 1000);
}
async function refreshPriceState() {
try {
const [optionsRes, historyRes] = await Promise.all([
fetch('/api/options?includeUnapproved=true'),
fetch('/api/price-history'),
]);
if (!optionsRes.ok || !historyRes.ok) return;
const [options, history] = await Promise.all([
optionsRes.json(),
historyRes.json(),
]);
state.options = options;
state.priceUpdatedAt = history.latestCheckedAt || state.priceUpdatedAt;
state.priceHistoryRunCount = history.totalRuns || state.priceHistoryRunCount;
renderTabs();
render();
if (mapInitialized) mapRefreshMarkers();
} catch (error) {
console.warn('Price refresh failed:', error);
}
}
function getVoteEntries(opt) {
if (Array.isArray(opt.votes)) return opt.votes;
if (Array.isArray(opt.voters)) return opt.voters.map(name => ({ name }));
return [];
}
function formatCurrency(value, currency = 'USD') {
if (typeof value !== 'number' || Number.isNaN(value)) return '';
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency,
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value);
}
function formatTrackedDate(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function renderPriceTrend(opt) {
const series = Array.isArray(opt.priceHistory)
? opt.priceHistory.filter(point => typeof point.price === 'number' && !Number.isNaN(point.price))
: [];
if (series.length === 0) {
return `
<div class="price-trend-empty">
Price tracking appears after the next automation run.
</div>
`;
}
const width = 260;
const height = 60;
const paddingX = 6;
const paddingY = 8;
const innerWidth = width - (paddingX * 2);
const innerHeight = height - (paddingY * 2);
const totalRuns = Math.max(state.priceHistoryRunCount || series.length, 1);
const chartKey = String(opt.id || opt.name || 'price-trend')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const prices = series.map(point => point.price);
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const range = maxPrice - minPrice || 1;
const points = series.map((point, index) => {
const runIndex = typeof point.runIndex === 'number' ? point.runIndex : index;
const x = paddingX + ((totalRuns === 1 ? 0.5 : runIndex / (totalRuns - 1)) * innerWidth);
const y = paddingY + innerHeight - ((point.price - minPrice) / range) * innerHeight;
return { ...point, x, y };
});
const path = points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' ');
const areaPath = [
`M ${points[0].x.toFixed(1)} ${height - paddingY}`,
...points.map((point) => `L ${point.x.toFixed(1)} ${point.y.toFixed(1)}`),
`L ${points[points.length - 1].x.toFixed(1)} ${height - paddingY}`,
'Z',
].join(' ');
const latest = points[points.length - 1];
const first = points[0];
const delta = latest.price - first.price;
const deltaText = delta === 0
? 'flat from first check'
: `${delta > 0 ? '+' : ''}${formatCurrency(Math.abs(delta), latest.currency)}`;
const checkedLabel = latest.checkedAt ? formatTrackedDate(latest.checkedAt) : '';
return `
<div class="price-trend">
<div class="price-trend-header">
<div>
<div class="price-trend-label">Automation price trail</div>
<div class="price-trend-value">${latest.displayPrice || formatCurrency(latest.price, latest.currency) || 'Tracked price'}</div>
</div>
<div class="price-trend-sub">${series.length} point${series.length === 1 ? '' : 's'}${checkedLabel ? ` · ${checkedLabel}` : ''}</div>
</div>
<svg class="price-trend-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-label="Price trend line graph">
<defs>
<linearGradient id="priceTrendStroke-${chartKey}" x1="0%" x2="100%" y1="0%" y2="0%">
<stop offset="0%" stop-color="#00d4ff" />
<stop offset="100%" stop-color="#fbbf24" />
</linearGradient>
<linearGradient id="priceTrendFill-${chartKey}" x1="0%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.18" />
<stop offset="100%" stop-color="#00d4ff" stop-opacity="0.02" />
</linearGradient>
</defs>
<path d="${areaPath}" fill="url(#priceTrendFill-${chartKey})" opacity="0.8"></path>
<path d="${path}" class="price-trend-line" stroke="url(#priceTrendStroke-${chartKey})"></path>
${points.map((point, index) => `
<circle class="price-trend-points" cx="${point.x.toFixed(1)}" cy="${point.y.toFixed(1)}" r="${index === points.length - 1 ? 3.4 : 2.4}">
<title>${(point.displayPrice || formatCurrency(point.price, point.currency) || 'Tracked price')} · ${formatTrackedDate(point.checkedAt) || 'automation run'}</title>
</circle>
`).join('')}
</svg>
<div class="price-trend-sub">Change since first check: ${deltaText}</div>
</div>
`;
}
// ── Name modal ────────────────────────────────────────────
function submitName() {
const name = document.getElementById('voterNameInput').value.trim();
@@ -1553,6 +1756,7 @@
${opt.details && opt.details.length ? `
<div class="itin-details">${opt.details.map(d => `<span class="itin-tag">${d}</span>`).join('')}</div>
` : ''}
${renderPriceTrend(opt)}
<div class="vote-bar-bg">
<div class="vote-bar-fill ${catClass}" style="width:${votePct}%"></div>
</div>
@@ -1575,7 +1779,7 @@
<section class="budget-board">
<h2>💸 Budget Cheat Sheet</h2>
<p>These are planning numbers for the group to compare tracks quickly before anyone starts buying flights. They use current live price signals and bake in the shared-cost difference between 8, 10, and 12 guys.</p>
<div class="budget-stamp">Pricing research last refreshed ${state.priceUpdatedAt || 'recently'}</div>
<div class="budget-stamp">Automation pricing last refreshed ${state.priceUpdatedAt || 'recently'}</div>
<div class="budget-grid">
${scenarios.map(scenario => `
<article class="budget-card">