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}