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:
jtricerolph 2026-07-08 15:54:38 +00:00
parent 1ffda18872
commit d737940a00
2 changed files with 89 additions and 43 deletions

View file

@ -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',