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:
parent
8ec066caa8
commit
e355833f1c
2 changed files with 249 additions and 3 deletions
|
|
@ -1484,3 +1484,135 @@ async def get_scrape_history(
|
||||||
}
|
}
|
||||||
for row in result.fetchall()
|
for row in result.fetchall()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# RATE CHANGE INDICATORS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@router.get("/rate-changes")
|
||||||
|
async def get_rate_changes(
|
||||||
|
from_date: str,
|
||||||
|
to_date: str,
|
||||||
|
since: str,
|
||||||
|
include_market: bool = False,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
For each hotel+date in the range, return the best 2-adult available rate
|
||||||
|
at the most recent scrape before `since`. The frontend diffs this against
|
||||||
|
the current rate to show ▲/▼ movement indicators.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
start = date.fromisoformat(from_date)
|
||||||
|
end = date.fromisoformat(to_date)
|
||||||
|
since_dt = datetime.fromisoformat(since.replace('Z', '+00:00'))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid date format: {e}")
|
||||||
|
|
||||||
|
if (end - start).days > 90:
|
||||||
|
raise HTTPException(status_code=400, detail="Date range cannot exceed 90 days")
|
||||||
|
|
||||||
|
tier_filter = "h.tier IN ('own', 'competitor')"
|
||||||
|
if include_market:
|
||||||
|
tier_filter = "h.tier IN ('own', 'competitor', 'market')"
|
||||||
|
|
||||||
|
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 prev_rate,
|
||||||
|
r.scraped_at AS prev_scraped_at
|
||||||
|
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.scraped_at <= :since
|
||||||
|
AND r.availability_status = 'available'
|
||||||
|
AND r.rate_gross IS NOT NULL
|
||||||
|
AND (r.max_persons IS NULL OR r.max_persons = 0 OR r.max_persons = 2)
|
||||||
|
ORDER BY r.hotel_id, r.rate_date,
|
||||||
|
CASE WHEN r.max_persons IS NULL OR r.max_persons = 0 OR r.max_persons = 2 THEN 0 ELSE 1 END,
|
||||||
|
r.scraped_at DESC,
|
||||||
|
r.rate_gross ASC NULLS LAST
|
||||||
|
"""),
|
||||||
|
{'from_date': start, 'to_date': end, 'since': since_dt},
|
||||||
|
)
|
||||||
|
|
||||||
|
out: Dict[int, Dict[str, dict]] = {}
|
||||||
|
for row in result.fetchall():
|
||||||
|
out.setdefault(row.hotel_id, {})[row.rate_date.isoformat()] = {
|
||||||
|
'prev_rate': float(row.prev_rate),
|
||||||
|
'prev_scraped_at': row.prev_scraped_at.isoformat() if row.prev_scraped_at else None,
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rate-changes-vs-own")
|
||||||
|
async def get_rate_changes_vs_own(
|
||||||
|
from_date: str,
|
||||||
|
to_date: str,
|
||||||
|
include_market: bool = False,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
For each competitor hotel+date, return the best 2-adult available rate
|
||||||
|
at the time our own Newbook rate last changed for that date. Shows whether
|
||||||
|
competitors moved their rates after we last updated ours.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
start = date.fromisoformat(from_date)
|
||||||
|
end = date.fromisoformat(to_date)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid date format: {e}")
|
||||||
|
|
||||||
|
if (end - start).days > 90:
|
||||||
|
raise HTTPException(status_code=400, detail="Date range cannot exceed 90 days")
|
||||||
|
|
||||||
|
tier_filter = "h.tier IN ('own', 'competitor')"
|
||||||
|
if include_market:
|
||||||
|
tier_filter = "h.tier IN ('own', 'competitor', 'market')"
|
||||||
|
|
||||||
|
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 prev_rate,
|
||||||
|
r.scraped_at AS prev_scraped_at,
|
||||||
|
own.own_last_changed
|
||||||
|
FROM booking_com_rates r
|
||||||
|
JOIN booking_com_hotels h ON r.hotel_id = h.id
|
||||||
|
JOIN (
|
||||||
|
SELECT rate_date, MAX(valid_from) AS own_last_changed
|
||||||
|
FROM newbook_current_rates
|
||||||
|
WHERE rate_date >= :from_date AND rate_date <= :to_date
|
||||||
|
GROUP BY rate_date
|
||||||
|
) own ON r.rate_date = own.rate_date
|
||||||
|
WHERE {tier_filter}
|
||||||
|
AND h.is_active = TRUE
|
||||||
|
AND r.rate_date >= :from_date AND r.rate_date <= :to_date
|
||||||
|
AND r.scraped_at <= own.own_last_changed
|
||||||
|
AND r.availability_status = 'available'
|
||||||
|
AND r.rate_gross IS NOT NULL
|
||||||
|
AND (r.max_persons IS NULL OR r.max_persons = 0 OR r.max_persons = 2)
|
||||||
|
ORDER BY r.hotel_id, r.rate_date,
|
||||||
|
CASE WHEN r.max_persons IS NULL OR r.max_persons = 0 OR r.max_persons = 2 THEN 0 ELSE 1 END,
|
||||||
|
r.scraped_at DESC,
|
||||||
|
r.rate_gross ASC NULLS LAST
|
||||||
|
"""),
|
||||||
|
{'from_date': start, 'to_date': end},
|
||||||
|
)
|
||||||
|
|
||||||
|
out: Dict[int, Dict[str, dict]] = {}
|
||||||
|
for row in result.fetchall():
|
||||||
|
out.setdefault(row.hotel_id, {})[row.rate_date.isoformat()] = {
|
||||||
|
'prev_rate': float(row.prev_rate),
|
||||||
|
'prev_scraped_at': row.prev_scraped_at.isoformat() if row.prev_scraped_at else None,
|
||||||
|
'own_last_changed': row.own_last_changed.isoformat() if row.own_last_changed else None,
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
|
||||||
|
|
@ -1423,6 +1423,13 @@ const RateMatrixTab: React.FC = () => {
|
||||||
const [customTo, setCustomTo] = useState(fmtDate(new Date(Date.now() + 13 * 86400000)))
|
const [customTo, setCustomTo] = useState(fmtDate(new Date(Date.now() + 13 * 86400000)))
|
||||||
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 [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 queryClient = useQueryClient()
|
||||||
const todayStr = fmtDate(new Date())
|
const todayStr = fmtDate(new Date())
|
||||||
|
|
||||||
|
|
@ -1606,6 +1613,38 @@ const RateMatrixTab: React.FC = () => {
|
||||||
enabled: showDirect,
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={styles.loading}>
|
<div style={styles.loading}>
|
||||||
|
|
@ -1694,6 +1733,55 @@ const RateMatrixTab: React.FC = () => {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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 ? (
|
{hotels.length === 0 ? (
|
||||||
<div style={styles.emptyState}>
|
<div style={styles.emptyState}>
|
||||||
<h3 style={{ margin: 0, color: 'var(--text-dark)' }}>No Rate Data</h3>
|
<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 showEye = !isPast && !!bookingUrl && !!rate
|
||||||
const showHistory = !!rate
|
const showHistory = !!rate
|
||||||
|
|
||||||
|
|
@ -1927,9 +2038,12 @@ const RateMatrixTab: React.FC = () => {
|
||||||
{showSoldLabel && (
|
{showSoldLabel && (
|
||||||
<span style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.05em', color: '#d97706' }}>SOLD</span>
|
<span style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.05em', color: '#d97706' }}>SOLD</span>
|
||||||
)}
|
)}
|
||||||
<span style={{ textDecoration: strikethrough ? 'line-through' : 'none' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
{rateText}
|
<span style={{ textDecoration: strikethrough ? 'line-through' : 'none' }}>
|
||||||
</span>
|
{rateText}
|
||||||
|
</span>
|
||||||
|
{changeSinceIndicator}
|
||||||
|
</div>
|
||||||
{priceIndexBadge}
|
{priceIndexBadge}
|
||||||
{(showEye || showHistory) && (
|
{(showEye || showHistory) && (
|
||||||
<div style={{ display: 'flex', gap: 4, marginTop: 2, opacity: 0.6 }}>
|
<div style={{ display: 'flex', gap: 4, marginTop: 2, opacity: 0.6 }}>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue