diff --git a/README.md b/README.md index d47174b..ac74f7d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ node server.js - **Real-time WebSocket voting** — all clients update instantly - **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 - **Add suggestions** — anyone can propose new venues - **Admin approval** — pending options require approval before going live - **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. 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. diff --git a/price-watch/watch-targets.json b/price-watch/watch-targets.json index c4eb625..10fa4fa 100644 --- a/price-watch/watch-targets.json +++ b/price-watch/watch-targets.json @@ -38,7 +38,7 @@ "notes": [ "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.", + "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." ] } diff --git a/public/index.html b/public/index.html index 1c2423e..54d3204 100644 --- a/public/index.html +++ b/public/index.html @@ -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 ` +
+ Price tracking appears after the next automation run. +
+ `; + } + + 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 ` +
+
+
+
Automation price trail
+
${latest.displayPrice || formatCurrency(latest.price, latest.currency) || 'Tracked price'}
+
+
${series.length} point${series.length === 1 ? '' : 's'}${checkedLabel ? ` · ${checkedLabel}` : ''}
+
+ + + + + + + + + + + + + + ${points.map((point, index) => ` + + ${(point.displayPrice || formatCurrency(point.price, point.currency) || 'Tracked price')} · ${formatTrackedDate(point.checkedAt) || 'automation run'} + + `).join('')} + +
Change since first check: ${deltaText}
+
+ `; + } + // ── Name modal ──────────────────────────────────────────── function submitName() { const name = document.getElementById('voterNameInput').value.trim(); @@ -1553,6 +1756,7 @@ ${opt.details && opt.details.length ? `
${opt.details.map(d => `${d}`).join('')}
` : ''} + ${renderPriceTrend(opt)}
@@ -1575,7 +1779,7 @@

💸 Budget Cheat Sheet

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.

-
Pricing research last refreshed ${state.priceUpdatedAt || 'recently'}
+
Automation pricing last refreshed ${state.priceUpdatedAt || 'recently'}
${scenarios.map(scenario => `
diff --git a/server.js b/server.js index 4153640..d6e2e5b 100644 --- a/server.js +++ b/server.js @@ -18,6 +18,10 @@ const DATA_DIR = process.env.DATA_DIR const DATA_FILE = process.env.DATA_FILE ? path.resolve(process.env.DATA_FILE) : 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(express.json()); @@ -51,6 +55,160 @@ function saveData(nextData) { 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() { return data.options .filter((option) => option.approved) @@ -69,15 +227,19 @@ function broadcast(payload) { } function buildRealtimeSnapshot() { + const priceHistoryState = loadPriceHistoryState(); + const approvedOptions = data.options.filter((option) => option.approved); + return { type: 'init', pollsOpen: data.pollsOpen, categories: data.categories, - options: data.options.filter((option) => option.approved), + options: decorateOptionsWithPriceHistory(approvedOptions, priceHistoryState), results: approvedOptionsWithVoteSummary(), totalVoters: data.voters.length, 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) => { const { category, includeUnapproved } = req.query; let options = data.options; + const priceHistoryState = loadPriceHistoryState(); if (category) options = options.filter((option) => option.categoryId === category); if (!includeUnapproved) options = options.filter((option) => option.approved); - res.json(options); + res.json(decorateOptionsWithPriceHistory(options, priceHistoryState)); }); app.get('/api/results', (req, res) => { + const priceHistoryState = loadPriceHistoryState(); const results = data.categories.map((category) => ({ ...category, options: data.options .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({ @@ -129,17 +296,32 @@ app.get('/api/results', (req, res) => { results, totalVoters: data.voters.length, budgetScenarios: data.budgetScenarios || [], - priceUpdatedAt: data.priceUpdatedAt || null, + priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null, + priceHistoryRunCount: priceHistoryState.totalRuns, }); }); app.get('/api/budgets', (req, res) => { + const priceHistoryState = loadPriceHistoryState(); res.json({ - updatedAt: data.priceUpdatedAt || null, + updatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null, 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) => { const { optionId, voterName } = req.body;