From e355833f1c39f499dfe5a3c5e88ced70061ee6f2 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Fri, 10 Jul 2026 11:19:34 +0000 Subject: [PATCH] Add "Changed Since" rate movement indicators to Market View matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/api/competitors.py | 132 ++++++++++++++++++++++++++++++ frontend/src/pages/MarketView.tsx | 120 ++++++++++++++++++++++++++- 2 files changed, 249 insertions(+), 3 deletions(-) diff --git a/backend/api/competitors.py b/backend/api/competitors.py index 2acb113..de5bdac 100644 --- a/backend/api/competitors.py +++ b/backend/api/competitors.py @@ -1484,3 +1484,135 @@ async def get_scrape_history( } 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 diff --git a/frontend/src/pages/MarketView.tsx b/frontend/src/pages/MarketView.tsx index 69b2785..93b1593 100644 --- a/frontend/src/pages/MarketView.tsx +++ b/frontend/src/pages/MarketView.tsx @@ -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(null) + const [changedSinceEnabled, setChangedSinceEnabled] = useState(false) + const [changedSinceDate, setChangedSinceDate] = useState(() => { + 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> + + const { data: prevRates } = useQuery({ + 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({ + 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 (
@@ -1694,6 +1733,55 @@ const RateMatrixTab: React.FC = () => {
+ {/* Changed Since row */} +
+ + {changedSinceEnabled && ( + <> + {!dynamicOwnRate && ( + <> + {[{ label: '24h', hours: 24 }, { label: '3d', hours: 72 }, { label: '7d', hours: 168 }].map(p => ( + + ))} + setChangedSinceDate(e.target.value)} + style={{ ...inputStyle, width: 178, padding: '4px 8px' }} + /> + + )} + + + )} +
+ {hotels.length === 0 ? (

No Rate Data

@@ -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 = ( + + {isUp ? '▲' : '▼'} + + ) + } + } + } + const showEye = !isPast && !!bookingUrl && !!rate const showHistory = !!rate @@ -1927,9 +2038,12 @@ const RateMatrixTab: React.FC = () => { {showSoldLabel && ( SOLD )} - - {rateText} - +
+ + {rateText} + + {changeSinceIndicator} +
{priceIndexBadge} {(showEye || showHistory) && (