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:
parent
218b2f45f6
commit
bf9425ee7e
2 changed files with 38 additions and 6 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 : {}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue