diff --git a/backend/api/competitors.py b/backend/api/competitors.py index 5e41cfb..74e66bf 100644 --- a/backend/api/competitors.py +++ b/backend/api/competitors.py @@ -520,6 +520,23 @@ async def get_competitor_matrix( 'scraped_at': row.scraped_at.isoformat() if row.scraped_at else None, } + # 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 + last_scraped_result = await db.execute( + text(""" + SELECT rate_date, MAX(scraped_at) AS last_scraped + FROM booking_com_rates + WHERE rate_date >= :from_date AND rate_date <= :to_date + GROUP BY rate_date + """), + {'from_date': start, 'to_date': end} + ) + last_scraped = { + row.rate_date.isoformat(): row.last_scraped.isoformat() if row.last_scraped else None + for row in last_scraped_result.fetchall() + } + # Build date list dates = [] current = start @@ -532,7 +549,8 @@ async def get_competitor_matrix( 'to_date': end.isoformat(), 'dates': dates, 'hotels': hotels, - 'rates': rates_by_hotel + 'rates': rates_by_hotel, + 'last_scraped': last_scraped } diff --git a/frontend/src/pages/MarketView.tsx b/frontend/src/pages/MarketView.tsx index 7b7c086..edd63f3 100644 --- a/frontend/src/pages/MarketView.tsx +++ b/frontend/src/pages/MarketView.tsx @@ -69,6 +69,7 @@ interface RateMatrixResponse { from_date: string to_date: string dates: string[] + last_scraped?: Record hotels: { id: number name: string @@ -1045,11 +1046,14 @@ const RateMatrixTab: React.FC = () => { }) }, [data?.hotels]) - // Compute latest scraped_at per date column across all hotels + // Latest scrape touching each date column, across ALL hotels (the backend + // map covers hotels outside the matrix too — a partial scrape can refresh + // a date without touching the displayed hotels). Fallback: compute from + // the visible cells. const scrapedAtByDate = useMemo(() => { const result: Record = {} for (const d of dates) { - let latest: string | null = null + let latest: string | null = data?.last_scraped?.[d] ?? null for (const hotel of hotels) { const rate = (rates[hotel.id] || {})[d] if (rate?.scraped_at) { @@ -1061,7 +1065,7 @@ const RateMatrixTab: React.FC = () => { result[d] = latest } return result - }, [dates, hotels, rates]) + }, [dates, hotels, rates, data?.last_scraped]) // Own hotel rate by date (first 'own' tier hotel) const ownRateByDate = useMemo(() => { @@ -1291,11 +1295,19 @@ 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 && + new Date(colLatest).getTime() - new Date(rate.scraped_at).getTime() > 60 * 60 * 1000) + 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, + 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 @@ -1319,11 +1331,12 @@ const RateMatrixTab: React.FC = () => { } } + const staleMark = isStale ? '*' : '' const rawContent = rate ? ( isAvailable && rate.rate_gross - ? formatCurrency(rate.rate_gross) + ? formatCurrency(rate.rate_gross) + staleMark : isSoldOut - ? 'Sold' + ? 'Sold' + staleMark : '-' ) : '' @@ -1353,6 +1366,7 @@ const RateMatrixTab: React.FC = () => { style={mergeStyles( styles.matrixTd, cellStyle, + isStale ? { fontStyle: 'italic' } : {}, isWeekend(d) ? styles.weekendCell : {}, isRowH || isColH ? styles.crosshairHighlight : {}, isCellH ? styles.crosshairCell : {}