diff --git a/backend/api/competitors.py b/backend/api/competitors.py index fec44d1..05f1196 100644 --- a/backend/api/competitors.py +++ b/backend/api/competitors.py @@ -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 # ============================================ diff --git a/frontend/src/pages/MarketView.tsx b/frontend/src/pages/MarketView.tsx index 4f486e1..8421a41 100644 --- a/frontend/src/pages/MarketView.tsx +++ b/frontend/src/pages/MarketView.tsx @@ -1,6 +1,7 @@ import React, { useState, useMemo, useCallback, useEffect } from 'react' 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' // 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}
%{x}${s.room_type}`, + })) + }, [data]) + + return ( +
+
e.stopPropagation()}> +
+
+
{modal.hotelName}
+
+ Best available rate history — {modal.stayDate} +
+
+ +
+ {isLoading ? ( +
Loading…
+ ) : !data?.series?.length ? ( +
No historical data for this date yet.
+ ) : ( + + )} + {data?.series && data.series.length > 0 && ( +
+ 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. +
+ )} +
+
+ ) +} + const RateMatrixTab: React.FC = () => { const [selectedMonth, setSelectedMonth] = useState(() => { const today = new Date() @@ -1142,6 +1221,7 @@ const RateMatrixTab: React.FC = () => { const [customFrom, setCustomFrom] = useState(fmtDate(new Date())) const [customTo, setCustomTo] = useState(fmtDate(new Date(Date.now() + 13 * 86400000))) const [showDirect, setShowDirect] = useState(false) + const [historyModal, setHistoryModal] = useState(null) const queryClient = useQueryClient() // The scrape endpoint returns immediately (runs in background), and the @@ -1597,16 +1677,34 @@ const RateMatrixTab: React.FC = () => { onMouseEnter={() => onCellEnter(rowIdx, colIdx)} onMouseLeave={onCellLeave} > - {bookingUrl ? ( - - {cellContent} - - ) : cellContent} +
+ {cellContent} + {(bookingUrl || (rate?.rate_gross && isAvailable)) && ( +
+ {bookingUrl && ( + e.stopPropagation()} + > + + + )} + {rate?.rate_gross && isAvailable && ( + + )} +
+ )} +
{priceIndexBadge} ) @@ -1657,6 +1755,10 @@ const RateMatrixTab: React.FC = () => { )} + {historyModal && ( + setHistoryModal(null)} /> + )} + {/* Legend */}
Legend: