From 078cb47b16b3bfe90913701da5bf61170a03d075 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 15 Jul 2026 09:44:18 +0000 Subject: [PATCH] Add configurable 30-day NewBook rate rescrape on 2/4/6/12h intervals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intraday rescrape jobs are distributed evenly between the main nightly run (05:20) and cover only the next 30 days — lightweight complement to the full 720-day nightly sweep. Interval is configurable from the Newbook tab in Settings and takes effect immediately without a container restart. Co-Authored-By: Claude Sonnet 4.6 --- backend/api/bookability.py | 84 +++++++++++++++++++++++++ backend/scheduler.py | 50 +++++++++++++++ frontend/src/pages/Settings.tsx | 105 +++++++++++++++++++++++++++++++- 3 files changed, 238 insertions(+), 1 deletion(-) diff --git a/backend/api/bookability.py b/backend/api/bookability.py index af4198d..c65703c 100644 --- a/backend/api/bookability.py +++ b/backend/api/bookability.py @@ -781,6 +781,90 @@ async def get_occupancy_history( } +# ============================================ +# RESCRAPE INTERVAL CONFIG +# ============================================ + +def _rescrape_times_from_config(interval_hours: int, time_str: str) -> list: + """Compute extra rescrape fire times (HH:MM strings) from interval and base time.""" + try: + parts = time_str.split(':') + base_hour, base_minute = int(parts[0]), int(parts[1]) + except (ValueError, IndexError): + base_hour, base_minute = 5, 20 + if interval_hours not in (2, 4, 6, 12): + return [] + return [ + f"{(base_hour + offset) % 24:02d}:{base_minute:02d}" + for offset in range(interval_hours, 24, interval_hours) + ] + + +@router.get("/config/rescrape-schedule") +async def get_rescrape_schedule( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Return current 30-day rescrape interval config and computed fire times.""" + result = await db.execute( + text(""" + SELECT config_key, config_value FROM system_config + WHERE config_key IN ('newbook_rescrape_interval_hours', 'sync_newbook_current_rates_time') + """) + ) + cfg = {row.config_key: row.config_value for row in result.fetchall()} + try: + interval_hours = int(cfg.get('newbook_rescrape_interval_hours') or '0') + except ValueError: + interval_hours = 0 + time_str = cfg.get('sync_newbook_current_rates_time') or '05:20' + return { + "interval_hours": interval_hours, + "base_time": time_str, + "rescrape_times": _rescrape_times_from_config(interval_hours, time_str), + } + + +class RescrapeIntervalBody(BaseModel): + interval_hours: int + + +@router.post("/config/rescrape-interval") +async def set_rescrape_interval( + body: RescrapeIntervalBody, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Save 30-day rescrape interval and live-reload the scheduler jobs.""" + if body.interval_hours not in (0, 2, 4, 6, 12): + raise HTTPException(status_code=400, detail="interval_hours must be 0, 2, 4, 6, or 12") + + await db.execute( + text(""" + INSERT INTO system_config (config_key, config_value) + VALUES ('newbook_rescrape_interval_hours', :val) + ON CONFLICT (config_key) DO UPDATE SET config_value = EXCLUDED.config_value + """), + {"val": str(body.interval_hours)} + ) + await db.commit() + + from scheduler import apply_newbook_rescrape_schedule + apply_newbook_rescrape_schedule() + + time_result = await db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'sync_newbook_current_rates_time'") + ) + time_row = time_result.fetchone() + time_str = (time_row.config_value if time_row and time_row.config_value else None) or '05:20' + return { + "status": "saved", + "interval_hours": body.interval_hours, + "base_time": time_str, + "rescrape_times": _rescrape_times_from_config(body.interval_hours, time_str), + } + + @router.post("/refresh-date/{rate_date}") async def refresh_single_date( rate_date: str, diff --git a/backend/scheduler.py b/backend/scheduler.py index e8c64a1..d1d24da 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -61,6 +61,53 @@ async def run_scheduled_direct_scrape(): await loop.run_in_executor(None, run_scrape_all_direct) +async def run_short_newbook_rescrape(): + """Fetch next-30-day NewBook rates — lightweight complement to the full nightly run.""" + from jobs.fetch_current_rates import run_fetch_current_rates + await run_fetch_current_rates(horizon_days=30) + + +def _compute_rescrape_times(base_hour: int, base_minute: int, interval_hours: int) -> list: + """Return (hour, minute) tuples evenly spaced around the clock, excluding the base (main) run.""" + return [ + ((base_hour + offset) % 24, base_minute) + for offset in range(interval_hours, 24, interval_hours) + ] + + +def apply_newbook_rescrape_schedule(): + """Read config and (re)register 30-day NewBook rescrape jobs without a scheduler restart.""" + for job in list(scheduler.get_jobs()): + if job.id.startswith('nb_rescrape_'): + scheduler.remove_job(job.id) + + interval_str = get_config_value('newbook_rescrape_interval_hours', '0') + try: + interval_hours = int(interval_str or '0') + except ValueError: + interval_hours = 0 + + if interval_hours not in (2, 4, 6, 12): + logger.info("NewBook 30-day rescrape disabled") + return + + rates_hour, rates_minute = get_sync_time('newbook_current_rates', 5, 20) + times = _compute_rescrape_times(rates_hour, rates_minute, interval_hours) + + for i, (h, m) in enumerate(times): + scheduler.add_job( + run_short_newbook_rescrape, + CronTrigger(hour=h, minute=m), + id=f'nb_rescrape_{i}', + replace_existing=True, + ) + logger.info( + f"NewBook rescrape: {len(times)} extra run(s) at {interval_hours}h intervals " + f"(base {rates_hour:02d}:{rates_minute:02d}): " + f"{[f'{h:02d}:{m:02d}' for h, m in times]}" + ) + + async def run_scheduled_parity_check(): from jobs.check_rate_parity import run_parity_check import asyncio @@ -159,6 +206,9 @@ def start_scheduler(): scheduler.start() logger.info(f"Scheduler started: booking scrape at {scrape_hour:02d}:{scrape_minute:02d}, rates fetch at {rates_hour:02d}:{rates_minute:02d}, direct scrape at 06:00, parity check at 06:45, watchdog every 30m") + # 30-day NewBook rescrape — optional intraday refresh, interval from config + apply_newbook_rescrape_schedule() + def shutdown_scheduler(): if scheduler.running: diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 593264a..277d46a 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Save, RefreshCw, Database, Clock, BedDouble, ChevronUp, ChevronDown, Network, ShieldCheck, AlertTriangle } from 'lucide-react' +import { Save, RefreshCw, Database, Clock, BedDouble, ChevronUp, ChevronDown, Network, ShieldCheck, AlertTriangle, Timer } from 'lucide-react' import api from '../api' const TABS = [ @@ -664,6 +664,109 @@ function NewbookTab({ config, isLoading, onSave, onSyncNow, saving, syncing }: N + + + ) +} + +// ─── 30-day Rescrape Schedule ───────────────────────────────────────────────── + +interface RescrapeSchedule { + interval_hours: number + base_time: string + rescrape_times: string[] +} + +function RescrapeCard() { + const qc = useQueryClient() + const [interval, setInterval] = useState(0) + const [loaded, setLoaded] = useState(false) + + const { data, isLoading } = useQuery({ + queryKey: ['rescrape-schedule'], + queryFn: () => api.get('/bookability/config/rescrape-schedule').then(r => r.data), + }) + + useEffect(() => { + if (data && !loaded) { + setInterval(data.interval_hours) + setLoaded(true) + } + }, [data, loaded]) + + const save = useMutation({ + mutationFn: () => + api.post('/bookability/config/rescrape-interval', { interval_hours: interval }).then(r => r.data as RescrapeSchedule), + onSuccess: () => qc.invalidateQueries({ queryKey: ['rescrape-schedule'] }), + }) + + const previewTimes = (data && loaded) + ? (() => { + const baseTime = data.base_time + if (interval === 0) return [] + const [bh, bm] = baseTime.split(':').map(Number) + const times: string[] = [] + for (let offset = interval; offset < 24; offset += interval) { + const h = (bh + offset) % 24 + times.push(`${String(h).padStart(2, '0')}:${String(bm).padStart(2, '0')}`) + } + return times + })() + : data?.rescrape_times ?? [] + + if (isLoading) return null + + return ( +
+
+ + + 30-day Rate Rescrape + + 0 ? 'badge-success' : 'badge-neutral'}`}> + {interval > 0 ? `Every ${interval}h` : 'Disabled'} + +
+
+

+ Re-fetches the next 30 days of NewBook rates throughout the day, between the main + nightly run at {data?.base_time ?? '05:20'}. Useful for keeping + Bookability fresh on days when tariff availability changes. +

+ +
+ + + {save.isSuccess && ( + ✓ Saved + )} +
+ + {previewTimes.length > 0 && ( +
+ Rescrape runs at: + {previewTimes.join(' · ')} + (in addition to the {data?.base_time} main run) +
+ )} +
) }