Add "Changed Since" rate movement indicators to Market View matrix

A new toolbar row on the Rate Matrix lets users pick a reference datetime
(defaults to 24h ago, with 24h/3d/7d presets) and see ▲/▼ triangles
next to each competitor's BAR where the rate has moved ≥50p since then.

Two backend endpoints:
- GET /competitors/rate-changes?since=  — static datetime comparison
- GET /competitors/rate-changes-vs-own  — dynamic: diffs against the
  timestamp our own Newbook rate last changed per date (highlights
  competitor moves made in response to our own pricing decisions)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-10 11:19:34 +00:00
parent 8ec066caa8
commit e355833f1c
2 changed files with 249 additions and 3 deletions

View file

@ -1423,6 +1423,13 @@ const RateMatrixTab: React.FC = () => {
const [customTo, setCustomTo] = useState(fmtDate(new Date(Date.now() + 13 * 86400000)))
const [showDirect, setShowDirect] = useState(false)
const [historyModal, setHistoryModal] = useState<RateHistoryModal | null>(null)
const [changedSinceEnabled, setChangedSinceEnabled] = useState(false)
const [changedSinceDate, setChangedSinceDate] = useState<string>(() => {
const d = new Date(Date.now() - 24 * 60 * 60 * 1000)
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`
})
const [dynamicOwnRate, setDynamicOwnRate] = useState(false)
const queryClient = useQueryClient()
const todayStr = fmtDate(new Date())
@ -1606,6 +1613,38 @@ const RateMatrixTab: React.FC = () => {
enabled: showDirect,
})
type PrevRateEntry = { prev_rate: number; prev_scraped_at: string | null; own_last_changed?: string | null }
type PrevRateMap = Record<number, Record<string, PrevRateEntry>>
const { data: prevRates } = useQuery<PrevRateMap>({
queryKey: ['rate-changes', fromDate, toDate, changedSinceDate, includeMarket],
queryFn: async () => {
const params = new URLSearchParams({
from_date: fromDate,
to_date: toDate,
since: new Date(changedSinceDate).toISOString(),
include_market: includeMarket.toString(),
})
return (await api.get(`/competitors/rate-changes?${params}`)).data
},
enabled: changedSinceEnabled && !dynamicOwnRate,
})
const { data: prevRatesDynamic } = useQuery<PrevRateMap>({
queryKey: ['rate-changes-vs-own', fromDate, toDate, includeMarket],
queryFn: async () => {
const params = new URLSearchParams({
from_date: fromDate,
to_date: toDate,
include_market: includeMarket.toString(),
})
return (await api.get(`/competitors/rate-changes-vs-own?${params}`)).data
},
enabled: changedSinceEnabled && dynamicOwnRate,
})
const activePrevRates: PrevRateMap | undefined = dynamicOwnRate ? prevRatesDynamic : prevRates
if (isLoading) {
return (
<div style={styles.loading}>
@ -1694,6 +1733,55 @@ const RateMatrixTab: React.FC = () => {
</label>
</div>
{/* Changed Since row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
<label style={styles.checkboxLabel}>
<input
type="checkbox"
checked={changedSinceEnabled}
onChange={e => setChangedSinceEnabled(e.target.checked)}
/>
<span style={{ fontWeight: changedSinceEnabled ? 600 : undefined }}>Changed since</span>
</label>
{changedSinceEnabled && (
<>
{!dynamicOwnRate && (
<>
{[{ label: '24h', hours: 24 }, { label: '3d', hours: 72 }, { label: '7d', hours: 168 }].map(p => (
<button
key={p.label}
style={buttonStyle('outline', 'small')}
onClick={() => {
const d = new Date(Date.now() - p.hours * 60 * 60 * 1000)
const pad = (n: number) => String(n).padStart(2, '0')
setChangedSinceDate(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`)
}}
>
{p.label}
</button>
))}
<input
type="datetime-local"
value={changedSinceDate}
onChange={e => setChangedSinceDate(e.target.value)}
style={{ ...inputStyle, width: 178, padding: '4px 8px' }}
/>
</>
)}
<label style={{ ...styles.checkboxLabel, marginLeft: dynamicOwnRate ? 0 : 4 }}>
<input
type="checkbox"
checked={dynamicOwnRate}
onChange={e => setDynamicOwnRate(e.target.checked)}
/>
<span title="Show ▲/▼ relative to when we last updated our own Newbook rate per date">
vs own rate last change
</span>
</label>
</>
)}
</div>
{hotels.length === 0 ? (
<div style={styles.emptyState}>
<h3 style={{ margin: 0, color: 'var(--text-dark)' }}>No Rate Data</h3>
@ -1901,6 +1989,29 @@ const RateMatrixTab: React.FC = () => {
}
}
// Rate change since indicator
let changeSinceIndicator: React.ReactNode = null
if (changedSinceEnabled && activePrevRates && !isPast && !isSoldOut && rate?.rate_gross) {
const prev = (activePrevRates[hotel.id] || {})[d]
if (prev?.prev_rate) {
const delta = rate.rate_gross - prev.prev_rate
if (Math.abs(delta) >= 0.5) {
const isUp = delta > 0
const prevLabel = dynamicOwnRate
? `vs our rate update (${formatDateTime(prev.own_last_changed ?? null)})`
: `at ${formatDateTime(prev.prev_scraped_at)}`
changeSinceIndicator = (
<span
style={{ fontSize: 9, color: isUp ? '#16a34a' : '#dc2626', lineHeight: 1, fontWeight: 700, flexShrink: 0 }}
title={`Was ${formatCurrency(prev.prev_rate)} ${prevLabel} (${isUp ? '+' : ''}${formatCurrency(delta)})`}
>
{isUp ? '▲' : '▼'}
</span>
)
}
}
}
const showEye = !isPast && !!bookingUrl && !!rate
const showHistory = !!rate
@ -1927,9 +2038,12 @@ const RateMatrixTab: React.FC = () => {
{showSoldLabel && (
<span style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.05em', color: '#d97706' }}>SOLD</span>
)}
<span style={{ textDecoration: strikethrough ? 'line-through' : 'none' }}>
{rateText}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span style={{ textDecoration: strikethrough ? 'line-through' : 'none' }}>
{rateText}
</span>
{changeSinceIndicator}
</div>
{priceIndexBadge}
{(showEye || showHistory) && (
<div style={{ display: 'flex', gap: 4, marginTop: 2, opacity: 0.6 }}>