fix: calculate stay totals

This commit is contained in:
TopherMayor
2026-04-30 12:07:45 -07:00
parent 071a18e4a4
commit 3e0a462431
2 changed files with 76 additions and 5 deletions

View File

@@ -22,6 +22,8 @@ const DEFAULT_PRICE_HISTORY_FILE = path.join(__dirname, 'price-watch', 'history.
const PRICE_HISTORY_FILE = process.env.PRICE_HISTORY_FILE
? path.resolve(process.env.PRICE_HISTORY_FILE)
: DEFAULT_PRICE_HISTORY_FILE;
const TRIP_CHECK_IN = process.env.TRIP_CHECK_IN || '2027-02-03';
const TRIP_CHECK_OUT = process.env.TRIP_CHECK_OUT || '2027-02-07';
app.use(cors());
app.use(express.json());
@@ -67,6 +69,15 @@ function normalizeSourceLabel(value) {
return String(value || 'Unknown source').trim() || 'Unknown source';
}
function formatCurrencyValue(value, currency = 'USD') {
if (typeof value !== 'number' || !Number.isFinite(value)) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value);
}
function normalizeBookingType(value) {
const normalized = normalizeKey(value || '');
if (['package', 'standalone', 'calculated'].includes(normalized)) return normalized;
@@ -274,6 +285,42 @@ function inferPriceBasis(point, defaults = {}) {
return null;
}
function getTripNightCount() {
const checkInMs = Date.parse(`${TRIP_CHECK_IN}T00:00:00Z`);
const checkOutMs = Date.parse(`${TRIP_CHECK_OUT}T00:00:00Z`);
if (Number.isNaN(checkInMs) || Number.isNaN(checkOutMs) || checkOutMs <= checkInMs) return 1;
return Math.max(1, Math.round((checkOutMs - checkInMs) / (24 * 60 * 60 * 1000)));
}
function isStayUnitPrice(priceBasis) {
const normalized = normalizeKey(priceBasis);
return ['pernight', 'perday', 'nightly', 'daily'].includes(normalized);
}
function normalizeTripPrice({ price, priceBasis, currency, displayPrice }) {
if (!isStayUnitPrice(priceBasis)) {
return {
price,
unitPrice: price,
tripTotalPrice: price,
displayPrice,
tripNights: null,
};
}
const tripNights = getTripNightCount();
const tripTotalPrice = Number((price * tripNights).toFixed(2));
const unitLabel = displayPrice || `${formatCurrencyValue(price, currency)}/night`;
return {
price: tripTotalPrice,
unitPrice: price,
tripTotalPrice,
displayPrice: `${formatCurrencyValue(tripTotalPrice, currency)} stay total (${unitLabel} x ${tripNights} nights)`,
tripNights,
};
}
function loadPriceHistoryState() {
const runs = readJsonLines(PRICE_HISTORY_FILE)
.map((entry, index) => {
@@ -304,19 +351,34 @@ function loadPriceHistoryState() {
});
if (!key || price === null) return;
const currency = point.currency || defaults.currency || 'USD';
const priceBasis = inferPriceBasis(point, defaults);
const rawDisplayPrice = point.displayPrice || point.priceLabel || point.displayLabel || point.label || defaults.displayPrice || null;
const tripPrice = normalizeTripPrice({
price,
priceBasis,
currency,
displayPrice: rawDisplayPrice,
});
const nextPoint = {
checkedAt: run.checkedAt,
checkedAtMs: run.checkedAtMs,
runIndex: run.runIndex,
price,
currency: point.currency || defaults.currency || 'USD',
displayPrice: point.displayPrice || point.priceLabel || point.displayLabel || point.label || defaults.displayPrice || null,
price: tripPrice.price,
unitPrice: tripPrice.unitPrice,
tripTotalPrice: tripPrice.tripTotalPrice,
tripNights: tripPrice.tripNights,
tripCheckIn: tripPrice.tripNights ? TRIP_CHECK_IN : null,
tripCheckOut: tripPrice.tripNights ? TRIP_CHECK_OUT : null,
currency,
displayPrice: tripPrice.displayPrice,
unitDisplayPrice: rawDisplayPrice,
source: normalizeSourceLabel(point.source || point.sourceLabel || point.vendor || defaults.source || null),
sourceKey: normalizeKey(point.sourceKey || point.sourceId || point.source || point.sourceLabel || point.vendor || defaults.sourceKey || 'unknown-source'),
sourceUrl: point.sourceUrl || point.url || defaults.sourceUrl || null,
bookingType: normalizeBookingType(inferBookingType(point, defaults)),
priceBasis: inferPriceBasis(point, defaults),
priceBasis,
includedComponents: toTextList(point.includedComponents || point.includesComponents || point.componentsIncluded || defaults.includedComponents),
excludedComponents: toTextList(point.excludedComponents || point.componentsExcluded || defaults.excludedComponents),
origin: point.origin || point.originAirport || defaults.origin || null,