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,
|
||||
}
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ interface RateMatrixResponse {
|
|||
rates: Record<number, Record<string, {
|
||||
availability_status: string
|
||||
rate_gross: number | null
|
||||
last_available_rate: number | null
|
||||
room_type: string | null
|
||||
breakfast_included: boolean | null
|
||||
free_cancellation: boolean | null
|
||||
|
|
@ -1223,6 +1224,7 @@ const RateMatrixTab: React.FC = () => {
|
|||
const [showDirect, setShowDirect] = useState(false)
|
||||
const [historyModal, setHistoryModal] = useState<RateHistoryModal | null>(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}` : '↻'}
|
||||
</button>
|
||||
{locationName && (
|
||||
{locationName && d >= todayStr && (
|
||||
<a
|
||||
href={buildSearchUrl(locationName, d)}
|
||||
target="_blank"
|
||||
|
|
@ -1585,10 +1587,14 @@ const RateMatrixTab: React.FC = () => {
|
|||
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 = (
|
||||
<span style={{ display: 'block', fontSize: 9, fontWeight: 700, color: fg, background: bg,
|
||||
borderRadius: 4, padding: '0 3px', lineHeight: '14px', marginTop: 1 }}>
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: fg, background: bg,
|
||||
borderRadius: 4, padding: '0 3px', lineHeight: '14px' }}>
|
||||
{delta > 0 ? '+' : ''}{delta}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
|
||||
<span>{cellContent}</span>
|
||||
{(bookingUrl || (rate?.rate_gross && isAvailable)) && (
|
||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0, opacity: 0.55 }}>
|
||||
{bookingUrl && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
<span style={{ textDecoration: strikethrough ? 'line-through' : 'none' }}>
|
||||
{rateText}
|
||||
</span>
|
||||
{priceIndexBadge}
|
||||
{(showEye || showHistory) && (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 2, opacity: 0.6 }}>
|
||||
{showEye && (
|
||||
<a
|
||||
href={bookingUrl}
|
||||
href={bookingUrl!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on Booking.com"
|
||||
style={{ color: 'inherit', display: 'inline-flex', alignItems: 'center', lineHeight: 1 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Eye size={9} strokeWidth={1.75} />
|
||||
<Eye size={12} strokeWidth={1.75} />
|
||||
</a>
|
||||
)}
|
||||
{rate?.rate_gross && isAvailable && (
|
||||
{showHistory && (
|
||||
<button
|
||||
title="Rate history"
|
||||
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 }}
|
||||
>
|
||||
<LineChart size={9} strokeWidth={1.75} />
|
||||
<LineChart size={12} strokeWidth={1.75} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{priceIndexBadge}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
|
|
@ -1763,8 +1785,9 @@ const RateMatrixTab: React.FC = () => {
|
|||
<div style={styles.legend}>
|
||||
<span style={styles.legendTitle}>Legend:</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.matrixCellPast)}>Past</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={mergeStyles(styles.tierDot, { background: '#2563eb' })} /> Own
|
||||
|
|
@ -2180,24 +2203,21 @@ const styles: Record<string, React.CSSProperties> = {
|
|||
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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue