Matrix: past/sold-out cells show last rate strikethrough, icons at bottom
Sold-out cells: amber (was red), show last known available rate with strikethrough. Past dates: grey, same strikethrough treatment, no eye icon (Booking.com won't serve past availability), history chart still accessible. Date column headers also hide the eye link for past dates. Backend adds a second query returning last_available_rate (most recent available + non-null rate_gross) per hotel+date for the full range. Icons are now 12px and stacked below the rate text in a flex-column cell layout. Price index badge sits between rate and icons. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1ffda18872
commit
d737940a00
2 changed files with 89 additions and 43 deletions
|
|
@ -671,6 +671,32 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 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
|
# Most recent scrape touching each date, across ALL hotels — a partial
|
||||||
# scrape may refresh the date without touching the displayed hotels, so
|
# scrape may refresh the date without touching the displayed hotels, so
|
||||||
# per-cell scraped_at can lag behind this column-level timestamp
|
# per-cell scraped_at can lag behind this column-level timestamp
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ interface RateMatrixResponse {
|
||||||
rates: Record<number, Record<string, {
|
rates: Record<number, Record<string, {
|
||||||
availability_status: string
|
availability_status: string
|
||||||
rate_gross: number | null
|
rate_gross: number | null
|
||||||
|
last_available_rate: number | null
|
||||||
room_type: string | null
|
room_type: string | null
|
||||||
breakfast_included: boolean | null
|
breakfast_included: boolean | null
|
||||||
free_cancellation: boolean | null
|
free_cancellation: boolean | null
|
||||||
|
|
@ -1223,6 +1224,7 @@ const RateMatrixTab: React.FC = () => {
|
||||||
const [showDirect, setShowDirect] = useState(false)
|
const [showDirect, setShowDirect] = useState(false)
|
||||||
const [historyModal, setHistoryModal] = useState<RateHistoryModal | null>(null)
|
const [historyModal, setHistoryModal] = useState<RateHistoryModal | null>(null)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const todayStr = fmtDate(new Date())
|
||||||
|
|
||||||
// The scrape endpoint returns immediately (runs in background), and the
|
// The scrape endpoint returns immediately (runs in background), and the
|
||||||
// scraper handles one job at a time, so queued jobs (single dates or
|
// 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}` : '↻'}
|
{scrapingDate === d ? '...' : queuePos >= 0 ? `#${queuePos + 1}` : '↻'}
|
||||||
</button>
|
</button>
|
||||||
{locationName && (
|
{locationName && d >= todayStr && (
|
||||||
<a
|
<a
|
||||||
href={buildSearchUrl(locationName, d)}
|
href={buildSearchUrl(locationName, d)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
@ -1585,10 +1587,14 @@ const RateMatrixTab: React.FC = () => {
|
||||||
const rate = hotelRates[d]
|
const rate = hotelRates[d]
|
||||||
const isAvailable = rate?.availability_status === 'available'
|
const isAvailable = rate?.availability_status === 'available'
|
||||||
const isSoldOut = rate?.availability_status === 'sold_out'
|
const isSoldOut = rate?.availability_status === 'sold_out'
|
||||||
|
const isPast = d < todayStr
|
||||||
|
const isSoldOrPast = isSoldOut || isPast
|
||||||
|
|
||||||
let cellStyle: React.CSSProperties = styles.matrixCellEmpty
|
let cellStyle: React.CSSProperties = styles.matrixCellEmpty
|
||||||
if (rate) {
|
if (rate) {
|
||||||
if (isAvailable && rate.rate_gross) {
|
if (isPast) {
|
||||||
|
cellStyle = styles.matrixCellPast
|
||||||
|
} else if (isAvailable && rate.rate_gross) {
|
||||||
cellStyle = styles.matrixCellAvailable
|
cellStyle = styles.matrixCellAvailable
|
||||||
} else if (isSoldOut) {
|
} else if (isSoldOut) {
|
||||||
cellStyle = styles.matrixCellSoldOut
|
cellStyle = styles.matrixCellSoldOut
|
||||||
|
|
@ -1600,21 +1606,43 @@ const RateMatrixTab: React.FC = () => {
|
||||||
// Stale = this cell wasn't touched by the column's most
|
// Stale = this cell wasn't touched by the column's most
|
||||||
// recent scrape (e.g. the hotel was on a page that failed)
|
// recent scrape (e.g. the hotel was on a page that failed)
|
||||||
const colLatest = scrapedAtByDate[d]
|
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)
|
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 ? [
|
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,
|
||||||
|
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,
|
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,
|
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
|
||||||
let bookingUrl: string | null = null
|
let bookingUrl: string | null = null
|
||||||
if (hotel.booking_com_url) {
|
if (!isPast && hotel.booking_com_url) {
|
||||||
const checkin = d
|
const checkin = d
|
||||||
const co = new Date(d + 'T00:00:00')
|
const co = new Date(d + 'T00:00:00')
|
||||||
co.setDate(co.getDate() + 1)
|
co.setDate(co.getDate() + 1)
|
||||||
|
|
@ -1628,35 +1656,27 @@ const RateMatrixTab: React.FC = () => {
|
||||||
url.searchParams.set('group_adults', '2')
|
url.searchParams.set('group_adults', '2')
|
||||||
bookingUrl = url.toString()
|
bookingUrl = url.toString()
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback if URL parsing fails
|
|
||||||
bookingUrl = hotel.booking_com_url
|
bookingUrl = hotel.booking_com_url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const staleMark = isStale ? '*' : ''
|
// Price index badge for competitor rows (use last_available_rate for sold/past)
|
||||||
const rawContent = rate ? (
|
const rateForIndex = isSoldOrPast ? rate?.last_available_rate : rate?.rate_gross
|
||||||
isAvailable && rate.rate_gross
|
|
||||||
? formatCurrency(rate.rate_gross) + staleMark
|
|
||||||
: isSoldOut
|
|
||||||
? 'Sold' + staleMark
|
|
||||||
: '-'
|
|
||||||
) : ''
|
|
||||||
|
|
||||||
// Price index badge for competitor rows
|
|
||||||
let priceIndexBadge: React.ReactNode = null
|
let priceIndexBadge: React.ReactNode = null
|
||||||
if (hotel.tier === 'competitor' && rate?.rate_gross && ownRateByDate[d]) {
|
if (hotel.tier === 'competitor' && rateForIndex && ownRateByDate[d]) {
|
||||||
const delta = Math.round((rate.rate_gross / ownRateByDate[d]! - 1) * 100)
|
const delta = Math.round((rateForIndex / ownRateByDate[d]! - 1) * 100)
|
||||||
const bg = delta > 5 ? '#dcfce7' : delta < -15 ? '#fee2e2' : delta < -5 ? '#fef3c7' : '#f1f5f9'
|
const bg = delta > 5 ? '#dcfce7' : delta < -15 ? '#fee2e2' : delta < -5 ? '#fef3c7' : '#f1f5f9'
|
||||||
const fg = delta > 5 ? '#16a34a' : delta < -15 ? '#dc2626' : delta < -5 ? '#d97706' : '#64748b'
|
const fg = delta > 5 ? '#16a34a' : delta < -15 ? '#dc2626' : delta < -5 ? '#d97706' : '#64748b'
|
||||||
priceIndexBadge = (
|
priceIndexBadge = (
|
||||||
<span style={{ display: 'block', fontSize: 9, fontWeight: 700, color: fg, background: bg,
|
<span style={{ fontSize: 9, fontWeight: 700, color: fg, background: bg,
|
||||||
borderRadius: 4, padding: '0 3px', lineHeight: '14px', marginTop: 1 }}>
|
borderRadius: 4, padding: '0 3px', lineHeight: '14px' }}>
|
||||||
{delta > 0 ? '+' : ''}{delta}%
|
{delta > 0 ? '+' : ''}{delta}%
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cellContent = rawContent
|
const showEye = !isPast && !!bookingUrl && !!rate
|
||||||
|
const showHistory = !!rate
|
||||||
|
|
||||||
const isRowH = hoveredCell?.row === rowIdx
|
const isRowH = hoveredCell?.row === rowIdx
|
||||||
const isColH = hoveredCell?.col === colIdx
|
const isColH = hoveredCell?.col === colIdx
|
||||||
|
|
@ -1677,35 +1697,37 @@ const RateMatrixTab: React.FC = () => {
|
||||||
onMouseEnter={() => onCellEnter(rowIdx, colIdx)}
|
onMouseEnter={() => onCellEnter(rowIdx, colIdx)}
|
||||||
onMouseLeave={onCellLeave}
|
onMouseLeave={onCellLeave}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||||
<span>{cellContent}</span>
|
<span style={{ textDecoration: strikethrough ? 'line-through' : 'none' }}>
|
||||||
{(bookingUrl || (rate?.rate_gross && isAvailable)) && (
|
{rateText}
|
||||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0, opacity: 0.55 }}>
|
</span>
|
||||||
{bookingUrl && (
|
{priceIndexBadge}
|
||||||
|
{(showEye || showHistory) && (
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginTop: 2, opacity: 0.6 }}>
|
||||||
|
{showEye && (
|
||||||
<a
|
<a
|
||||||
href={bookingUrl}
|
href={bookingUrl!}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
title="View on Booking.com"
|
title="View on Booking.com"
|
||||||
style={{ color: 'inherit', display: 'inline-flex', alignItems: 'center', lineHeight: 1 }}
|
style={{ color: 'inherit', display: 'inline-flex', alignItems: 'center', lineHeight: 1 }}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Eye size={9} strokeWidth={1.75} />
|
<Eye size={12} strokeWidth={1.75} />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{rate?.rate_gross && isAvailable && (
|
{showHistory && (
|
||||||
<button
|
<button
|
||||||
title="Rate history"
|
title="Rate history"
|
||||||
onClick={() => setHistoryModal({ hotelId: hotel.id, hotelName: hotel.name, stayDate: d })}
|
onClick={() => setHistoryModal({ hotelId: hotel.id, hotelName: hotel.name, stayDate: d })}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'inherit', display: 'inline-flex', alignItems: 'center', lineHeight: 1 }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'inherit', display: 'inline-flex', alignItems: 'center', lineHeight: 1 }}
|
||||||
>
|
>
|
||||||
<LineChart size={9} strokeWidth={1.75} />
|
<LineChart size={12} strokeWidth={1.75} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{priceIndexBadge}
|
|
||||||
</td>
|
</td>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -1763,8 +1785,9 @@ const RateMatrixTab: React.FC = () => {
|
||||||
<div style={styles.legend}>
|
<div style={styles.legend}>
|
||||||
<span style={styles.legendTitle}>Legend:</span>
|
<span style={styles.legendTitle}>Legend:</span>
|
||||||
<span style={mergeStyles(styles.legendItem, styles.matrixCellAvailable)}>Available</span>
|
<span style={mergeStyles(styles.legendItem, styles.matrixCellAvailable)}>Available</span>
|
||||||
<span style={mergeStyles(styles.legendItem, styles.matrixCellSoldOut)}>Sold Out</span>
|
<span style={mergeStyles(styles.legendItem, styles.matrixCellSoldOut)}>Sold Out (strikethrough = last rate)</span>
|
||||||
<span style={mergeStyles(styles.legendItem, styles.matrixCellNoRate)}>No Rate</span>
|
<span style={mergeStyles(styles.legendItem, styles.matrixCellNoRate)}>No Rate</span>
|
||||||
|
<span style={mergeStyles(styles.legendItem, styles.matrixCellPast)}>Past</span>
|
||||||
<span style={mergeStyles(styles.legendItem, styles.matrixCellEmpty)}>No Data</span>
|
<span style={mergeStyles(styles.legendItem, styles.matrixCellEmpty)}>No Data</span>
|
||||||
<span style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '11px' }}>
|
<span style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '11px' }}>
|
||||||
<span style={mergeStyles(styles.tierDot, { background: '#2563eb' })} /> Own
|
<span style={mergeStyles(styles.tierDot, { background: '#2563eb' })} /> Own
|
||||||
|
|
@ -2180,24 +2203,21 @@ const styles: Record<string, React.CSSProperties> = {
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
},
|
},
|
||||||
matrixCellSoldOut: {
|
matrixCellSoldOut: {
|
||||||
background: '#fee2e2',
|
|
||||||
color: 'var(--danger)',
|
|
||||||
},
|
|
||||||
matrixCellNoRate: {
|
|
||||||
background: '#fef3c7',
|
background: '#fef3c7',
|
||||||
color: '#d97706',
|
color: '#d97706',
|
||||||
},
|
},
|
||||||
|
matrixCellNoRate: {
|
||||||
|
background: '#fef9ec',
|
||||||
|
color: '#b45309',
|
||||||
|
},
|
||||||
matrixCellEmpty: {
|
matrixCellEmpty: {
|
||||||
background: 'var(--body-bg)',
|
background: 'var(--body-bg)',
|
||||||
color: 'var(--text-mid)',
|
color: 'var(--text-mid)',
|
||||||
},
|
},
|
||||||
matrixCellLink: {
|
matrixCellPast: {
|
||||||
color: 'inherit',
|
background: '#f1f5f9',
|
||||||
textDecoration: 'none',
|
color: '#94a3b8',
|
||||||
display: 'block',
|
},
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
} as React.CSSProperties,
|
|
||||||
crosshairHighlight: {
|
crosshairHighlight: {
|
||||||
boxShadow: 'inset 0 0 0 1px #1a1a2e33',
|
boxShadow: 'inset 0 0 0 1px #1a1a2e33',
|
||||||
background: '#1a1a2e08',
|
background: '#1a1a2e08',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue