feat: add price history support
This commit is contained in:
@@ -16,6 +16,7 @@ node server.js
|
|||||||
- **Real-time WebSocket voting** — all clients update instantly
|
- **Real-time WebSocket voting** — all clients update instantly
|
||||||
- **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
|
||||||
- **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
|
||||||
@@ -24,6 +25,7 @@ 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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"notes": [
|
"notes": [
|
||||||
"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.",
|
"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.",
|
||||||
"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."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -621,6 +621,70 @@
|
|||||||
border-color: rgba(0, 212, 255, 0.45);
|
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 */
|
||||||
.vote-bar-bg {
|
.vote-bar-bg {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
@@ -1221,12 +1285,14 @@
|
|||||||
options: [],
|
options: [],
|
||||||
budgetScenarios: [],
|
budgetScenarios: [],
|
||||||
priceUpdatedAt: '',
|
priceUpdatedAt: '',
|
||||||
|
priceHistoryRunCount: 0,
|
||||||
pollsOpen: true,
|
pollsOpen: true,
|
||||||
totalVoters: 0,
|
totalVoters: 0,
|
||||||
wsConnected: false,
|
wsConnected: false,
|
||||||
};
|
};
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let activeTab = 'hotel';
|
let activeTab = 'hotel';
|
||||||
|
let priceRefreshTimer = null;
|
||||||
|
|
||||||
// ── Init ───────────────────────────────────────────────────
|
// ── Init ───────────────────────────────────────────────────
|
||||||
function init() {
|
function init() {
|
||||||
@@ -1249,6 +1315,7 @@
|
|||||||
connectWS();
|
connectWS();
|
||||||
renderTabs();
|
renderTabs();
|
||||||
render();
|
render();
|
||||||
|
schedulePriceRefresh();
|
||||||
|
|
||||||
if (!viewResults) {
|
if (!viewResults) {
|
||||||
document.getElementById('voterNameInput').addEventListener('keydown', e => {
|
document.getElementById('voterNameInput').addEventListener('keydown', e => {
|
||||||
@@ -1265,6 +1332,10 @@
|
|||||||
setTab(state.categories[num - 1].id);
|
setTab(state.categories[num - 1].id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (!document.hidden) refreshPriceState();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WebSocket ──────────────────────────────────────────────
|
// ── WebSocket ──────────────────────────────────────────────
|
||||||
@@ -1300,14 +1371,15 @@
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
if (msg.type === 'init') {
|
if (msg.type === 'init') {
|
||||||
state.categories = msg.categories;
|
state.categories = msg.categories;
|
||||||
state.options = msg.options;
|
state.options = msg.options;
|
||||||
state.budgetScenarios = msg.budgetScenarios || [];
|
state.budgetScenarios = msg.budgetScenarios || [];
|
||||||
state.priceUpdatedAt = msg.priceUpdatedAt || '';
|
state.priceUpdatedAt = msg.priceUpdatedAt || '';
|
||||||
state.pollsOpen = msg.pollsOpen;
|
state.priceHistoryRunCount = msg.priceHistoryRunCount || 0;
|
||||||
state.totalVoters = msg.totalVoters;
|
state.pollsOpen = msg.pollsOpen;
|
||||||
renderTabs();
|
state.totalVoters = msg.totalVoters;
|
||||||
render();
|
renderTabs();
|
||||||
|
render();
|
||||||
} else if (msg.type === 'vote_update') {
|
} else if (msg.type === 'vote_update') {
|
||||||
msg.results.forEach(r => {
|
msg.results.forEach(r => {
|
||||||
const opt = state.options.find(o => o.id === r.id);
|
const opt = state.options.find(o => o.id === r.id);
|
||||||
@@ -1368,12 +1440,143 @@
|
|||||||
badge.className = 'polls-badge ' + (state.pollsOpen ? 'open' : 'closed');
|
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) {
|
function getVoteEntries(opt) {
|
||||||
if (Array.isArray(opt.votes)) return opt.votes;
|
if (Array.isArray(opt.votes)) return opt.votes;
|
||||||
if (Array.isArray(opt.voters)) return opt.voters.map(name => ({ name }));
|
if (Array.isArray(opt.voters)) return opt.voters.map(name => ({ name }));
|
||||||
return [];
|
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 ────────────────────────────────────────────
|
// ── Name modal ────────────────────────────────────────────
|
||||||
function submitName() {
|
function submitName() {
|
||||||
const name = document.getElementById('voterNameInput').value.trim();
|
const name = document.getElementById('voterNameInput').value.trim();
|
||||||
@@ -1553,6 +1756,7 @@
|
|||||||
${opt.details && opt.details.length ? `
|
${opt.details && opt.details.length ? `
|
||||||
<div class="itin-details">${opt.details.map(d => `<span class="itin-tag">${d}</span>`).join('')}</div>
|
<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-bg">
|
||||||
<div class="vote-bar-fill ${catClass}" style="width:${votePct}%"></div>
|
<div class="vote-bar-fill ${catClass}" style="width:${votePct}%"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1575,7 +1779,7 @@
|
|||||||
<section class="budget-board">
|
<section class="budget-board">
|
||||||
<h2>💸 Budget Cheat Sheet</h2>
|
<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>
|
<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">
|
<div class="budget-grid">
|
||||||
${scenarios.map(scenario => `
|
${scenarios.map(scenario => `
|
||||||
<article class="budget-card">
|
<article class="budget-card">
|
||||||
|
|||||||
194
server.js
194
server.js
@@ -18,6 +18,10 @@ const DATA_DIR = process.env.DATA_DIR
|
|||||||
const DATA_FILE = process.env.DATA_FILE
|
const DATA_FILE = process.env.DATA_FILE
|
||||||
? path.resolve(process.env.DATA_FILE)
|
? path.resolve(process.env.DATA_FILE)
|
||||||
: path.join(DATA_DIR, 'votes.json');
|
: path.join(DATA_DIR, 'votes.json');
|
||||||
|
const DEFAULT_PRICE_HISTORY_FILE = path.join(__dirname, 'price-watch', 'history.jsonl');
|
||||||
|
const PRICE_HISTORY_FILE = process.env.PRICE_HISTORY_FILE
|
||||||
|
? path.resolve(process.env.PRICE_HISTORY_FILE)
|
||||||
|
: DEFAULT_PRICE_HISTORY_FILE;
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -51,6 +55,160 @@ function saveData(nextData) {
|
|||||||
fs.writeFileSync(DATA_FILE, JSON.stringify(nextData, null, 2));
|
fs.writeFileSync(DATA_FILE, JSON.stringify(nextData, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeKey(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonLines(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) return [];
|
||||||
|
|
||||||
|
return fs.readFileSync(filePath, 'utf8')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionHistoryKeys(option) {
|
||||||
|
const nameKey = normalizeKey(option.name);
|
||||||
|
const categoryNameKey = option.categoryId && nameKey ? `${option.categoryId}-${nameKey}` : '';
|
||||||
|
|
||||||
|
return [...new Set([
|
||||||
|
option.seedKey,
|
||||||
|
option.id,
|
||||||
|
option.priceKey,
|
||||||
|
option.optionKey,
|
||||||
|
option.slug,
|
||||||
|
categoryNameKey,
|
||||||
|
nameKey,
|
||||||
|
].filter(Boolean).map(normalizeKey))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNumericPrice(point) {
|
||||||
|
const candidates = [
|
||||||
|
point.price,
|
||||||
|
point.value,
|
||||||
|
point.amount,
|
||||||
|
point.perPerson,
|
||||||
|
point.groupTotal,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === 'number' && Number.isFinite(candidate)) return candidate;
|
||||||
|
if (typeof candidate === 'string') {
|
||||||
|
const parsed = Number(candidate.replace(/[^0-9.-]/g, ''));
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPriceHistoryState() {
|
||||||
|
const runs = readJsonLines(PRICE_HISTORY_FILE)
|
||||||
|
.map((entry, index) => {
|
||||||
|
const checkedAtRaw = entry.checkedAt || entry.checked_at || entry.runAt || entry.timestamp || entry.date || null;
|
||||||
|
const checkedAtMs = checkedAtRaw ? Date.parse(checkedAtRaw) : Date.now() + index;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
checkedAt: Number.isNaN(checkedAtMs) ? null : new Date(checkedAtMs).toISOString(),
|
||||||
|
checkedAtMs: Number.isNaN(checkedAtMs) ? Date.now() + index : checkedAtMs,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.checkedAtMs - b.checkedAtMs);
|
||||||
|
|
||||||
|
runs.forEach((run, index) => {
|
||||||
|
run.runIndex = index;
|
||||||
|
});
|
||||||
|
|
||||||
|
const seriesByKey = new Map();
|
||||||
|
|
||||||
|
runs.forEach((run) => {
|
||||||
|
const pricePoints = Array.isArray(run.optionPrices)
|
||||||
|
? run.optionPrices
|
||||||
|
: Array.isArray(run.prices)
|
||||||
|
? run.prices
|
||||||
|
: Array.isArray(run.trackedPrices)
|
||||||
|
? run.trackedPrices
|
||||||
|
: [];
|
||||||
|
|
||||||
|
pricePoints.forEach((point) => {
|
||||||
|
const key = normalizeKey(
|
||||||
|
point.optionKey || point.optionId || point.seedKey || point.slug || point.key || point.name,
|
||||||
|
);
|
||||||
|
const price = extractNumericPrice(point);
|
||||||
|
|
||||||
|
if (!key || price === null) return;
|
||||||
|
|
||||||
|
const nextPoint = {
|
||||||
|
checkedAt: run.checkedAt,
|
||||||
|
checkedAtMs: run.checkedAtMs,
|
||||||
|
runIndex: run.runIndex,
|
||||||
|
price,
|
||||||
|
currency: point.currency || 'USD',
|
||||||
|
displayPrice: point.displayPrice || point.priceLabel || point.label || null,
|
||||||
|
source: point.source || null,
|
||||||
|
sourceUrl: point.sourceUrl || point.url || null,
|
||||||
|
note: point.note || point.description || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!seriesByKey.has(key)) seriesByKey.set(key, []);
|
||||||
|
seriesByKey.get(key).push(nextPoint);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
seriesByKey.forEach((series) => {
|
||||||
|
series.sort((a, b) => a.runIndex - b.runIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
runs,
|
||||||
|
seriesByKey,
|
||||||
|
latestCheckedAt: runs.at(-1)?.checkedAt || null,
|
||||||
|
totalRuns: runs.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriceHistoryForOption(option, priceHistoryState) {
|
||||||
|
const optionKeys = getOptionHistoryKeys(option);
|
||||||
|
for (const key of optionKeys) {
|
||||||
|
const series = priceHistoryState.seriesByKey.get(key);
|
||||||
|
if (series && series.length) {
|
||||||
|
return series;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function decorateOptionWithPriceHistory(option, priceHistoryState) {
|
||||||
|
const priceHistory = getPriceHistoryForOption(option, priceHistoryState);
|
||||||
|
const latestPricePoint = priceHistory.at(-1) || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
priceHistory,
|
||||||
|
latestPricePoint,
|
||||||
|
currentPrice: latestPricePoint?.price ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decorateOptionsWithPriceHistory(options, priceHistoryState) {
|
||||||
|
return options.map((option) => decorateOptionWithPriceHistory(option, priceHistoryState));
|
||||||
|
}
|
||||||
|
|
||||||
function approvedOptionsWithVoteSummary() {
|
function approvedOptionsWithVoteSummary() {
|
||||||
return data.options
|
return data.options
|
||||||
.filter((option) => option.approved)
|
.filter((option) => option.approved)
|
||||||
@@ -69,15 +227,19 @@ function broadcast(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildRealtimeSnapshot() {
|
function buildRealtimeSnapshot() {
|
||||||
|
const priceHistoryState = loadPriceHistoryState();
|
||||||
|
const approvedOptions = data.options.filter((option) => option.approved);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'init',
|
type: 'init',
|
||||||
pollsOpen: data.pollsOpen,
|
pollsOpen: data.pollsOpen,
|
||||||
categories: data.categories,
|
categories: data.categories,
|
||||||
options: data.options.filter((option) => option.approved),
|
options: decorateOptionsWithPriceHistory(approvedOptions, priceHistoryState),
|
||||||
results: approvedOptionsWithVoteSummary(),
|
results: approvedOptionsWithVoteSummary(),
|
||||||
totalVoters: data.voters.length,
|
totalVoters: data.voters.length,
|
||||||
budgetScenarios: data.budgetScenarios || [],
|
budgetScenarios: data.budgetScenarios || [],
|
||||||
priceUpdatedAt: data.priceUpdatedAt || null,
|
priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
|
||||||
|
priceHistoryRunCount: priceHistoryState.totalRuns,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,19 +271,24 @@ app.get('/api/categories', (req, res) => {
|
|||||||
app.get('/api/options', (req, res) => {
|
app.get('/api/options', (req, res) => {
|
||||||
const { category, includeUnapproved } = req.query;
|
const { category, includeUnapproved } = req.query;
|
||||||
let options = data.options;
|
let options = data.options;
|
||||||
|
const priceHistoryState = loadPriceHistoryState();
|
||||||
|
|
||||||
if (category) options = options.filter((option) => option.categoryId === category);
|
if (category) options = options.filter((option) => option.categoryId === category);
|
||||||
if (!includeUnapproved) options = options.filter((option) => option.approved);
|
if (!includeUnapproved) options = options.filter((option) => option.approved);
|
||||||
|
|
||||||
res.json(options);
|
res.json(decorateOptionsWithPriceHistory(options, priceHistoryState));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/results', (req, res) => {
|
app.get('/api/results', (req, res) => {
|
||||||
|
const priceHistoryState = loadPriceHistoryState();
|
||||||
const results = data.categories.map((category) => ({
|
const results = data.categories.map((category) => ({
|
||||||
...category,
|
...category,
|
||||||
options: data.options
|
options: data.options
|
||||||
.filter((option) => option.approved && option.categoryId === category.id)
|
.filter((option) => option.approved && option.categoryId === category.id)
|
||||||
.map((option) => ({ ...option, voteCount: option.votes.length })),
|
.map((option) => ({
|
||||||
|
...decorateOptionWithPriceHistory(option, priceHistoryState),
|
||||||
|
voteCount: option.votes.length,
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -129,17 +296,32 @@ app.get('/api/results', (req, res) => {
|
|||||||
results,
|
results,
|
||||||
totalVoters: data.voters.length,
|
totalVoters: data.voters.length,
|
||||||
budgetScenarios: data.budgetScenarios || [],
|
budgetScenarios: data.budgetScenarios || [],
|
||||||
priceUpdatedAt: data.priceUpdatedAt || null,
|
priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
|
||||||
|
priceHistoryRunCount: priceHistoryState.totalRuns,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/budgets', (req, res) => {
|
app.get('/api/budgets', (req, res) => {
|
||||||
|
const priceHistoryState = loadPriceHistoryState();
|
||||||
res.json({
|
res.json({
|
||||||
updatedAt: data.priceUpdatedAt || null,
|
updatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
|
||||||
scenarios: data.budgetScenarios || [],
|
scenarios: data.budgetScenarios || [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/price-history', (req, res) => {
|
||||||
|
const priceHistoryState = loadPriceHistoryState();
|
||||||
|
const seriesByOption = Object.fromEntries(
|
||||||
|
[...priceHistoryState.seriesByKey.entries()],
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
latestCheckedAt: priceHistoryState.latestCheckedAt,
|
||||||
|
totalRuns: priceHistoryState.totalRuns,
|
||||||
|
seriesByOption,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/vote', (req, res) => {
|
app.post('/api/vote', (req, res) => {
|
||||||
const { optionId, voterName } = req.body;
|
const { optionId, voterName } = req.body;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user