← Back to documentation

comparison-sparse-state

Sparse-State and Low-Coverage Refinements for Comparison UI

Issue: BUY-2530
Goal: BUY-2037 Goal
Status: Draft - Implementation Notes


Overview

This document defines the UI state handling for products with sparse data (missing prices, stale data) and low retailer coverage (1-2 retailers). These refinements ensure AI agents and users receive actionable signals when data quality is degraded.


1. Retailer Coverage States

1.1 States

StateRetailer CountUI Behavior
Full Coverage3+ retailers with pricesStandard comparison table
Low Coverage2 retailersCondensed 2-column layout
Minimal Coverage1 retailerSingle-card with "Limited Data" warning
No Coverage0 retailersEmpty state with explanation

1.2 Layout Specifications

Full Coverage (3+ retailers)

┌─────────────────────────────────────────────────────┐
│  Product Title                          [Cheapest] │
│  ─────────────────────────────────────────────────  │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐  │
│  │ Retailer│ │ Retailer│ │ Retailer│ │ Retailer│  │
│  │    A    │ │    B    │ │    C    │ │    D    │  │
│  │ $Price  │ │ $Price  │ │ $Price  │ │ $Price  │  │
│  │ [Buy]   │ │ [Buy]   │ │ [Buy]   │ │ [Buy]   │  │
│  └─────────┘ └─────────┘ └─────────┘ └─────────┘  │
└─────────────────────────────────────────────────────┘

Low Coverage (2 retailers)

┌─────────────────────────────────────────────────────┐
│  Product Title                    ⚠ Limited Coverage │
│  ─────────────────────────────────────────────────  │
│  ┌───────────────────┐  ┌───────────────────┐      │
│  │    Retailer A     │  │    Retailer B     │      │
│  │      $Price       │  │      $Price       │      │
│  │   Rating: 4.5★   │  │   Rating: 4.2★   │      │
│  │     [Buy Now]     │  │     [Buy Now]     │      │
│  └───────────────────┘  └───────────────────┘      │
│                                                     │
│  💡 More retailers coming soon                      │
└─────────────────────────────────────────────────────┘

Minimal Coverage (1 retailer)

┌─────────────────────────────────────────────────────┐
│  Product Title                 ⚠️ Limited Coverage   │
│  ─────────────────────────────────────────────────  │
│  ┌─────────────────────────────────────────┐        │
│  │            Retailer A Only               │        │
│  │                                         │        │
│  │              $Price                    │        │
│  │           Rating: 4.5★ (234)           │        │
│  │                                         │        │
│  │            [Buy Now]                   │        │
│  └─────────────────────────────────────────┘        │
│                                                     │
│  ⚠️ Price comparisons unavailable - limited data   │
└─────────────────────────────────────────────────────┘

2. Content Hierarchy: Warning vs Value Signal

2.1 Value Signals (Positive)

SignalIconMeaning
Best Price🏆Lowest price among available retailers
Best RatingHighest rated option
Fastest Shipping🚚Lowest shipping days
Best Deal💰Best overall value (price + rating + availability)
In StockConfirmed available
Fresh Data🟢Data < 24 hours old

2.2 Warning Signals (Degraded Data)

SignalIconMeaningAction Required
Stale Data🟡Data 7-30 days oldVerify price before purchase
Very Stale Data🔴Data > 30 days oldDo not recommend - may be outdated
Price MissingNo price availableCannot compare
Partial Coverage⚠️< 3 retailersLimited comparison
Retailer Unavailable⏸️Retailer API downRetry later
Low ConfidenceMatch score < 0.7Manual verification suggested

3. Data Freshness Tiers

3.1 Tier Definitions

TierAge RangeUI Treatmentis_available Behavior
Fresh< 24 hoursGreen badgeTrust value
Recent24h - 7 daysNo badgeTrust value
Stale7 - 30 daysYellow warningTrust but show warning
Very Stale> 30 daysRed warningSet is_available: false

3.2 Freshness Display Rules

function getFreshnessBadge(data_freshness: string): BadgeConfig {
  switch (data_freshness) {
    case "fresh":
      return { color: "green", label: "Fresh", icon: "🟢" };
    case "recent":
      return { color: "none", label: "", icon: "" };  // No badge needed
    case "stale":
      return { color: "yellow", label: "Price may vary", icon: "🟡" };
    case "very_stale":
      return { color: "red", label: "Outdated", icon: "🔴" };
    default:
      return { color: "gray", label: "Unknown", icon: "❓" };
  }
}

4. Price Missing States

4.1 Price Missing Reasons

Reasonprice_missing_reasonUI Message
Retailer API errorretailer_api_error"Price unavailable - retailer API issue"
Retailer timeoutretailer_timeout"Price check timed out - retry later"
Product unavailableproduct_unavailable"Product no longer available at this retailer"
Price not disclosedprice_not_disclosed"Price not disclosed by retailer"
Crawl errorcrawl_error"Price data unavailable - scraping issue"

4.2 Price Missing UI Handling

interface PriceMatch {
  price: number | null;
  price_missing: boolean;
  price_missing_reason?: PriceMissingReason;
}

function renderPriceCell(match: PriceMatch): RenderedCell {
  if (match.price_missing) {
    return {
      type: "warning",
      message: getPriceMissingMessage(match.price_missing_reason),
      icon: "⚫",
      actionable: match.price_missing_reason === "retailer_timeout"
    };
  }
  return {
    type: "price",
    value: formatPrice(match.price, match.currency)
  };
}

5. API Signals Required for Each State

5.1 1-Retailer State (Minimal Coverage)

Minimum required signals:

{
  "source_product_id": 12345,
  "source_product_name": "Product Name",
  "total_matches": 1,
  "matches": [
    {
      "id": 12345,
      "source": "shopee_sg",
      "price": 99.00,
      "currency": "SGD",
      "is_available": true,
      "data_freshness": "fresh",
      "price_missing": false,
      "price_missing_reason": null,
      "coverage_note": "limited_coverage"
    }
  ],
  "highlights": {
    "cheapest": { "id": 12345, "source": "shopee_sg", "price": 99.00 },
    "best_rated": null,
    "fastest_shipping": null
  },
  "meta": {
    "retailer_count": 1,
    "price_coverage_pct": 33.3,
    "warning": "limited_coverage"
  }
}

UI must show:

  • Single card with warning banner: "⚠️ Limited Coverage - Only 1 retailer available"
  • No comparison features (cannot compare with self)
  • Suggest user search for alternatives

5.2 2-Retailer State (Low Coverage)

Minimum required signals:

{
  "total_matches": 2,
  "matches": [...],
  "meta": {
    "retailer_count": 2,
    "price_coverage_pct": 66.7,
    "warning": "low_coverage"
  }
}

UI must show:

  • Condensed 2-column layout
  • "⚠️ Low Coverage" badge
  • Cannot compute meaningful statistics (median, spread, etc.)

5.3 Stale Data State

Minimum required signals:

{
  "matches": [
    {
      "id": 12345,
      "source": "shopee_sg",
      "price": 99.00,
      "data_freshness": "stale",
      "last_checked": "2026-04-09T10:00:00Z"
    }
  ],
  "meta": {
    "stale_count": 1,
    "fresh_count": 0
  }
}

UI must show:

  • Yellow warning badge on stale items
  • "Last checked X days ago" timestamp
  • Disclaimer: "Prices may have changed"

5.4 Very Stale Data State

Minimum required signals:

{
  "matches": [
    {
      "id": 12345,
      "source": "lazada_sg",
      "price": 99.00,
      "data_freshness": "very_stale",
      "is_available": false,
      "last_checked": "2026-04-01T08:00:00Z"
    }
  ]
}

UI must show:

  • Red warning badge
  • "Outdated - Do not recommend" overlay
  • is_available: false makes product undismissable

6. Meta Fields for Coverage Tracking

6.1 Recommended Meta Fields

FieldTypeDescription
retailer_countintTotal retailers with this product
price_coverage_pctfloat% of retailers with valid prices
stale_countintNumber of stale offers
very_stale_countintNumber of very stale offers
warningstringTop-level warning code
unavailable_retailersstring[]Retailers that failed to respond

6.2 Meta Implementation

# In CompareResponse
meta: Optional[Dict[str, Any]] = Field(
    None,
    description="Metadata including price_coverage_pct, stale_count, very_stale_count"
)

def compute_meta(matches: List[CompareMatch]) -> Dict[str, Any]:
    total = len(matches)
    with_prices = sum(1 for m in matches if m.price is not None and not m.price_missing)
    stale_count = sum(1 for m in matches if m.data_freshness == "stale")
    very_stale_count = sum(1 for m in matches if m.data_freshness == "very_stale")

    return {
        "retailer_count": total,
        "price_coverage_pct": round((with_prices / total * 100) if total > 0 else 0, 1),
        "stale_count": stale_count,
        "very_stale_count": very_stale_count,
        "warning": "limited_coverage" if total < 3 else None
    }

7. Implementation Checklist

  • Fix BUG-1: Handle None prices in _get_highlights (compare.py:65)
  • Populate price_missing and price_missing_reason fields
  • Implement data_freshness tier classification
  • Populate zero_price flag for free items
  • Implement unavailable_retailers[] tracking
  • Update frontend types with sparse-state handling
  • Add coverage badges to UI components
  • Add freshness indicators to price cards
  • Document pagination behavior for sparse data

8. Backend Changes Required

8.1 Priority 1: Bug Fixes

  • Fix TypeError in _get_highlights when prices are None

8.2 Priority 2: Data Population

  • Implement data_freshness calculation based on updated_at or last_checked
  • Populate price_missing_reason when scraping fails
  • Track unavailable_retailers when retailer APIs fail

8.3 Priority 3: New Meta Fields

  • Add compute_meta() helper to build coverage metadata
  • Return meta object in all compare responses
  • Document meta field semantics

Document version: 1.0
Created for BUY-2530 sparse-state and low-coverage refinements