feat: add price history support
This commit is contained in:
194
server.js
194
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user