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

194
server.js
View File

@@ -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;