Market View: per-hotel rate history chart and Booking.com eye link

Replace full-cell link with two small icon buttons in each matrix cell:
- Eye icon links to the hotel's Booking.com page for that check-in date
- LineChart icon opens a history modal (Plotly multi-line chart)

The history chart plots best available rate over scrape runs, with one
line per room type so that changes in which room is cheapest show as
separate traces rather than a single jumpy line.

Backend: GET /competitors/hotels/{id}/rate-history/{stay_date} groups
by scrape batch, takes MIN(rate_gross) per room type per run, returns
series [{room_type, points: [{t, rate}]}].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-08 15:41:32 +00:00
parent 2abc2b0297
commit 1ffda18872
2 changed files with 158 additions and 11 deletions

View file

@ -1143,6 +1143,51 @@ async def get_booking_availability(
} }
# ============================================
# HOTEL RATE HISTORY
# ============================================
@router.get("/hotels/{hotel_id}/rate-history/{stay_date}")
async def get_hotel_rate_history(
hotel_id: int,
stay_date: date,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Per-room-type best available rate over time for a single stay date.
Groups by scrape batch so each x-point is one scrape run, not one row.
Returns series: [{room_type, points: [{t, rate}]}]"""
result = await db.execute(
text("""
SELECT
COALESCE(b.started_at, date_trunc('hour', r.scraped_at)) AS scrape_time,
r.room_type,
MIN(r.rate_gross)::float AS best_rate
FROM booking_com_rates r
LEFT JOIN booking_scrape_log b ON b.batch_id = r.scrape_batch_id
WHERE r.hotel_id = :hotel_id
AND r.rate_date = :stay_date
AND r.rate_gross IS NOT NULL
AND r.availability_status = 'available'
GROUP BY COALESCE(b.started_at, date_trunc('hour', r.scraped_at)), r.room_type
ORDER BY scrape_time
"""),
{"hotel_id": hotel_id, "stay_date": stay_date},
)
by_room: dict = {}
for row in result.mappings():
rt = (row["room_type"] or "Unknown").split("\n")[0].strip()
by_room.setdefault(rt, []).append({
"t": row["scrape_time"].isoformat() if row["scrape_time"] else None,
"rate": row["best_rate"],
})
series = [{"room_type": k, "points": v} for k, v in by_room.items()]
series.sort(key=lambda s: s["points"][0]["t"] if s["points"] else "")
return {"stay_date": str(stay_date), "series": series}
# ============================================ # ============================================
# SCRAPE HISTORY # SCRAPE HISTORY
# ============================================ # ============================================

View file

@ -1,6 +1,7 @@
import React, { useState, useMemo, useCallback, useEffect } from 'react' import React, { useState, useMemo, useCallback, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Eye, RefreshCw } from 'lucide-react' import { Eye, RefreshCw, LineChart, X } from 'lucide-react'
import Plot from 'react-plotly.js'
import api from '../api' import api from '../api'
// Format Date as YYYY-MM-DD using local time (avoids UTC/DST shift from toISOString) // Format Date as YYYY-MM-DD using local time (avoids UTC/DST shift from toISOString)
@ -1131,6 +1132,84 @@ const MonthSelector: React.FC<{
) )
} }
// ============================================
// RATE HISTORY MODAL
// ============================================
interface RateHistoryPoint { t: string | null; rate: number }
interface RateHistorySeries { room_type: string; points: RateHistoryPoint[] }
interface RateHistoryModal { hotelId: number; hotelName: string; stayDate: string }
const CHART_COLORS = ['#1a1a2e', '#c9a84c', '#16a34a', '#dc2626', '#7c3aed', '#0891b2', '#ea580c', '#be185d']
const RateHistoryModalComponent: React.FC<{ modal: RateHistoryModal; onClose: () => void }> = ({ modal, onClose }) => {
const { data, isLoading } = useQuery<{ stay_date: string; series: RateHistorySeries[] }>({
queryKey: ['hotel-rate-history', modal.hotelId, modal.stayDate],
queryFn: async () => (await api.get(`/competitors/hotels/${modal.hotelId}/rate-history/${modal.stayDate}`)).data,
})
const traces = useMemo(() => {
if (!data?.series) return []
return data.series.map((s, i) => ({
type: 'scatter' as const,
mode: 'lines+markers' as const,
name: s.room_type,
x: s.points.map(p => p.t),
y: s.points.map(p => p.rate),
line: { color: CHART_COLORS[i % CHART_COLORS.length], width: 2 },
marker: { size: 5 },
hovertemplate: `£%{y:.0f}<br>%{x}<extra>${s.room_type}</extra>`,
}))
}, [data])
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={onClose}>
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, width: '90%', maxWidth: 760, maxHeight: '85vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
<div>
<div style={{ fontWeight: 700, fontSize: 16, color: 'var(--text-dark)' }}>{modal.hotelName}</div>
<div style={{ fontSize: 13, color: 'var(--text-mid)', marginTop: 2 }}>
Best available rate history {modal.stayDate}
</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-mid)', padding: 4 }}>
<X size={18} strokeWidth={1.75} />
</button>
</div>
{isLoading ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-mid)' }}>Loading</div>
) : !data?.series?.length ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-mid)' }}>No historical data for this date yet.</div>
) : (
<Plot
data={traces}
layout={{
margin: { t: 10, r: 10, b: 50, l: 55 },
height: 340,
legend: { orientation: 'h', y: -0.2 },
xaxis: { title: { text: '' }, tickformat: '%d %b %H:%M', type: 'date' },
yaxis: { title: { text: 'Rate (£)' }, tickformat: '£,.0f' },
plot_bgcolor: 'var(--body-bg)',
paper_bgcolor: 'var(--card-bg)',
font: { family: 'inherit', size: 11, color: 'var(--text-dark)' },
hovermode: 'x unified',
}}
config={{ displayModeBar: false, responsive: true }}
style={{ width: '100%' }}
/>
)}
{data?.series && data.series.length > 0 && (
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 8 }}>
Each line is the cheapest available rate for that room type at each scrape run. When the best available room type changes, the lines show which was cheapest at each point.
</div>
)}
</div>
</div>
)
}
const RateMatrixTab: React.FC = () => { const RateMatrixTab: React.FC = () => {
const [selectedMonth, setSelectedMonth] = useState(() => { const [selectedMonth, setSelectedMonth] = useState(() => {
const today = new Date() const today = new Date()
@ -1142,6 +1221,7 @@ const RateMatrixTab: React.FC = () => {
const [customFrom, setCustomFrom] = useState(fmtDate(new Date())) const [customFrom, setCustomFrom] = useState(fmtDate(new Date()))
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 queryClient = useQueryClient() const queryClient = useQueryClient()
// The scrape endpoint returns immediately (runs in background), and the // The scrape endpoint returns immediately (runs in background), and the
@ -1597,16 +1677,34 @@ const RateMatrixTab: React.FC = () => {
onMouseEnter={() => onCellEnter(rowIdx, colIdx)} onMouseEnter={() => onCellEnter(rowIdx, colIdx)}
onMouseLeave={onCellLeave} onMouseLeave={onCellLeave}
> >
{bookingUrl ? ( <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
<a <span>{cellContent}</span>
href={bookingUrl} {(bookingUrl || (rate?.rate_gross && isAvailable)) && (
target="_blank" <div style={{ display: 'flex', gap: 2, flexShrink: 0, opacity: 0.55 }}>
rel="noopener noreferrer" {bookingUrl && (
style={styles.matrixCellLink} <a
> href={bookingUrl}
{cellContent} target="_blank"
</a> rel="noopener noreferrer"
) : cellContent} 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} />
</a>
)}
{rate?.rate_gross && isAvailable && (
<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} />
</button>
)}
</div>
)}
</div>
{priceIndexBadge} {priceIndexBadge}
</td> </td>
) )
@ -1657,6 +1755,10 @@ const RateMatrixTab: React.FC = () => {
</div> </div>
)} )}
{historyModal && (
<RateHistoryModalComponent modal={historyModal} onClose={() => setHistoryModal(null)} />
)}
{/* Legend */} {/* Legend */}
<div style={styles.legend}> <div style={styles.legend}>
<span style={styles.legendTitle}>Legend:</span> <span style={styles.legendTitle}>Legend:</span>