diff --git a/price-watch/watch-targets.json b/price-watch/watch-targets.json index afa711f..f673673 100644 --- a/price-watch/watch-targets.json +++ b/price-watch/watch-targets.json @@ -8,6 +8,12 @@ "highlightNewOptions": 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": [ { "id": "hotel-packages", @@ -116,7 +122,9 @@ "source": "travel site or vendor", "sourceUrl": "exact result or source URL", "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"], "excludedComponents": ["components that must be budgeted separately"], "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.", "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 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.", "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.", diff --git a/server.js b/server.js index 9603dd9..26b5c61 100644 --- a/server.js +++ b/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 ? 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,