diff --git a/frontend/src/pages/MarketView.tsx b/frontend/src/pages/MarketView.tsx index 3ff928f..22f4f20 100644 --- a/frontend/src/pages/MarketView.tsx +++ b/frontend/src/pages/MarketView.tsx @@ -1,14 +1,35 @@ import React, { useState, useMemo, useCallback, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Eye } from 'lucide-react' import api from '../api' // Format Date as YYYY-MM-DD using local time (avoids UTC/DST shift from toISOString) const fmtDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +// Booking.com search results for the scrape location on a given night +const buildSearchUrl = (location: string, checkin: string) => { + const co = new Date(checkin + 'T00:00:00') + co.setDate(co.getDate() + 1) + const params = new URLSearchParams({ + ss: location, + checkin, + checkout: fmtDate(co), + group_adults: '2', + no_rooms: '1', + group_children: '0', + }) + return `https://www.booking.com/searchresults.html?${params}` +} + // ============================================ // TYPES // ============================================ +interface ScrapeJob { + from: string + to: string +} + interface ScraperStatus { enabled: boolean paused: boolean @@ -915,7 +936,6 @@ const RateMatrixTab: React.FC = () => { }) const [includeMarket, setIncludeMarket] = useState(false) const [hoveredCell, setHoveredCell] = useState<{ row: number; col: number } | null>(null) - const [scrapingDate, setScrapingDate] = useState(null) const [rangeMode, setRangeMode] = useState(false) const [customFrom, setCustomFrom] = useState(fmtDate(new Date())) const [customTo, setCustomTo] = useState(fmtDate(new Date(Date.now() + 13 * 86400000))) @@ -923,49 +943,61 @@ const RateMatrixTab: React.FC = () => { const queryClient = useQueryClient() // The scrape endpoint returns immediately (runs in background), and the - // scraper handles one date at a time, so queued dates are dispatched - // sequentially: watch scraper status until a new batch finishes, refetch - // the matrix, then start the next date in the queue. - const [scrapeQueue, setScrapeQueue] = useState([]) - const [scrapeWatch, setScrapeWatch] = useState<{ prevBatch: string | null, startedAt: number } | null>(null) + // scraper handles one job at a time, so queued jobs (single dates or + // ranges) are dispatched sequentially: watch scraper status until a new + // batch finishes, refetch the matrix, then start the next job. + const [scrapeQueue, setScrapeQueue] = useState([]) + const [scrapeWatch, setScrapeWatch] = useState<{ prevBatch: string | null, startedAt: number, timeoutMs: number } | null>(null) + + const enqueueScrape = (from: string, to: string) => { + setScrapeQueue(q => q.some(j => j.from === from && j.to === to) ? q : [...q, { from, to }]) + } + + const activeJob = scrapeWatch ? scrapeQueue[0] : undefined + const scrapingDate = activeJob && activeJob.from === activeJob.to ? activeJob.from : null const dateScrapeM = useMutation({ - mutationFn: async (d: string) => { + mutationFn: async (job: ScrapeJob) => { const st = (await api.get('/competitors/status')).data as ScraperStatus - setScrapingDate(d) - setScrapeWatch({ prevBatch: st?.last_scrape?.batch_id ?? null, startedAt: Date.now() }) - return (await api.post('/competitors/scrape', { from_date: d, to_date: d })).data + const days = Math.round((new Date(job.to).getTime() - new Date(job.from).getTime()) / 86400000) + 1 + setScrapeWatch({ + prevBatch: st?.last_scrape?.batch_id ?? null, + startedAt: Date.now(), + // generous per-date allowance: ranges take ~1 min per date + timeoutMs: 10 * 60 * 1000 + days * 90 * 1000, + }) + return (await api.post('/competitors/scrape', { from_date: job.from, to_date: job.to })).data }, onError: () => { - setScrapingDate(null) setScrapeWatch(null) setScrapeQueue(q => q.slice(1)) }, }) - // Dispatch the next queued date when idle + // Dispatch the next queued job when idle useEffect(() => { if (scrapeQueue.length && !scrapeWatch && !dateScrapeM.isPending) { dateScrapeM.mutate(scrapeQueue[0]) } }, [scrapeQueue, scrapeWatch, dateScrapeM.isPending]) // eslint-disable-line react-hooks/exhaustive-deps + // Also serves the date-header Booking.com links via location_name const { data: watchStatus } = useQuery({ queryKey: ['scraper-status'], queryFn: async () => (await api.get('/competitors/status')).data, - enabled: scrapeWatch !== null, refetchInterval: scrapeWatch !== null ? 5000 : false, + staleTime: 60 * 1000, }) + const locationName = watchStatus?.location_name useEffect(() => { if (!scrapeWatch) return const ls = watchStatus?.last_scrape const finished = ls && ls.batch_id !== scrapeWatch.prevBatch && ls.status !== 'running' - const timedOut = Date.now() - scrapeWatch.startedAt > 10 * 60 * 1000 + const timedOut = Date.now() - scrapeWatch.startedAt > scrapeWatch.timeoutMs if (finished || timedOut) { queryClient.invalidateQueries({ queryKey: ['competitor-matrix'] }) queryClient.invalidateQueries({ queryKey: ['scraper-status'] }) - setScrapingDate(null) setScrapeWatch(null) setScrapeQueue(q => q.slice(1)) } @@ -995,6 +1027,8 @@ const RateMatrixTab: React.FC = () => { }) return (await api.get(`/competitors/matrix?${params}`)).data }, + // While a scrape runs, refresh so columns fill in as dates complete + refetchInterval: scrapeWatch !== null ? 30000 : false, }) const dates = data?.dates || [] @@ -1115,6 +1149,24 @@ const RateMatrixTab: React.FC = () => { )} +
+ {[{ label: 'Scrape 7d', days: 7 }, { label: 'Scrape 30d', days: 30 }].map(p => { + const from = fmtDate(new Date()) + const to = fmtDate(new Date(Date.now() + (p.days - 1) * 86400000)) + const queued = scrapeQueue.some(j => j.from === from && j.to === to) + const active = activeJob && activeJob.from === from && activeJob.to === to + return ( + + ) + })} +