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 `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${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;