From 3172df3fae0e1965271a2fec17ddf9f557209244 Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Thu, 11 Dec 2025 22:14:18 +0100 Subject: [PATCH] fix(auctions): prevent time drift + consistent now - Clamp end_time on updates to prevent drift from rounded time-left sources (e.g. Sav) - Use a single request 'now' for time_remaining formatting across endpoints - Avoid 'Ended' flicker caused by processing delays --- backend/app/api/auctions.py | 31 ++++++++++++++----------- backend/app/services/auction_scraper.py | 12 ++++++++++ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/backend/app/api/auctions.py b/backend/app/api/auctions.py index 79dc008..73535ab 100644 --- a/backend/app/api/auctions.py +++ b/backend/app/api/auctions.py @@ -161,11 +161,14 @@ class MarketFeedResponse(BaseModel): # ============== Helper Functions ============== -def _format_time_remaining(end_time: datetime) -> str: +def _format_time_remaining(end_time: datetime, now: Optional[datetime] = None) -> str: """Format time remaining in human-readable format.""" - delta = end_time - datetime.utcnow() + ref = now or datetime.utcnow() + delta = end_time - ref - if delta.total_seconds() <= 0: + # Small grace window to avoid displaying "Ended" due to request processing time. + # If an auction ends within the next ~2 seconds, we show "0m". + if delta.total_seconds() <= -2: return "Ended" hours = int(delta.total_seconds() // 3600) @@ -177,7 +180,7 @@ def _format_time_remaining(end_time: datetime) -> str: elif hours > 0: return f"{hours}h {minutes}m" else: - return f"{minutes}m" + return f"{max(minutes, 0)}m" def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str: @@ -203,7 +206,8 @@ def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str: async def _convert_to_listing( auction: DomainAuction, db: AsyncSession, - include_valuation: bool = True + include_valuation: bool = True, + now: Optional[datetime] = None, ) -> AuctionListing: """Convert database auction to API response.""" valuation_data = None @@ -234,7 +238,7 @@ async def _convert_to_listing( currency=auction.currency, num_bids=auction.num_bids, end_time=auction.end_time, - time_remaining=_format_time_remaining(auction.end_time), + time_remaining=_format_time_remaining(auction.end_time, now=now), buy_now_price=auction.buy_now_price, reserve_met=auction.reserve_met, traffic=auction.traffic, @@ -383,7 +387,7 @@ async def search_auctions( # Convert to response with valuations listings = [] for auction in auctions: - listing = await _convert_to_listing(auction, db, include_valuation=True) + listing = await _convert_to_listing(auction, db, include_valuation=True, now=now) listings.append(listing) # Sort by value_ratio if requested (after valuation) @@ -426,7 +430,8 @@ async def get_ending_soon( Data is scraped from public auction sites - no mock data. """ - cutoff = datetime.utcnow() + timedelta(hours=hours) + now = datetime.utcnow() + cutoff = now + timedelta(hours=hours) query = ( select(DomainAuction) @@ -434,7 +439,7 @@ async def get_ending_soon( and_( DomainAuction.is_active == True, DomainAuction.end_time <= cutoff, - DomainAuction.end_time > datetime.utcnow(), + DomainAuction.end_time > now, ) ) .order_by(DomainAuction.end_time.asc()) @@ -446,7 +451,7 @@ async def get_ending_soon( listings = [] for auction in auctions: - listing = await _convert_to_listing(auction, db, include_valuation=True) + listing = await _convert_to_listing(auction, db, include_valuation=True, now=now) listings.append(listing) return listings @@ -481,7 +486,7 @@ async def get_hot_auctions( listings = [] for auction in auctions: - listing = await _convert_to_listing(auction, db, include_valuation=True) + listing = await _convert_to_listing(auction, db, include_valuation=True, now=now) listings.append(listing) return listings @@ -716,7 +721,7 @@ async def get_smart_opportunities( if opportunity_score < 3: continue - listing = await _convert_to_listing(auction, db, include_valuation=False) + listing = await _convert_to_listing(auction, db, include_valuation=False, now=now) recommendation = ( "🔥 Hot" if opportunity_score >= 10 else @@ -1064,7 +1069,7 @@ async def get_market_feed( source=auction.platform, is_pounce=False, verified=False, - time_remaining=_format_time_remaining(auction.end_time), + time_remaining=_format_time_remaining(auction.end_time, now=now), end_time=auction.end_time, num_bids=auction.num_bids, url=_get_affiliate_url(auction.platform, auction.domain, auction.auction_url), diff --git a/backend/app/services/auction_scraper.py b/backend/app/services/auction_scraper.py index 190c337..968b945 100644 --- a/backend/app/services/auction_scraper.py +++ b/backend/app/services/auction_scraper.py @@ -312,6 +312,18 @@ class AuctionScraperService: existing = existing.scalar_one_or_none() if existing: + # Prevent "end_time drift" on sources that only provide rounded time-left. + # `end_time` must be monotonically decreasing (or stable) across scrapes. + try: + incoming_end = cleaned.get("end_time") + if isinstance(incoming_end, datetime) and existing.end_time: + # Allow tiny increases due to rounding/clock skew, but never extend materially. + tolerance = timedelta(minutes=2) + if incoming_end > existing.end_time + tolerance: + cleaned["end_time"] = existing.end_time + except Exception: + pass + for key, value in cleaned.items(): setattr(existing, key, value) existing.updated_at = datetime.utcnow()