Market View: honest staleness per cell after partial scrapes

- matrix response gains last_scraped per date (max scraped_at across all
  hotels), so column headers show the newest scrape touching the date even
  when the visible hotels were on a failed page
- cells >1h older than the column's latest scrape render italic with *
- every cell tooltip now includes the datestamp the price was scraped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-05 15:46:59 +00:00
parent 218b2f45f6
commit bf9425ee7e
2 changed files with 38 additions and 6 deletions

View file

@ -520,6 +520,23 @@ async def get_competitor_matrix(
'scraped_at': row.scraped_at.isoformat() if row.scraped_at else None, '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 # Build date list
dates = [] dates = []
current = start current = start
@ -532,7 +549,8 @@ async def get_competitor_matrix(
'to_date': end.isoformat(), 'to_date': end.isoformat(),
'dates': dates, 'dates': dates,
'hotels': hotels, 'hotels': hotels,
'rates': rates_by_hotel 'rates': rates_by_hotel,
'last_scraped': last_scraped
} }

View file

@ -69,6 +69,7 @@ interface RateMatrixResponse {
from_date: string from_date: string
to_date: string to_date: string
dates: string[] dates: string[]
last_scraped?: Record<string, string | null>
hotels: { hotels: {
id: number id: number
name: string name: string
@ -1045,11 +1046,14 @@ const RateMatrixTab: React.FC = () => {
}) })
}, [data?.hotels]) }, [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 scrapedAtByDate = useMemo(() => {
const result: Record<string, string | null> = {} const result: Record<string, string | null> = {}
for (const d of dates) { for (const d of dates) {
let latest: string | null = null let latest: string | null = data?.last_scraped?.[d] ?? null
for (const hotel of hotels) { for (const hotel of hotels) {
const rate = (rates[hotel.id] || {})[d] const rate = (rates[hotel.id] || {})[d]
if (rate?.scraped_at) { if (rate?.scraped_at) {
@ -1061,7 +1065,7 @@ const RateMatrixTab: React.FC = () => {
result[d] = latest result[d] = latest
} }
return result return result
}, [dates, hotels, rates]) }, [dates, hotels, rates, data?.last_scraped])
// Own hotel rate by date (first 'own' tier hotel) // Own hotel rate by date (first 'own' tier hotel)
const ownRateByDate = useMemo(() => { 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 ? [ const tooltip = rate ? [
rate.room_type, rate.room_type,
rate.breakfast_included ? 'Breakfast incl.' : null, rate.breakfast_included ? 'Breakfast incl.' : null,
rate.free_cancellation ? 'Free cancel' : null, rate.free_cancellation ? 'Free cancel' : null,
rate.rooms_left ? `${rate.rooms_left} left` : 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(' | ') : '' ].filter(Boolean).join(' | ') : ''
// Build booking.com link: strip existing date/guest params, add ours // 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 ? ( const rawContent = rate ? (
isAvailable && rate.rate_gross isAvailable && rate.rate_gross
? formatCurrency(rate.rate_gross) ? formatCurrency(rate.rate_gross) + staleMark
: isSoldOut : isSoldOut
? 'Sold' ? 'Sold' + staleMark
: '-' : '-'
) : '' ) : ''
@ -1353,6 +1366,7 @@ const RateMatrixTab: React.FC = () => {
style={mergeStyles( style={mergeStyles(
styles.matrixTd, styles.matrixTd,
cellStyle, cellStyle,
isStale ? { fontStyle: 'italic' } : {},
isWeekend(d) ? styles.weekendCell : {}, isWeekend(d) ? styles.weekendCell : {},
isRowH || isColH ? styles.crosshairHighlight : {}, isRowH || isColH ? styles.crosshairHighlight : {},
isCellH ? styles.crosshairCell : {} isCellH ? styles.crosshairCell : {}