fix: calculate stay totals
This commit is contained in:
@@ -8,6 +8,12 @@
|
|||||||
"highlightNewOptions": true,
|
"highlightNewOptions": true,
|
||||||
"markLoginRequiredSources": true
|
"markLoginRequiredSources": true
|
||||||
},
|
},
|
||||||
|
"tripDates": {
|
||||||
|
"checkIn": "2027-02-03",
|
||||||
|
"checkOut": "2027-02-07",
|
||||||
|
"nights": 4,
|
||||||
|
"note": "All per-night or per-day rates should be converted to the full check-in/check-out total for comparison, while preserving the unit rate in the display label."
|
||||||
|
},
|
||||||
"trackedSources": [
|
"trackedSources": [
|
||||||
{
|
{
|
||||||
"id": "hotel-packages",
|
"id": "hotel-packages",
|
||||||
@@ -116,7 +122,9 @@
|
|||||||
"source": "travel site or vendor",
|
"source": "travel site or vendor",
|
||||||
"sourceUrl": "exact result or source URL",
|
"sourceUrl": "exact result or source URL",
|
||||||
"bookingType": "package | standalone | calculated",
|
"bookingType": "package | standalone | calculated",
|
||||||
"priceBasis": "perTraveler | perNight | perPerson | perGroup | totalPackage | perRound | perTable",
|
"priceBasis": "perTraveler | perNight | perDay | perPerson | perGroup | totalPackage | perRound | perTable",
|
||||||
|
"unitPrice": "original unit price when source quotes per night or per day",
|
||||||
|
"tripTotalPrice": "calculated full-stay/check-in-to-check-out price when source quotes per night or per day",
|
||||||
"includedComponents": ["flight", "hotel", "transfer", "golf", "nightlife", "excursion"],
|
"includedComponents": ["flight", "hotel", "transfer", "golf", "nightlife", "excursion"],
|
||||||
"excludedComponents": ["components that must be budgeted separately"],
|
"excludedComponents": ["components that must be budgeted separately"],
|
||||||
"origin": "airport code for flight/package quotes when applicable",
|
"origin": "airport code for flight/package quotes when applicable",
|
||||||
@@ -159,6 +167,7 @@
|
|||||||
"Differentiate bundled package prices from standalone booking prices using bookingType and priceBasis on every price point.",
|
"Differentiate bundled package prices from standalone booking prices using bookingType and priceBasis on every price point.",
|
||||||
"For package quotes, list the included and excluded components so budgets do not double-count flights, hotels, transfers, or resort credits.",
|
"For package quotes, list the included and excluded components so budgets do not double-count flights, hotels, transfers, or resort credits.",
|
||||||
"For standalone quotes, list the exact unit being priced: per night, per traveler, per person, per group, per round, or per table.",
|
"For standalone quotes, list the exact unit being priced: per night, per traveler, per person, per group, per round, or per table.",
|
||||||
|
"For per-night or per-day hotel/activity rates, calculate the total for the expected check-in/check-out dates and preserve the unit rate in unitPrice or displayLabel.",
|
||||||
"Itinerary and budget options are calculated outputs. Recompute them from the freshest current package and standalone component prices instead of treating seed-data.js totals as current.",
|
"Itinerary and budget options are calculated outputs. Recompute them from the freshest current package and standalone component prices instead of treating seed-data.js totals as current.",
|
||||||
"Write a human-readable report to price-watch/latest-report.md on every run.",
|
"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, including per-option price points, derivedItineraries, and budgetScenarios keyed by stable option ids or seed keys.",
|
"Append one machine-readable summary line per run to price-watch/history.jsonl, including per-option price points, derivedItineraries, and budgetScenarios keyed by stable option ids or seed keys.",
|
||||||
|
|||||||
70
server.js
70
server.js
@@ -22,6 +22,8 @@ const DEFAULT_PRICE_HISTORY_FILE = path.join(__dirname, 'price-watch', 'history.
|
|||||||
const PRICE_HISTORY_FILE = process.env.PRICE_HISTORY_FILE
|
const PRICE_HISTORY_FILE = process.env.PRICE_HISTORY_FILE
|
||||||
? path.resolve(process.env.PRICE_HISTORY_FILE)
|
? path.resolve(process.env.PRICE_HISTORY_FILE)
|
||||||
: DEFAULT_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(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -67,6 +69,15 @@ function normalizeSourceLabel(value) {
|
|||||||
return String(value || 'Unknown source').trim() || 'Unknown source';
|
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) {
|
function normalizeBookingType(value) {
|
||||||
const normalized = normalizeKey(value || '');
|
const normalized = normalizeKey(value || '');
|
||||||
if (['package', 'standalone', 'calculated'].includes(normalized)) return normalized;
|
if (['package', 'standalone', 'calculated'].includes(normalized)) return normalized;
|
||||||
@@ -274,6 +285,42 @@ function inferPriceBasis(point, defaults = {}) {
|
|||||||
return null;
|
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() {
|
function loadPriceHistoryState() {
|
||||||
const runs = readJsonLines(PRICE_HISTORY_FILE)
|
const runs = readJsonLines(PRICE_HISTORY_FILE)
|
||||||
.map((entry, index) => {
|
.map((entry, index) => {
|
||||||
@@ -304,19 +351,34 @@ function loadPriceHistoryState() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!key || price === null) return;
|
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 = {
|
const nextPoint = {
|
||||||
checkedAt: run.checkedAt,
|
checkedAt: run.checkedAt,
|
||||||
checkedAtMs: run.checkedAtMs,
|
checkedAtMs: run.checkedAtMs,
|
||||||
runIndex: run.runIndex,
|
runIndex: run.runIndex,
|
||||||
price,
|
price: tripPrice.price,
|
||||||
currency: point.currency || defaults.currency || 'USD',
|
unitPrice: tripPrice.unitPrice,
|
||||||
displayPrice: point.displayPrice || point.priceLabel || point.displayLabel || point.label || defaults.displayPrice || null,
|
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),
|
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'),
|
sourceKey: normalizeKey(point.sourceKey || point.sourceId || point.source || point.sourceLabel || point.vendor || defaults.sourceKey || 'unknown-source'),
|
||||||
sourceUrl: point.sourceUrl || point.url || defaults.sourceUrl || null,
|
sourceUrl: point.sourceUrl || point.url || defaults.sourceUrl || null,
|
||||||
bookingType: normalizeBookingType(inferBookingType(point, defaults)),
|
bookingType: normalizeBookingType(inferBookingType(point, defaults)),
|
||||||
priceBasis: inferPriceBasis(point, defaults),
|
priceBasis,
|
||||||
includedComponents: toTextList(point.includedComponents || point.includesComponents || point.componentsIncluded || defaults.includedComponents),
|
includedComponents: toTextList(point.includedComponents || point.includesComponents || point.componentsIncluded || defaults.includedComponents),
|
||||||
excludedComponents: toTextList(point.excludedComponents || point.componentsExcluded || defaults.excludedComponents),
|
excludedComponents: toTextList(point.excludedComponents || point.componentsExcluded || defaults.excludedComponents),
|
||||||
origin: point.origin || point.originAirport || defaults.origin || null,
|
origin: point.origin || point.originAirport || defaults.origin || null,
|
||||||
|
|||||||
Reference in New Issue
Block a user