Files
cannaiq/docs/CANNABRANDS_API_FRONTEND_SPEC.md
Kelly 9d8972aa86 Fix category-crawler-jobs store lookup query
- Fix column name from s.dutchie_plus_url to s.dutchie_url
- Add availability tracking and product freshness APIs
- Add crawl script for sequential dispensary processing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 00:07:00 -07:00

28 KiB

Cannabrands Dashboard API - Front-End Ready Specification

Overview

This document defines the front-end-ready API responses for the Cannabrands brand dashboard. All responses are optimized for direct consumption by dashboard cards, widgets, and chart components.

Base URL: https://api.cannabrands.app/v1

Authentication: Bearer token (JWT) with brand_key claim


1. Brand Key Resolution Flow

Resolution Steps

API Request: GET /v1/brands/cb_12345/dashboard/summary
                              ↓
                         brand_key = "cb_12345"
                              ↓
              ┌───────────────┴───────────────┐
              │  1. JWT Validation            │
              │     Extract brand_key from    │
              │     token claims              │
              │     Verify: token.brand_key   │
              │     === "cb_12345"            │
              └───────────────┬───────────────┘
                              ↓
              ┌───────────────┴───────────────┐
              │  2. Brand Resolution          │
              │     Step A: Try cannabrands_id│
              │       SELECT id FROM          │
              │       canonical_brands WHERE  │
              │       cannabrands_id =        │
              │       'cb_12345'              │
              │                               │
              │     Step B (fallback): Try    │
              │       cannabrands_slug        │
              │       SELECT id FROM          │
              │       canonical_brands WHERE  │
              │       cannabrands_slug =      │
              │       'cb_12345'              │
              └───────────────┬───────────────┘
                              ↓
                     canonical_brand_id = 42
                              ↓
              ┌───────────────┴───────────────┐
              │  3. All Queries Scoped        │
              │     WHERE canonical_brand_id  │
              │     = 42                      │
              └───────────────────────────────┘

TypeScript Implementation

async function resolveBrandKey(db: Pool, brandKey: string): Promise<number | null> {
  // Step A: Try cannabrands_id (primary identifier)
  const byId = await db.query(
    `SELECT id FROM canonical_brands WHERE cannabrands_id = $1`,
    [brandKey]
  );
  if (byId.rows.length > 0) {
    return byId.rows[0].id;
  }

  // Step B: Fallback to cannabrands_slug
  const bySlug = await db.query(
    `SELECT id FROM canonical_brands WHERE cannabrands_slug = $1`,
    [brandKey]
  );
  if (bySlug.rows.length > 0) {
    return bySlug.rows[0].id;
  }

  return null;
}

2. Dashboard Summary Endpoint

GET /v1/brands/:brand_key/dashboard/summary

Returns all KPIs needed for the main dashboard cards in a single request.

Query Parameters:

Param Type Required Default Description
window string No 30d Comparison window: 7d, 30d, 90d

Response (Success - 200):

{
  "brand_key": "cb_12345",
  "brand_name": "Raw Garden",
  "as_of": "2025-01-15T08:00:00.000Z",
  "window": "30d",

  "store_footprint": {
    "current": 127,
    "previous": 112,
    "delta": 15,
    "delta_percent": 13.39,
    "direction": "up"
  },

  "active_skus": {
    "current": 543,
    "previous": 476,
    "delta": 67,
    "delta_percent": 14.08,
    "direction": "up"
  },

  "in_stock_rate": {
    "current": 90.1,
    "previous": 88.7,
    "delta": 1.4,
    "direction": "up"
  },

  "promo_exposure": {
    "skus_on_promo": 34,
    "stores_running_promos": 23,
    "percent_of_skus": 6.3,
    "direction": "stable"
  },

  "price_summary": {
    "avg_cents": 4250,
    "min_cents": 2500,
    "max_cents": 8500,
    "avg_formatted": "$42.50",
    "min_formatted": "$25.00",
    "max_formatted": "$85.00"
  },

  "activity_last_7d": {
    "stores_added": 3,
    "stores_removed": 0,
    "new_skus": 12,
    "removed_skus": 2,
    "price_changes": 8
  }
}

Dashboard Card Mapping:

Card Fields
"Store Distribution" store_footprint.current, store_footprint.delta, store_footprint.direction
"Active SKUs" active_skus.current, active_skus.delta, active_skus.direction
"In-Stock Rate" in_stock_rate.current + "%" suffix, in_stock_rate.direction
"Promotional Activity" promo_exposure.skus_on_promo, promo_exposure.stores_running_promos
"Avg Price" price_summary.avg_formatted
"Recent Activity" activity_last_7d.*

Response (Empty State - 200):

{
  "brand_key": "cb_99999",
  "brand_name": "New Brand",
  "as_of": "2025-01-15T08:00:00.000Z",
  "window": "30d",

  "store_footprint": {
    "current": 0,
    "previous": 0,
    "delta": 0,
    "delta_percent": 0,
    "direction": "stable"
  },

  "active_skus": {
    "current": 0,
    "previous": 0,
    "delta": 0,
    "delta_percent": 0,
    "direction": "stable"
  },

  "in_stock_rate": {
    "current": null,
    "previous": null,
    "delta": null,
    "direction": "stable"
  },

  "promo_exposure": {
    "skus_on_promo": 0,
    "stores_running_promos": 0,
    "percent_of_skus": 0,
    "direction": "stable"
  },

  "price_summary": {
    "avg_cents": null,
    "min_cents": null,
    "max_cents": null,
    "avg_formatted": null,
    "min_formatted": null,
    "max_formatted": null
  },

  "activity_last_7d": {
    "stores_added": 0,
    "stores_removed": 0,
    "new_skus": 0,
    "removed_skus": 0,
    "price_changes": 0
  }
}

Direction Values:

Value Meaning UI Treatment
"up" Increased from previous period Green arrow, positive indicator
"down" Decreased from previous period Red arrow, negative indicator
"stable" No significant change Gray dash, neutral indicator

3. Store Timeseries Endpoint

GET /v1/brands/:brand_key/dashboard/store-timeseries

Returns daily store/SKU data points optimized for line/area charts.

Query Parameters:

Param Type Required Default Description
from string No 30 days ago ISO date YYYY-MM-DD
to string No today ISO date YYYY-MM-DD
interval string No day Aggregation: day, week, month

Response (Success - 200):

{
  "brand_key": "cb_12345",
  "period": {
    "from": "2024-12-16",
    "to": "2025-01-15",
    "interval": "day",
    "points_count": 31
  },

  "points": [
    {
      "date": "2024-12-16",
      "stores": 112,
      "stores_added": 2,
      "stores_removed": 0,
      "stores_net": 2,
      "skus": 476,
      "skus_in_stock": 423,
      "skus_new": 5,
      "skus_removed": 1,
      "skus_net": 4
    },
    {
      "date": "2024-12-17",
      "stores": 114,
      "stores_added": 2,
      "stores_removed": 0,
      "stores_net": 2,
      "skus": 482,
      "skus_in_stock": 430,
      "skus_new": 8,
      "skus_removed": 2,
      "skus_net": 6
    }
  ],

  "summary": {
    "stores_start": 112,
    "stores_end": 127,
    "stores_peak": 128,
    "stores_low": 112,
    "skus_start": 476,
    "skus_end": 543,
    "skus_peak": 545,
    "skus_low": 476
  }
}

Chart Component Usage:

// Example: Recharts line chart
const storeChartData = response.points.map(p => ({
  date: p.date,
  stores: p.stores,
  skus: p.skus
}));

<LineChart data={storeChartData}>
  <Line dataKey="stores" stroke="#3b82f6" name="Stores" />
  <Line dataKey="skus" stroke="#10b981" name="SKUs" />
</LineChart>

Response (Empty State - 200):

{
  "brand_key": "cb_99999",
  "period": {
    "from": "2024-12-16",
    "to": "2025-01-15",
    "interval": "day",
    "points_count": 0
  },
  "points": [],
  "summary": {
    "stores_start": null,
    "stores_end": null,
    "stores_peak": null,
    "stores_low": null,
    "skus_start": null,
    "skus_end": null,
    "skus_peak": null,
    "skus_low": null
  }
}

4. Promo Timeseries Endpoint

GET /v1/brands/:brand_key/dashboard/promo-timeseries

Returns daily promotional activity data points for stacked bar/area charts.

Query Parameters:

Param Type Required Default Description
from string No 30 days ago ISO date YYYY-MM-DD
to string No today ISO date YYYY-MM-DD
interval string No day Aggregation: day, week, month

Response (Success - 200):

{
  "brand_key": "cb_12345",
  "period": {
    "from": "2024-12-16",
    "to": "2025-01-15",
    "interval": "day",
    "points_count": 31
  },

  "points": [
    {
      "date": "2024-12-16",
      "total_promos": 45,
      "percent_off": 28,
      "dollar_off": 5,
      "bogo": 8,
      "bundle": 2,
      "set_price": 1,
      "other": 1,
      "avg_discount_percent": 18.5,
      "stores_with_promos": 23
    },
    {
      "date": "2024-12-17",
      "total_promos": 52,
      "percent_off": 32,
      "dollar_off": 6,
      "bogo": 10,
      "bundle": 2,
      "set_price": 1,
      "other": 1,
      "avg_discount_percent": 19.2,
      "stores_with_promos": 27
    }
  ],

  "summary": {
    "total_promo_days": 45,
    "peak_promos": 67,
    "peak_date": "2024-12-24",
    "avg_daily_promos": 48.2,
    "most_common_type": "percent_off",
    "avg_discount_percent": 18.8
  }
}

Stacked Bar Chart Usage:

// Promo breakdown stacked bar
const promoTypes = ['percent_off', 'dollar_off', 'bogo', 'bundle', 'set_price', 'other'];

<BarChart data={response.points}>
  {promoTypes.map((type, i) => (
    <Bar key={type} dataKey={type} stackId="promos" fill={colors[i]} />
  ))}
</BarChart>

Response (Empty State - 200):

{
  "brand_key": "cb_99999",
  "period": {
    "from": "2024-12-16",
    "to": "2025-01-15",
    "interval": "day",
    "points_count": 0
  },
  "points": [],
  "summary": {
    "total_promo_days": 0,
    "peak_promos": null,
    "peak_date": null,
    "avg_daily_promos": 0,
    "most_common_type": null,
    "avg_discount_percent": null
  }
}

5. Store Changes Endpoint

GET /v1/brands/:brand_key/dashboard/store-changes

Returns recent store additions and removals for "New Stores" / "Lost Stores" widgets.

Query Parameters:

Param Type Required Default Description
days number No 30 Lookback period (max 90)
limit number No 10 Max results per list

Response (Success - 200):

{
  "brand_key": "cb_12345",
  "period": {
    "days": 30,
    "from": "2024-12-16",
    "to": "2025-01-15"
  },

  "stores_added": {
    "count": 15,
    "list": [
      {
        "store_id": 142,
        "store_name": "Green Thumb LA",
        "store_slug": "green-thumb-la",
        "dispensary_name": "Green Thumb Dispensary",
        "city": "Los Angeles",
        "state": "CA",
        "date_added": "2025-01-14",
        "days_ago": 1,
        "initial_sku_count": 12
      },
      {
        "store_id": 156,
        "store_name": "Harvest House SF",
        "store_slug": "harvest-house-sf",
        "dispensary_name": "Harvest House",
        "city": "San Francisco",
        "state": "CA",
        "date_added": "2025-01-12",
        "days_ago": 3,
        "initial_sku_count": 8
      }
    ]
  },

  "stores_removed": {
    "count": 2,
    "list": [
      {
        "store_id": 78,
        "store_name": "MedMen Venice",
        "store_slug": "medmen-venice",
        "dispensary_name": "MedMen",
        "city": "Venice",
        "state": "CA",
        "date_removed": "2025-01-10",
        "days_ago": 5,
        "tenure_days": 156,
        "last_sku_count": 6
      }
    ]
  },

  "stores_reappeared": {
    "count": 1,
    "list": [
      {
        "store_id": 92,
        "store_name": "The Cannabist - Sacramento",
        "store_slug": "cannabist-sacramento",
        "dispensary_name": "The Cannabist",
        "city": "Sacramento",
        "state": "CA",
        "date_reappeared": "2025-01-08",
        "days_ago": 7,
        "days_absent": 21,
        "sku_count": 4
      }
    ]
  }
}

Widget Mapping:

Widget Data Source
"New Stores" badge count stores_added.count
"New Stores" list stores_added.list
"Lost Stores" badge count stores_removed.count
"Lost Stores" list stores_removed.list
"Returned" badge count stores_reappeared.count

Response (Empty State - 200):

{
  "brand_key": "cb_99999",
  "period": {
    "days": 30,
    "from": "2024-12-16",
    "to": "2025-01-15"
  },
  "stores_added": {
    "count": 0,
    "list": []
  },
  "stores_removed": {
    "count": 0,
    "list": []
  },
  "stores_reappeared": {
    "count": 0,
    "list": []
  }
}

6. Stores Drill-Down Endpoint

GET /v1/brands/:brand_key/dashboard/stores

Returns all stores carrying this brand with current metrics for a detailed table view.

Query Parameters:

Param Type Required Default Description
status string No active Filter: active, removed, all
state string No - Filter by state code (e.g., CA)
sort string No sku_count Sort: sku_count, name, date_added, tenure
order string No desc Order: asc, desc
limit number No 50 Max results (max 200)
offset number No 0 Pagination offset
search string No - Search store/dispensary name

Response (Success - 200):

{
  "brand_key": "cb_12345",
  "filters": {
    "status": "active",
    "state": null,
    "search": null
  },
  "pagination": {
    "total": 127,
    "limit": 50,
    "offset": 0,
    "has_more": true
  },

  "stores": [
    {
      "store_id": 42,
      "store_name": "Green Thumb LA",
      "store_slug": "green-thumb-la",
      "dispensary_name": "Green Thumb Dispensary",
      "city": "Los Angeles",
      "state": "CA",
      "dutchie_url": "https://dutchie.com/dispensary/green-thumb-la",

      "presence": {
        "status": "active",
        "first_seen": "2024-06-15",
        "last_seen": "2025-01-15",
        "tenure_days": 214
      },

      "sku_counts": {
        "active": 24,
        "in_stock": 21,
        "on_promo": 3,
        "total_ever": 28
      },

      "price_stats": {
        "avg_cents": 4500,
        "min_cents": 2500,
        "max_cents": 8500
      },

      "activity_7d": {
        "new_skus": 2,
        "removed_skus": 0,
        "price_changes": 1
      }
    }
  ],

  "aggregates": {
    "total_stores": 127,
    "states_count": 8,
    "avg_skus_per_store": 4.3,
    "top_states": [
      { "state": "CA", "count": 45 },
      { "state": "CO", "count": 28 },
      { "state": "WA", "count": 18 }
    ]
  }
}

Table Column Mapping:

Column Field
Store Name store_name (link to dutchie_url)
Location city, state
SKUs sku_counts.active
In Stock sku_counts.in_stock
On Promo sku_counts.on_promo
Avg Price price_stats.avg_cents / 100
Since presence.first_seen
Tenure presence.tenure_days + " days"

Response (Empty State - 200):

{
  "brand_key": "cb_99999",
  "filters": {
    "status": "active",
    "state": null,
    "search": null
  },
  "pagination": {
    "total": 0,
    "limit": 50,
    "offset": 0,
    "has_more": false
  },
  "stores": [],
  "aggregates": {
    "total_stores": 0,
    "states_count": 0,
    "avg_skus_per_store": 0,
    "top_states": []
  }
}

7. Products Drill-Down Endpoint

GET /v1/brands/:brand_key/dashboard/products

Returns all products for this brand with lifecycle and pricing data.

Query Parameters:

Param Type Required Default Description
store_slug string No - Filter by specific store
status string No active Filter: active, stale, removed, all
in_stock string No - Filter: true, false
on_promo string No - Filter: true, false
sort string No store_count Sort: store_count, name, price, date_added
order string No desc Order: asc, desc
limit number No 50 Max results (max 200)
offset number No 0 Pagination offset
search string No - Search product name

Response (Success - 200):

{
  "brand_key": "cb_12345",
  "filters": {
    "store_slug": null,
    "status": "active",
    "in_stock": null,
    "on_promo": null,
    "search": null
  },
  "pagination": {
    "total": 543,
    "limit": 50,
    "offset": 0,
    "has_more": true
  },

  "products": [
    {
      "product_id": 12345,
      "fingerprint": "a1b2c3d4e5f6...",
      "name": "Live Resin - Slurm OG",
      "full_name": "Raw Garden Live Resin - Slurm OG",
      "variant": "1g",
      "weight": "1g",

      "distribution": {
        "store_count_current": 45,
        "store_count_previous_30d": 38,
        "store_count_delta": 7,
        "store_count_direction": "up",
        "states": ["CA", "CO", "WA", "NV"]
      },

      "pricing": {
        "avg_price_cents": 4500,
        "min_price_cents": 3999,
        "max_price_cents": 5499,
        "price_spread_percent": 37.5,
        "avg_formatted": "$45.00",
        "min_formatted": "$39.99",
        "max_formatted": "$54.99"
      },

      "stock": {
        "in_stock_count": 42,
        "out_of_stock_count": 3,
        "in_stock_percent": 93.3
      },

      "promos": {
        "stores_on_promo": 8,
        "percent_on_promo": 17.8,
        "percent_time_on_special_30d": 23.5,
        "common_promo_type": "percent_off"
      },

      "lifecycle": {
        "first_seen": "2024-08-10",
        "days_tracked": 158,
        "status": "active"
      }
    }
  ],

  "aggregates": {
    "total_products": 543,
    "total_in_stock": 489,
    "total_on_promo": 34,
    "avg_stores_per_product": 4.2,
    "categories": [
      { "name": "Concentrates", "count": 312 },
      { "name": "Vapes", "count": 156 },
      { "name": "Flower", "count": 75 }
    ]
  }
}

Table Column Mapping:

Column Field
Product name
Size variant or weight
Stores distribution.store_count_current
Avg Price pricing.avg_formatted
Price Range pricing.min_formatted - pricing.max_formatted
In Stock % stock.in_stock_percent
On Promo promos.stores_on_promo stores
First Seen lifecycle.first_seen

Response (Empty State - 200):

{
  "brand_key": "cb_99999",
  "filters": {
    "store_slug": null,
    "status": "active",
    "in_stock": null,
    "on_promo": null,
    "search": null
  },
  "pagination": {
    "total": 0,
    "limit": 50,
    "offset": 0,
    "has_more": false
  },
  "products": [],
  "aggregates": {
    "total_products": 0,
    "total_in_stock": 0,
    "total_on_promo": 0,
    "avg_stores_per_product": 0,
    "categories": []
  }
}

8. Empty State Behavior Summary

All endpoints return HTTP 200 with structured empty data rather than errors when a brand has no data:

Endpoint Empty Indicator UI Treatment
/dashboard/summary store_footprint.current === 0 Show "No data yet" card overlay
/dashboard/store-timeseries points.length === 0 Show empty chart with "No data" message
/dashboard/promo-timeseries points.length === 0 Show empty chart with "No data" message
/dashboard/store-changes All .count === 0 Show "No changes in this period"
/dashboard/stores pagination.total === 0 Show empty state illustration
/dashboard/products pagination.total === 0 Show empty state illustration

Empty State Detection (TypeScript):

function hasBrandData(summary: DashboardSummary): boolean {
  return summary.store_footprint.current > 0 || summary.active_skus.current > 0;
}

function hasTimeseriesData(data: TimeseriesResponse): boolean {
  return data.points.length > 0;
}

function hasStoreChanges(data: StoreChangesResponse): boolean {
  return data.stores_added.count > 0 ||
         data.stores_removed.count > 0 ||
         data.stores_reappeared.count > 0;
}

9. Error Responses

All endpoints use consistent error response format:

401 Unauthorized

{
  "error": "Unauthorized",
  "code": "AUTH_REQUIRED",
  "message": "Valid authentication token required"
}

403 Forbidden

{
  "error": "Access denied",
  "code": "BRAND_ACCESS_DENIED",
  "message": "You do not have access to brand: cb_12345"
}

404 Brand Not Found

{
  "error": "Brand not found",
  "code": "BRAND_NOT_FOUND",
  "message": "No brand found with key: cb_unknown"
}

404 Feature Disabled

{
  "error": "Feature not available",
  "code": "FEATURE_DISABLED",
  "message": "Brand intelligence features are temporarily unavailable"
}

422 Validation Error

{
  "error": "Validation error",
  "code": "INVALID_PARAMS",
  "message": "Invalid parameters",
  "details": {
    "window": "Must be one of: 7d, 30d, 90d",
    "limit": "Must be between 1 and 200"
  }
}

500 Server Error

{
  "error": "Internal server error",
  "code": "INTERNAL_ERROR",
  "message": "An unexpected error occurred",
  "request_id": "req_abc123"
}

10. TypeScript Response Types

// Common types
type Direction = 'up' | 'down' | 'stable';

interface DeltaMetric {
  current: number;
  previous: number;
  delta: number;
  delta_percent: number;
  direction: Direction;
}

interface NullableDeltaMetric {
  current: number | null;
  previous: number | null;
  delta: number | null;
  direction: Direction;
}

// Summary endpoint
interface DashboardSummary {
  brand_key: string;
  brand_name: string;
  as_of: string;
  window: '7d' | '30d' | '90d';
  store_footprint: DeltaMetric;
  active_skus: DeltaMetric;
  in_stock_rate: NullableDeltaMetric;
  promo_exposure: {
    skus_on_promo: number;
    stores_running_promos: number;
    percent_of_skus: number;
    direction: Direction;
  };
  price_summary: {
    avg_cents: number | null;
    min_cents: number | null;
    max_cents: number | null;
    avg_formatted: string | null;
    min_formatted: string | null;
    max_formatted: string | null;
  };
  activity_last_7d: {
    stores_added: number;
    stores_removed: number;
    new_skus: number;
    removed_skus: number;
    price_changes: number;
  };
}

// Timeseries endpoints
interface TimeseriesPeriod {
  from: string;
  to: string;
  interval: 'day' | 'week' | 'month';
  points_count: number;
}

interface StoreTimeseriesPoint {
  date: string;
  stores: number;
  stores_added: number;
  stores_removed: number;
  stores_net: number;
  skus: number;
  skus_in_stock: number;
  skus_new: number;
  skus_removed: number;
  skus_net: number;
}

interface StoreTimeseriesResponse {
  brand_key: string;
  period: TimeseriesPeriod;
  points: StoreTimeseriesPoint[];
  summary: {
    stores_start: number | null;
    stores_end: number | null;
    stores_peak: number | null;
    stores_low: number | null;
    skus_start: number | null;
    skus_end: number | null;
    skus_peak: number | null;
    skus_low: number | null;
  };
}

interface PromoTimeseriesPoint {
  date: string;
  total_promos: number;
  percent_off: number;
  dollar_off: number;
  bogo: number;
  bundle: number;
  set_price: number;
  other: number;
  avg_discount_percent: number;
  stores_with_promos: number;
}

interface PromoTimeseriesResponse {
  brand_key: string;
  period: TimeseriesPeriod;
  points: PromoTimeseriesPoint[];
  summary: {
    total_promo_days: number;
    peak_promos: number | null;
    peak_date: string | null;
    avg_daily_promos: number;
    most_common_type: string | null;
    avg_discount_percent: number | null;
  };
}

// Store changes
interface StoreChangeEntry {
  store_id: number;
  store_name: string;
  store_slug: string;
  dispensary_name: string;
  city: string;
  state: string;
}

interface StoreAddedEntry extends StoreChangeEntry {
  date_added: string;
  days_ago: number;
  initial_sku_count: number;
}

interface StoreRemovedEntry extends StoreChangeEntry {
  date_removed: string;
  days_ago: number;
  tenure_days: number;
  last_sku_count: number;
}

interface StoreReappearedEntry extends StoreChangeEntry {
  date_reappeared: string;
  days_ago: number;
  days_absent: number;
  sku_count: number;
}

interface StoreChangesResponse {
  brand_key: string;
  period: {
    days: number;
    from: string;
    to: string;
  };
  stores_added: {
    count: number;
    list: StoreAddedEntry[];
  };
  stores_removed: {
    count: number;
    list: StoreRemovedEntry[];
  };
  stores_reappeared: {
    count: number;
    list: StoreReappearedEntry[];
  };
}

// Stores drill-down
interface StoreListEntry {
  store_id: number;
  store_name: string;
  store_slug: string;
  dispensary_name: string;
  city: string;
  state: string;
  dutchie_url: string;
  presence: {
    status: 'active' | 'removed';
    first_seen: string;
    last_seen: string;
    tenure_days: number;
  };
  sku_counts: {
    active: number;
    in_stock: number;
    on_promo: number;
    total_ever: number;
  };
  price_stats: {
    avg_cents: number;
    min_cents: number;
    max_cents: number;
  };
  activity_7d: {
    new_skus: number;
    removed_skus: number;
    price_changes: number;
  };
}

interface StoresResponse {
  brand_key: string;
  filters: {
    status: string;
    state: string | null;
    search: string | null;
  };
  pagination: {
    total: number;
    limit: number;
    offset: number;
    has_more: boolean;
  };
  stores: StoreListEntry[];
  aggregates: {
    total_stores: number;
    states_count: number;
    avg_skus_per_store: number;
    top_states: { state: string; count: number }[];
  };
}

// Products drill-down
interface ProductListEntry {
  product_id: number;
  fingerprint: string;
  name: string;
  full_name: string;
  variant: string;
  weight: string;
  distribution: {
    store_count_current: number;
    store_count_previous_30d: number;
    store_count_delta: number;
    store_count_direction: Direction;
    states: string[];
  };
  pricing: {
    avg_price_cents: number;
    min_price_cents: number;
    max_price_cents: number;
    price_spread_percent: number;
    avg_formatted: string;
    min_formatted: string;
    max_formatted: string;
  };
  stock: {
    in_stock_count: number;
    out_of_stock_count: number;
    in_stock_percent: number;
  };
  promos: {
    stores_on_promo: number;
    percent_on_promo: number;
    percent_time_on_special_30d: number;
    common_promo_type: string | null;
  };
  lifecycle: {
    first_seen: string;
    days_tracked: number;
    status: 'active' | 'stale' | 'removed';
  };
}

interface ProductsResponse {
  brand_key: string;
  filters: {
    store_slug: string | null;
    status: string;
    in_stock: string | null;
    on_promo: string | null;
    search: string | null;
  };
  pagination: {
    total: number;
    limit: number;
    offset: number;
    has_more: boolean;
  };
  products: ProductListEntry[];
  aggregates: {
    total_products: number;
    total_in_stock: number;
    total_on_promo: number;
    avg_stores_per_product: number;
    categories: { name: string; count: number }[];
  };
}

Document Version

Version Date Author Changes
1.0 2025-01-15 Claude Initial front-end ready specification