diff --git a/backend/api/competitors.py b/backend/api/competitors.py index 05f1196..deb67c2 100644 --- a/backend/api/competitors.py +++ b/backend/api/competitors.py @@ -671,6 +671,32 @@ async def get_competitor_matrix( 'scraped_at': row.scraped_at.isoformat() if row.scraped_at else None, } + # Last available rate per hotel+date (used for sold-out / past-date display) + last_avail_result = await db.execute( + text(f""" + SELECT DISTINCT ON (r.hotel_id, r.rate_date) + r.hotel_id, + r.rate_date, + r.rate_gross AS last_available_rate + FROM booking_com_rates r + JOIN booking_com_hotels h ON r.hotel_id = h.id + WHERE {tier_filter} + AND h.is_active = TRUE + AND r.rate_date >= :from_date AND r.rate_date <= :to_date + AND r.availability_status = 'available' + AND r.rate_gross IS NOT NULL + ORDER BY r.hotel_id, r.rate_date, r.scraped_at DESC + """), + {'from_date': start, 'to_date': end} + ) + last_avail: Dict[int, Dict[str, float]] = {} + for row in last_avail_result.fetchall(): + last_avail.setdefault(row.hotel_id, {})[row.rate_date.isoformat()] = float(row.last_available_rate) + + for hotel_id, date_map in rates_by_hotel.items(): + for rate_date, cell in date_map.items(): + cell['last_available_rate'] = last_avail.get(hotel_id, {}).get(rate_date) + # Most recent scrape touching each date, across ALL hotels — a partial # scrape may refresh the date without touching the displayed hotels, so # per-cell scraped_at can lag behind this column-level timestamp diff --git a/frontend/src/pages/MarketView.tsx b/frontend/src/pages/MarketView.tsx index 8421a41..1544c75 100644 --- a/frontend/src/pages/MarketView.tsx +++ b/frontend/src/pages/MarketView.tsx @@ -82,6 +82,7 @@ interface RateMatrixResponse { rates: Record { const [showDirect, setShowDirect] = useState(false) const [historyModal, setHistoryModal] = useState(null) const queryClient = useQueryClient() + const todayStr = fmtDate(new Date()) // The scrape endpoint returns immediately (runs in background), and the // scraper handles one job at a time, so queued jobs (single dates or @@ -1542,7 +1544,7 @@ const RateMatrixTab: React.FC = () => { > {scrapingDate === d ? '...' : queuePos >= 0 ? `#${queuePos + 1}` : '↻'} - {locationName && ( + {locationName && d >= todayStr && ( { const rate = hotelRates[d] const isAvailable = rate?.availability_status === 'available' const isSoldOut = rate?.availability_status === 'sold_out' + const isPast = d < todayStr + const isSoldOrPast = isSoldOut || isPast let cellStyle: React.CSSProperties = styles.matrixCellEmpty if (rate) { - if (isAvailable && rate.rate_gross) { + if (isPast) { + cellStyle = styles.matrixCellPast + } else if (isAvailable && rate.rate_gross) { cellStyle = styles.matrixCellAvailable } else if (isSoldOut) { cellStyle = styles.matrixCellSoldOut @@ -1600,21 +1606,43 @@ const RateMatrixTab: React.FC = () => { // Stale = this cell wasn't touched by the column's most // recent scrape (e.g. the hotel was on a page that failed) const colLatest = scrapedAtByDate[d] - const isStale = !!(rate?.scraped_at && colLatest && + const isStale = !isPast && !!(rate?.scraped_at && colLatest && new Date(colLatest).getTime() - new Date(rate.scraped_at).getTime() > 60 * 60 * 1000) + const staleMark = isStale ? '*' : '' + + const lastAvailRate = rate?.last_available_rate + ? formatCurrency(rate.last_available_rate) + : null + + // For sold-out / past: show last known available rate with strikethrough + let rateText = '' + let strikethrough = false + if (!rate) { + rateText = '' + } else if (isSoldOrPast && lastAvailRate) { + rateText = lastAvailRate + staleMark + strikethrough = true + } else if (isSoldOrPast) { + rateText = isSoldOut ? 'Sold' : '—' + } else if (isAvailable && rate.rate_gross) { + rateText = formatCurrency(rate.rate_gross) + staleMark + } else { + rateText = '—' + } const tooltip = rate ? [ rate.room_type, rate.breakfast_included ? 'Breakfast incl.' : null, rate.free_cancellation ? 'Free cancel' : null, rate.rooms_left ? `${rate.rooms_left} left` : null, + lastAvailRate && isSoldOrPast ? `Last available: ${lastAvailRate}` : null, rate.scraped_at ? `Scraped: ${new Date(rate.scraped_at).toLocaleString('en-GB', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}` : null, isStale ? 'Not updated in latest scrape' : null, ].filter(Boolean).join(' | ') : '' - // Build booking.com link: strip existing date/guest params, add ours + // Build booking.com link let bookingUrl: string | null = null - if (hotel.booking_com_url) { + if (!isPast && hotel.booking_com_url) { const checkin = d const co = new Date(d + 'T00:00:00') co.setDate(co.getDate() + 1) @@ -1628,35 +1656,27 @@ const RateMatrixTab: React.FC = () => { url.searchParams.set('group_adults', '2') bookingUrl = url.toString() } catch { - // Fallback if URL parsing fails bookingUrl = hotel.booking_com_url } } - const staleMark = isStale ? '*' : '' - const rawContent = rate ? ( - isAvailable && rate.rate_gross - ? formatCurrency(rate.rate_gross) + staleMark - : isSoldOut - ? 'Sold' + staleMark - : '-' - ) : '' - - // Price index badge for competitor rows + // Price index badge for competitor rows (use last_available_rate for sold/past) + const rateForIndex = isSoldOrPast ? rate?.last_available_rate : rate?.rate_gross let priceIndexBadge: React.ReactNode = null - if (hotel.tier === 'competitor' && rate?.rate_gross && ownRateByDate[d]) { - const delta = Math.round((rate.rate_gross / ownRateByDate[d]! - 1) * 100) + if (hotel.tier === 'competitor' && rateForIndex && ownRateByDate[d]) { + const delta = Math.round((rateForIndex / ownRateByDate[d]! - 1) * 100) const bg = delta > 5 ? '#dcfce7' : delta < -15 ? '#fee2e2' : delta < -5 ? '#fef3c7' : '#f1f5f9' const fg = delta > 5 ? '#16a34a' : delta < -15 ? '#dc2626' : delta < -5 ? '#d97706' : '#64748b' priceIndexBadge = ( - + {delta > 0 ? '+' : ''}{delta}% ) } - const cellContent = rawContent + const showEye = !isPast && !!bookingUrl && !!rate + const showHistory = !!rate const isRowH = hoveredCell?.row === rowIdx const isColH = hoveredCell?.col === colIdx @@ -1677,35 +1697,37 @@ const RateMatrixTab: React.FC = () => { onMouseEnter={() => onCellEnter(rowIdx, colIdx)} onMouseLeave={onCellLeave} > -
- {cellContent} - {(bookingUrl || (rate?.rate_gross && isAvailable)) && ( -
- {bookingUrl && ( +
+ + {rateText} + + {priceIndexBadge} + {(showEye || showHistory) && ( +
+ {showEye && ( e.stopPropagation()} > - + )} - {rate?.rate_gross && isAvailable && ( + {showHistory && ( )}
)}
- {priceIndexBadge} ) })} @@ -1763,8 +1785,9 @@ const RateMatrixTab: React.FC = () => {
Legend: Available - Sold Out + Sold Out (strikethrough = last rate) No Rate + Past No Data Own @@ -2180,24 +2203,21 @@ const styles: Record = { fontWeight: 600, }, matrixCellSoldOut: { - background: '#fee2e2', - color: 'var(--danger)', - }, - matrixCellNoRate: { background: '#fef3c7', color: '#d97706', }, + matrixCellNoRate: { + background: '#fef9ec', + color: '#b45309', + }, matrixCellEmpty: { background: 'var(--body-bg)', color: 'var(--text-mid)', }, - matrixCellLink: { - color: 'inherit', - textDecoration: 'none', - display: 'block', - width: '100%', - height: '100%', - } as React.CSSProperties, + matrixCellPast: { + background: '#f1f5f9', + color: '#94a3b8', + }, crosshairHighlight: { boxShadow: 'inset 0 0 0 1px #1a1a2e33', background: '#1a1a2e08',