# CBB Moneyline Bug Report

## Quick Summary

CBB moneyline was showing tomorrow's games instead of today's during US evening hours. The root cause was a UTC-based date filter in `discoverSportsMarkets()` that used `Date.toISOString()` — which returns UTC time — to compute "today." After 4 PM PT / 7 PM ET, the UTC calendar day rolls over to the next day, silently excluding all of today's games from the results. Fixed by switching to local date components (`getFullYear()`/`getMonth()`/`getDate()`).

**File changed:** `apps/dashboard/src/lib/sportsDiscovery/discover.ts`
**Lines changed:** 87–93
**Risk:** Low — isolated to date range filtering in market discovery
**Scope:** Fixes all sports stream views, not just CBB moneyline

---

## Detailed Analysis

### 1. Observed Behavior

| View          | Behavior                                                               |
| ------------- | ---------------------------------------------------------------------- |
| CBB Moneyline | Shows games starting from **tomorrow forward** — today's games missing |
| CBB Spread    | Correctly shows **today's** games                                      |
| CBB Total     | Correctly shows **today's** games                                      |
| NBA Moneyline | Correctly shows **today's** games                                      |

The issue is most visible in the evening (US time) and specifically on CBB moneyline because college basketball has dense daily schedules, making the absence of today's games obvious.

### 2. Root Cause

**File:** `apps/dashboard/src/lib/sportsDiscovery/discover.ts`, line 89 (before fix)

```typescript
// BEFORE (broken):
const todayStr = now.toISOString().slice(0, 10); // "YYYY-MM-DD"
```

`Date.toISOString()` **always returns UTC**. In US time zones, the UTC date is ahead of local time during evening hours:

| Local Time        | UTC Time           | `todayStr` (UTC) | Local Date   | Mismatch? |
| ----------------- | ------------------ | ---------------- | ------------ | --------- |
| 2 PM PT (Feb 13)  | 10 PM UTC (Feb 13) | `2026-02-13`     | `2026-02-13` | No        |
| 4 PM PT (Feb 13)  | 12 AM UTC (Feb 14) | `2026-02-14`     | `2026-02-13` | **YES**   |
| 8 PM ET (Feb 13)  | 1 AM UTC (Feb 14)  | `2026-02-14`     | `2026-02-13` | **YES**   |
| 11 PM ET (Feb 13) | 4 AM UTC (Feb 14)  | `2026-02-14`     | `2026-02-13` | **YES**   |

When `todayStr` is `2026-02-14` but today's game ticker dates are `2026-02-13`, the filter excludes them:

```typescript
if (marketDateStr < todayStr || marketDateStr > cutoffStr) continue;
// '2026-02-13' < '2026-02-14' → true → game EXCLUDED
```

The Kalshi ticker dates (e.g., `26FEB13` in `KXNCAAMBGAME-26FEB13WSUORST`) represent the US game date, not UTC. So comparing against a UTC-derived "today" creates a window every evening where today's games silently disappear.

The code comment originally read `"(avoids timezone issues)"` — but it actually **introduced** the timezone issue.

### 3. Why NBA Moneyline Was Unaffected

NBA moneyline uses a **completely separate data pipeline** — the consolidated stream in `lib/nba.ts`. That pipeline filters by market expiry timestamp, not ticker date strings:

```typescript
// nba.ts:200-203 — timestamp-based (correct)
const marketDate = new Date(m.expiry_time || m.close_time);
return marketDate >= now && marketDate <= cutoff;
```

A game tonight at 7 PM ET has an `expiry_time` in the future regardless of what calendar day UTC thinks it is, so it passes the filter. This is why NBA moneyline continued working while CBB moneyline did not.

### 4. Why CBB Spread/Total Were Unaffected

CBB spread and total views were unaffected for two independent reasons:

1. **Polymarket does not offer CBB spread or total markets.** The `hydratePolySpreads()` and `hydratePolyTotals()` functions in `stream.ts` explicitly exclude CBB via early returns (CBB is not in their sport allow-lists). This means CBB spread/total streams skip Polymarket hydration entirely, proceeding directly from market discovery to the Kalshi WebSocket — bypassing the CORS error cascade described in Section 8 below.

2. **The date bug in `discoverSportsMarkets` does affect all market types equally**, but it was most visible on CBB moneyline due to the dense daily schedule. CBB spread/total may have been tested outside the UTC rollover window, or cached data from earlier sessions masked the issue.

### 5. Full Scope of Impact

**Affected views (all go through `discoverSportsMarkets`):**

- NFL moneyline, spread, total
- NHL moneyline, spread, total
- CBB moneyline, spread, total
- Tennis ATP/WTA moneyline

**Not affected:**

- NBA moneyline (separate consolidated pipeline in `nba.ts`)

**Trigger window:** Daily from ~4 PM PT / 7 PM ET until midnight local time

### 6. The Fix

**File:** `apps/dashboard/src/lib/sportsDiscovery/discover.ts`

Replaced UTC-based date computation with local date components:

```typescript
// BEFORE (UTC — broken in US evenings):
const todayStr = now.toISOString().slice(0, 10);
const cutoffStr = cutoffDate.toISOString().slice(0, 10);

// AFTER (local time — matches Kalshi's US-based ticker dates):
const pad2 = (n: number) => String(n).padStart(2, '0');
const todayStr = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
const cutoffStr = `${cutoffDate.getFullYear()}-${pad2(cutoffDate.getMonth() + 1)}-${pad2(cutoffDate.getDate())}`;
```

`getFullYear()`, `getMonth()`, and `getDate()` return **local** date components, which align with Kalshi's US-based ticker dates. A user at 8 PM ET on Feb 13 now correctly gets `todayStr = '2026-02-13'`, keeping today's games in the results.

### 7. Additional Observations (Separate Issue — Ticker Parser)

During investigation, a secondary concern was identified in the CBB ticker parser (`tickerParser.ts:parseTeamCodes`, lines 117–135). The variable-length team code heuristic for 7-character matchup strings always assumes a **3+4 split**, which fails for **4+3 combinations** (e.g., `UTAHBYU` → parsed as `UTA` + `HBYU` instead of `UTAH` + `BYU`). This causes moneyline book lookups to fail silently for affected games, resulting in missing odds data. A fallback exists (`buildMoneylineGame` lines 121–137 in `stream.ts`) but it depends on name matching that doesn't always succeed. This is a separate issue from the date bug and should be tracked independently.

### 8. Post-Fix Issue: "Hydrating Polymarket 100/100" Hang (CORS + Race Condition)

After the date fix was deployed, CBB moneylines appeared to hang at "Hydrating Polymarket 100/100" with all data columns showing dashes/$0. The date fix itself was correct — games were being discovered — but it exposed a latent CORS/race condition bug in the Polymarket hydration flow.

#### Root Cause

The `hydratePolyMoneylines()` function in `stream.ts` resolves Polymarket slugs for each CBB event. When the direct slug lookup fails (common for CBB due to the variable-length team code issue in Section 7), it falls back to `resolvePolyGameSlugByParticipants()` in `gamma.ts`, which fetches ALL active CBB events from the Gamma API:

```http
GET https://gamma-api.polymarket.com/events?series_id=10470&tag_id=100639&active=true&closed=false&limit=500
```

With ~100 CBB games, each failing the slug lookup, **~100 identical requests** were fired in parallel. The relay server (used to bypass CORS) could only handle a few at a time. The remaining requests fell through to a direct `fetch()` from the browser, which was CORS-blocked by `gamma-api.polymarket.com`, throwing uncaught `TypeError`s.

The error propagation chain:

1. `fetchDirect()` (`gamma.ts`) throws CORS `TypeError` — **uncaught**
2. Propagates through `fetchGammaJson()` → `resolvePolyGameSlugByParticipants()` → resolve promise
3. The resolve promises used `try/finally` with **no `catch`** — errors rejected the promise
4. `Promise.all(resolves)` rejected on the first CORS error
5. `start()` caught it and set `phase = 'error'`
6. **Race condition:** The remaining ~99 promises continued running in the background. Their `finally` blocks called `updateLoadingState('hydrating-polymarket', { done: N, total: 100 })`, overwriting the error state back to `'hydrating-polymarket'` with `isLoading: true`
7. Result: UI shows "Hydrating Polymarket 100/100" with spinner, but the main flow has exited — Kalshi WebSocket never connects

#### Why This Was Only Exposed After the Date Fix

Before the fix, during US evening hours, the UTC date bug excluded today's CBB games from discovery. With zero discovered markets, `hydratePolyMoneylines()` had no events to resolve, so the CORS-prone code path was never triggered.

#### Fixes Applied

**File: `apps/dashboard/src/lib/polymarket/gamma.ts`**

1. **Deduplicated `resolvePolyGameSlugByParticipants` calls** — Added a module-level promise cache (`gammaEventsBySeriesCache`) keyed by URL. The first call for a given series triggers the actual fetch; all concurrent callers await the same promise. Reduces ~100 identical API calls to 1.

2. **Caught CORS errors in `fetchDirect` fallback** — Wrapped the direct fetch fallback in try-catch so CORS/network `TypeError`s return `null` instead of propagating as uncaught exceptions.

**File: `apps/dashboard/src/lib/sportsStream/stream.ts`**

1. **Added `catch` blocks to resolve promises** — Changed `try/finally` to `try/catch/finally` in `hydratePolyMoneylines()`, `hydratePolySpreads()`, and `hydratePolyTotals()`. On error, each promise now returns `{ info: null }` instead of rejecting, preventing `Promise.all` from short-circuiting and eliminating the race condition.

2. **Added `clearGammaEventsCache()` calls** — Each hydration function clears the Gamma events cache at the start of a pass to ensure fresh data.

**File: `apps/dashboard/src/lib/polymarket/marketStream.ts`**

1. **Added 15s WebSocket connect timeout** — The Polymarket WebSocket connect promise previously had no timeout. If the connection hung at TCP level, the promise would hang forever. Added a `setTimeout` that rejects after 15 seconds.
