Oracle3
Oracle3: 예측시장 거래 엔진·MCP 서버
AI가 독립적으로 모든 경기 결과를 분석해 시장과 비교, 잘못된 배당률을 찾아냅니다
AI odds intelligence for Polymarket football markets. An independent AI read on every match, compared against the market to spot mispriced odds.
금융TypeScript · CSSGitHub @jpcpais01온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
AI odds-intelligence for Polymarket prediction markets. Home (/, the app's default tab) is
your paper-trading portfolio — see below. Sports is the app's focus: upcoming 1X2 matches from
Polymarket's top 8 European football leagues, with an LLM independently researching and forming
its own probability read on a match before it ever sees Polymarket's price, then comparing the two
to flag matches that might be mispriced.README 전문 보기 ↗AI odds-intelligence for Polymarket prediction markets. Home (/, the app's default tab) is
your paper-trading portfolio — see below. Sports is the app's focus: upcoming 1X2 matches from
Polymarket's top 8 European football leagues, with an LLM independently researching and forming
its own probability read on a match before it ever sees Polymarket's price, then comparing the two
to flag matches that might be mispriced.
This is a paper-trading / research tool: there is no real-money betting. "Saving a pick" just stores your AI's read in your browser's local storage, and Home's portfolio balance is play money you can top up freely — see below.
Discover was the app's second, more general tab — a trending feed of Polymarket's
highest-volume markets across every non-football category (politics, crypto, business,
entertainment, science, ...), analyzed with the same independent-research-then-compare flow
generalized for markets with anywhere from 2 (Yes/No) to a dozen-plus named outcomes. It's hidden
and deactivated for now: it no longer has a Bottom navigation entry, and its route
(app/discover/page.tsx) redirects straight to Home rather than rendering, so a stale bookmark or
the browser's back button can't reach it either. Nothing about it was deleted — the full
implementation (lib/allMarkets.ts, lib/openrouterMarkets.ts, /api/markets,
/api/analyze/market/predict, /api/analyze/market/compare, lib/marketPicks.ts,
components/MarketCard.tsx/MarketAnalysisSheet.tsx/MarketPickDetailSheet.tsx, and more) is
still in the codebase, just not wired up to any page right now, and any market pick or bet placed
on it before deactivation is untouched in storage — Picks and Lab simply don't read
lib/marketPicks.ts anymore while this is off. Re-enabling it is a matter of restoring
app/discover/page.tsx's original component and its BottomNav.tsx entry — both recoverable from
git history.
The Sports tab (app/sports/page.tsx) is the original football-only experience — see below
for how its AI analysis, filtering, and caching work.
Sports lists matches you can still act on. Once a match is over it drops off the list entirely, rather than sitting there for the rest of the day showing odds nobody can bet into.
"Over" is answered in exactly one place, lib/matchClock.ts, and everything else defers to it:
FINISHED is over the
moment it says so, however recently it kicked off.MATCH_OVER_AFTER_MS, 3h — 90 minutes plus
half time, stoppage and any realistic delay, rounded up). It covers leagues the provider doesn't,
fixtures it didn't return, and the case where it simply stops updating: without it, an abandoned
match would stay "in play" on screen forever.That single definition also decides how long mergeGames keeps carrying a game the upstream feed
has dropped, and when live-score polling stops. A saved pick (pruneFinishedPicks,
Picks and Lab) uses a related but deliberately later
threshold — the match being "over" plus a further day's grace — rather than this exact moment.
Settlement doesn't consult this module at
all — it settles from the real score directly, not from a clock-based guess about whether one
exists yet.
Because kickoff and full time are moments that pass with no data arriving to announce them, the page keeps its own 30-second clock tick, so a match appears and disappears on time rather than whenever some unrelated fetch happens to land.
A card's general odds refresh is click-triggered, not automatic (see
Filtering and refreshing) — fine for pre-match prices, but a live match
needs its score and clock updating on its own, with nobody tapping anything. lib/liveScores.ts
gets both from ESPN's public site API (site.api.espn.com) — no key, and no documented rate limit,
unlike football-data.org (still used for the AI digest's
form/standings/head-to-head, a different feature with no ESPN equivalent). This used to run through
football-data.org too, sharing its 10-requests/minute budget with AI analysis: a single match digest
alone is 4-5 requests, so a batch analysis of several could be 30-50, and a live-score poll that
queued behind that would sit past its own route's server timeout and get killed with nothing to
show for it — indistinguishable, from the outside, from "live scores just don't work." Moving to a
source with no shared budget removes the failure mode outright rather than papering over it.
From 15 minutes before kickoff until the match is over, the page polls /api/games/live-scores
every 20 seconds — comfortably inside "at least once a minute," and cheap to do since there's no
budget to protect — sending only the leagues that actually have a match in play right now. Once
ESPN's own clock label says any polled match has reached the 85th minute, that poll tightens to
every 10 seconds instead — since a goal or the final whistle in that stretch can flip both the
result and the market within seconds, on both Sports and Picks
(LATE_GAME_MINUTE/parseElapsedMinutes in app/sports/page.tsx, app/picks/page.tsx,
lib/liveScores.ts). Live odds tighten on their own, earlier threshold (the 80th minute — see
below), so by the time scores reach their own 85th-minute tightening, odds are usually already
polling faster still. The result replaces the guessed "LIVE NOW" badge with
the real thing: the score and ESPN's own live match clock — "63′ 2-1", "HT 1-0", "FT 3-1" —
which is what actually makes a card read as live rather than just eventually-correct; a bare "LIVE"
label never said whether that meant kickoff had just happened or the 90th minute had. It's also
what tells the page a match is finished so it can drop off the list. Two details worth knowing
about, both from the football-data.org era and still true here since the underlying problem was
never about which provider:
getLiveScores only returns matches that are
actually IN_PLAY, PAUSED, or FINISHED — a fixture ESPN's ±1-day scoreboard window happens to
include but that hasn't kicked off yet (or was postponed/suspended/cancelled) is filtered out
before it ever reaches a card. Without that filter, a not-yet-started match would show up as
"LIVE 0-0" with no clock, since nothing downstream distinguishes "no data" from "genuinely
scoreless" — a real bug this app shipped with briefly.MOCK_GAMES=1 skips this entirely, since mock fixtures carry real club names but synthetic kickoff
times and would otherwise risk picking up an unrelated real match between two same-named clubs.
A card's home/draw/away percentages, and the odds-history panel's own chart underneath it, both come
from CLOB (clob.polymarket.com), reusing the exact mechanism Lab and Home's portfolio already
relied on (lib/livePrices.ts). game.odds — Polymarket's Gamma events feed, refreshed at most
every 10 minutes by the general sweep below — is only ever the seed and the fallback for a token
CLOB has no recent trade for, never the number actually shown. Before this, the bars read game.odds
directly (or, once a match kicked off, a similarly Gamma-sourced 10-second poll) — a different
provider than the chart's own data, so the two could disagree even though they were describing "the
same" market at "the same" moment.
Every listed game that HASN'T kicked off yet gets its price fetched from CLOB at the 5-minute-
fidelity 3-hour window the moment the games list itself changes (initial load, a manual refresh, a
live game rejoining after a merge) — not on its own timer, since that already matches the general
sweep's own cadence. A game not returned by CLOB (nothing traded recently enough) just keeps
showing game.odds unchanged.
For any game that has actually kicked off, a separate poll reads the price a completely different
way: not a point off prices-history's bucketed candles at all, but CLOB's /midpoint endpoint —
the order book's current best-bid/best-ask midpoint, with no bucket to close and publish before it's
readable (fetchMidpoint/fetchMidpoints, lib/oddsHistoryServer.ts; /api/live-odds;
fetchMidpointPrices, lib/livePrices.ts). Even prices-history's finest fidelity is a whole
1-minute candle, and a candle only gets published once it's fully elapsed — so polling that endpoint
instantly still meant the number shown could be a minute or more behind the market's actual current
state. The midpoint has nothing to wait on, so it's stale by at most however long ago the last poll
ran: every 10 seconds normally, tightened to every 3 seconds once the match clock reaches the 80th
minute (LATE_GAME_ODDS_MINUTE/LATE_LIVE_ODDS_POLL_MS in app/sports/page.tsx,
app/picks/page.tsx) — a goal in that stretch can swing the market within seconds, and by then even
a 10-second-old number starts to feel slow. The odds-history chart's own LIVE tab still reads the
bucketed prices-history series at 1-minute fidelity (it's drawing a trend line, not a single
current number), so the two intentionally use different CLOB endpoints for different jobs.
A live game's odds belong exclusively to the dedicated poll above — nothing else can touch them.
Two separate protections make that true regardless of how a general refresh was triggered (the
initial load, returning to a stale tab, or tapping refresh): mergeGames (lib/gamesCache.ts) keeps
a live game's odds field (the Gamma snapshot) exactly as it was rather than the general sweep's,
and the CLOB seed effect above skips any already-live game outright rather than re-fetching its
price at the coarser 3h window at all. Everything else about the game — volume, liquidity, tokenIds
— still updates normally from a general refresh; only the two fields the dedicated poll already owns
are protected. Before this, a general refresh landing moments after the live-odds poll would
silently overwrite a current, correct price with an older, coarser snapshot from a completely
different fetch strategy.
Both the score and odds polls subscribe on a joined string key rather than a freshly-built array. That sounds like a detail, but depending on the array meant every incoming score rebuilt it, tore down both intervals and restarted them — so neither poll ever actually ran at its own stated cadence.
Polymarket's full event list (getUpcomingGames) isn't on an automatic timer at all — it fetches on
the initial page load, when you tap the refresh button, and when you return to a tab that's been in
the background — and every one of those is throttled to firing at most once every 10 minutes.
Non-live odds simply don't move fast enough to need checking more often than that, and it keeps
polling out of live games' way entirely (see above). Tapping refresh inside that 10-minute window
is a quiet no-op rather than an error; the one exception is the Try again button shown on a
failed fetch, which bypasses the floor so one network blip doesn't lock you out of retrying for the
rest of the window.
getUpcomingGames asks Polymarket for events with closed:"false", and Polymarket flips that flag
within hours of kickoff — well before the match itself is done. Left alone, that would drop a game
mid-match the instant Polymarket stopped offering it. mergeGames fixes this client-side rather
than guessing at Polymarket's exact timing: each sweep merges the fresh fetch with whatever was
already on screen, keeping a match that has kicked off but isn't over yet.
Two things are deliberately not preserved: a game that hasn't started and is simply missing from a fresh fetch (that's a real removal — delisted, filters changed), and one that's already over (the list hides those anyway, so carrying them further would be work nothing consumes).
The server keeps its own wider 24-hour grace (withinWindow) for a different reason: when a
kickoff time has to be recovered from a question's date text it has no time of day and lands at
00:00 UTC, so a tight server-side window would drop real fixtures. The client, which has the real
status, does the precise filtering.
A UCL fixture's card swaps the neutral surface every other card uses for the competition's own
colors: an electric-blue glow off the top-left corner, a deeper navy pooling in from the right, a
faintly blue border and a matching outer shadow (.ucl-card, app/globals.css, applied in
components/GameCard.tsx on game.league === "champions-league"). It's deliberately
background-only — no recolored text, odds bars, percentages, or badges — so a European night is
recognisable at a glance while scrolling without a single number on the card being any harder to
read than it is anywhere else.
The "AI last said" panel a card shows (see below) reflects whatever the match looked like before
kickoff — once the game actually starts, that read no longer accounts for the live score or a
possibly different squad, so the card stops showing it rather than presenting a stale pre-match take
as if it were still current. Re-running Analyze after kickoff produces a fresh read — one whose
research digest already reflects the live match status and score (see
How the AI analysis works) — which then shows normally on the card
until the next time this match starts (i.e., never again, for a finished one-off fixture). Nothing
is deleted from storage; the panel just isn't rendered once game.startTime has passed.
With Discover deactivated (see above), both pages are football-only for now — no All / Football filter, since there's nothing else to filter between:
components/PickDetailSheet.tsx).Any market pick saved from Discover before it was deactivated stays in lib/marketPicks.ts
untouched — it's just not read or shown by either page while Discover is off.
A saved pick's own card (PickCard) changes look across the same three phases a Sports game card
does — upcoming (the default treatment), live (a thicker, red-tinted border and a more
transparent/glassy interior, with the real score, match clock, and CLOB's live current price all
shown right in it, refreshed the same way Sports' own live polling works — app/picks/page.tsx
runs the identical score/odds polls, just scoped to whichever saved picks are actually in play),
and finished (muted, flattened — no longer actionable, but the final score is still worth a
glance). A pick isn't removed the instant its match ends, either — it stays for a full day
afterward, long enough that "what actually happened" (and whether the original read was right) is
still visible well after the match itself stopped mattering. pruneFinishedPicks (lib/picks.ts)
is called in place of a plain load by both Picks and Lab, so an entry actually past that window is
removed from storage (not just hidden) the moment either page next opens — the retention window
itself (isPastRetentionWindow, lib/matchClock.ts) is a plain kickoff-time heuristic (3+ hours
past kickoff to call a match over, plus a further 24h grace) rather than a real status check;
unlike settlement, getting this wrong costs nothing worse than a pick sticking around a little
longer than strictly necessary, so it isn't worth an extra network round-trip on every page load
for the rare edge case (a postponed match) it could get wrong.
Lab goes a step further for its own Build tab: a match that's merely started (kickoff has
passed at all, hasKickedOff) is filtered out of the buildable list the moment Lab loads, well
before a pick's own much longer retention window ever deletes it — Lab is a "build a new bet" tool,
so a game already underway isn't something you can act on anymore, even though Picks still shows it
as recent (and, for a while, live) analysis history in the meantime. Any leg already sitting in the
draft slip for a match that has since kicked off is removed the same way, so the slip never carries
something no longer placeable.
Lab is deliberately styled unlike the rest of the app — a very dark, restrained "private members'
club" look (.lab in app/globals.css, its own CSS custom properties alongside the neutral palette
Sports/Picks use and the green-phosphor terminal look Discover uses) instead of another variation
on the same look. One muted silver carries every accent, selection ring, and CTA over soft
translucent glass boxes; home/draw/away get gently desaturated, closely related tones just distinct
enough to tell apart at a glance, rather than a set of competing hues.
The one deliberate exception is the bet slip itself (below) — it keeps its own separate, livelier
violet-and-gold "modern sportsbook" identity (--slip-* custom properties, same file), so it reads
as a distinct object sitting on top of the page's calmer silver surface rather than blending into
it. Every other Lab surface — the header, the outcome tiles, placed-bet cards — uses the page's
--lab-* variables and follows the dark-silver look.
Every outcome — the main three-way, the two double-chance combos, any Discover
market's outcomes — renders through the same button: a label, a big decimal odds number
(toDecimalOdds, lib/format.ts, e.g. 2.38x) rather than the percentage-first framing used
everywhere else, with its plain percentage equivalent in small letters right next to it (toPercent
of that same probability) for anyone who thinks in implied probability rather than a multiplier —
and the AI's own probability underneath. Edge only ever shows as a small "value"
badge (a bolt icon + the edge) when the AI's read clears the market by 2+ points — deliberately the
only place edge appears, and only when it's worth noticing, rather than a number crowding every
single button whether or not it means anything.
Every odds figure and edge shown in Lab is live, not the snapshot from when you originally
analyzed the pick — the market moves after that, and showing a stale number would be misleading. A
shared client-side lookup (lib/livePrices.ts) re-fetches each outcome's current price by its
Polymarket CLOB token id (the same one behind the odds-history chart's /api/odds-history, so no
new API route was needed) and every card, slip leg, and placed bet reads from that one map. A pick
saved before this shipped, or a double-chance combo (no single token prices "1X"), simply has
nothing to reprice and holds at its last known value.
"Current price" reads the finest series available — the 3-hour window, bucketed at 5 minutes — not the 7-day one. This matters more than it sounds: the 7-day series is bucketed at three hours, so its last point (which is what "the price now" means here) could be that far behind. A match would finish and its odds would sit there still showing the pre-match 70/20 read for the rest of the afternoon. The tradeoff is deliberate: a token with no trades at all in the last few hours now returns nothing rather than an hours-old number, and the caller falls back to its own honest stand-in — a leg's entry price — rather than showing a stale number that looks live. Home's portfolio graph is the one place that still asks for the 7-day series, because it is drawing a 7-day graph — a finer window wouldn't reach back far enough. (Settlement itself doesn't use this mechanism at all — see below — so a stale odds number was never able to affect a real win/loss, only what Lab's live odds display showed.)
Build tab: search across every saved football pick, tap
an outcome to add it as a leg, or tap the pick's own title/teams to open its full analysis
report. Every football pick also offers double chance — 1X (home win or draw) and X2
(draw or away win) — as two extra buttons alongside the main three-way ones. Neither is a market
Polymarket sells separately (a partner-league event is a plain 1X2 with no fourth or fifth
outcome), so both are computed client-side as the sum of the two 1X2 outcomes they cover
(legFromPick in lib/betslip.ts), for both the market's own probability and the AI's
independent read — edge included, since a combo's edge is just the sum of the two edges it covers.
A row of five risk presets — Calm, Easy, Normal, Risky, Mega — each its own tinted "colored
glass" pill (green through cyan and silver to red, echoing the bet slip's own colored-background
treatment rather than a plain neutral box) that one-tap builds a slip for you (lib/riskModes.ts),
scanning every saved football pick for its single best qualifying leg
(never two correlated legs — say, a team's win and its own double-chance — from the same match)
and replacing whatever's currently in the slip with the result. Calm and Easy only ever bet the
match's own market favorite among home/draw/away (never a double-chance combo, which is
definitionally more likely than either outcome it covers and would always look like "the
favorite" without actually being the market's pick); Normal, Risky, and Mega open the search to
all five leg types (home, draw, away, 1X, X2).
Each mode is a genuine window on AI-vs-market edge, not just a floor — minEdge <= edge < maxEdge — so the five ranges partition the whole edge axis with no gaps or overlaps: Calm is
[10pp, ∞), Easy and Normal both [5pp, 10pp), Risky [3pp, 5pp), Mega [1pp, 3pp). Every
mode's ceiling is simply the floor of the tier above it, and Calm — the top of the ladder — is the
one mode with no ceiling at all. This is why Mega actually means "the AI barely thinks this is
worth it," rather than "at least a little edge": without a ceiling, a 25-point-edge sure thing
would satisfy Mega's floor just as easily as Calm's, and tapping Mega could hand you back the same
rock-solid favorite Calm would have — never the genuinely marginal signal the preset is named for.
Easy and Normal deliberately share their entire window; they're not ranked against each other by
edge at all, only by whether they're restricted to the favorite, so giving them different edge
ranges would invent a distinction that was never there. Whatever qualifies within a mode's window
is always capped at exactly 3 legs — the highest-edge qualifying games first — so a preset never
floods the slip with every match that happens to qualify; fewer than 3 qualifying games shows "Not
enough games for <preset> mode." instead of building anything.
Home (app/page.tsx — the app's default/root route) is a sleek, dark portfolio dashboard: a big
portfolio-value number, an all-time gain/loss line, a value-over-time graph, and your most recent
placed bets with their own live P&L.
Balance: starts at a seeded €1,000 (STARTING_BALANCE, lib/portfolio.ts) the first time
you ever open Home, and you can freely Add funds afterwards (quick chips or a custom amount)
— it's play money, so topping up is just for keeping the paper-trading loop going, never a real
transaction.
The graph only moves on price, never on a deposit. Every deposit you've ever made is simply
summed into one flat baseline (totalDeposited) as if it had all happened before the graph even
starts — a €500 top-up never appears as a fake spike. The only thing that moves the line above or
below that baseline is the mark-to-market value of your open bets: each leg is repriced by the
same live-price mechanism Lab uses, using the market's own price-history (/api/odds-history) to
reconstruct what your portfolio would have been worth at each point over the past week
(buildPortfolioSeries, lib/portfolioHistory.ts) — the same "buy a share at p0, it's worth p1
now" math as a real prediction-market position, generalized across every leg in a parlay and
summed across every bet you've placed. A leg that can't be repriced (no token, or the pick
predates this feature) just holds at its stake, never fabricating a number.
The graph is drawn as water. It carries no y-axis labels at all — the big number above it
already says what the portfolio is worth, and the hover readout gives the exact value at any
point, so axis ticks were only ever stealing width from the plot. What's left is a body of water
(components/PortfolioChart.tsx), built from three pieces:
The surface uses trochoidal (Gerstner) waves, not a stack of plain sines. On real water every particle travels in a circle rather than straight up and down, so as it rises it also drifts sideways — bunching together at the crests and spreading apart in the troughs. That asymmetry (peaked crests, broad flat troughs) is the entire visual signature of water; symmetric sines just read as a wobbling line, which is exactly what the first attempt at this looked like. Four layers at frequencies that don't divide into each other, travelling in opposite directions, so the pattern never repeats — with two slow out-of-step swells riding on top so the surface builds and calms on its own rhythm instead of churning at one fixed intensity.
Caustics are what actually make the fill read as water rather than a tinted triangle: soft patches of light dappling below the surface, each drifting at its own rate and breathing on its own cycle, clipped to whatever shape the water currently is. Each patch measures its depth from the surface directly above it — the water is only as deep as the line is high at that x — and shrinks and fades in the shallows. Pinning them to fixed heights instead puts every one of them outside the clip, and invisible, wherever the line runs low.
A placed bet used to just sit as mark-to-market forever — the app had no way to know how a match
actually finished, so even a bet on a match that ended days ago kept "repricing" instead of ever
becoming a real win or loss. lib/settlement.ts fixes this for football legs, and does it from
exactly one source of truth: the real, final score of the match, from ESPN
(lib/liveScores.ts, above). There used to be a second
path that read a bet's own match-price on Polymarket instead, on the theory that it would settle
faster — it added a whole second way for a leg to resolve, with its own gate and its own edge
cases, and was removed because relying on price movement to mean the same thing as a result is
exactly the kind of "clever" that made settlement hard to reason about. The rule now is the plain
one: if the game is finished, settle the bet — or the part of it that's decided.
resolvePendingSettlements runs once when Home or Lab first loads, and then again every 60
seconds for as long as the tab stays open — it used to run only on that first load, so a match
finishing while the page sat open in the background left its bet stuck showing "Pending" until the
next full reload. The recurring check reads whatever bets exist at the moment it fires, via a
ref mirroring state rather than a direct effect dependency, for the same reason the Sports page's
live-score/live-odds polling does: depending on the bets array directly would tear the interval
down and rebuild it every time a bet settled, so it would never actually run on schedule.
Each check looks only at legs still genuinely open. getMatchResultsSince (lib/liveScores.ts)
asks /api/bets/settlement-scores for the real final score of every match a still-pending leg
belongs to, looking back as far as that league's oldest unsettled bet needs rather than just "right
now" — matched against both of ESPN's names for a club, full and short, since checking only the
full name meant a bet on Wolves, Spurs, Man City or Inter never found its own match and stayed
Pending forever, however long ago it finished, while bets on other clubs settled normally.
A leg's result is cached the moment it's known, and never asked about again. Once
computeLegResults has resolved a leg to Won or Lost, that's final — a finished match's score
doesn't change — so every later check carries it forward from PlacedBet.legResults instead of
looking it up again. This is also why the recurring check stays cheap however long a parlay takes
to fully resolve: a 3-leg parlay waiting on its last match only ever asks about that one match, not
all three, and settlementRefs doesn't even include a league in the request once every leg it
covers is already decided.
Every leg needs its own league/homeTeam/awayTeam to be looked up at all — fields added to SlipLeg
after this app had already been in real use for a while. Without a backfill, a bet placed before
that would carry none of them and settlementRefs would silently skip it forever: not "can't
settle it yet", but never even trying — the actual bug behind "the game finished a day ago and it's
still Pending". backfillLeg (lib/settlement.ts) recovers what it can from fields that have
never been optional — title ("Home v Away") and meta ("🏴 League Name") for the team names and
league, placedAt as a safe (if conservative) stand-in kickoff lower bound — so every bet ever
placed gets the same chance to settle as one placed today, with no need to touch data already
sitting in a user's own browser storage.
stake / combined market probability (the same decimal-odds math shown everywhere else).Once settled, a bet's value is final and frozen from that moment on — computeBetValue
(lib/portfolioHistory.ts) shows the real payout instead of a live mark-to-market estimate for any
timestamp at or after settlement, while a portfolio-history point from before that moment still
shows exactly what the mark-to-market path looked like at the time. PortfolioBetRow and
PlacedBetCard show a Won/Lost badge in place of the live odds/edge readout once a bet
resolves either way.
The rules above decide the bet AS A WHOLE, but a 3-leg parlay where only one match has finished
doesn't have to wait for the other two before showing what's already known. computeLegResults
(lib/settlement.ts) runs the exact same per-leg logic and is persisted separately
(PlacedBet.legResults, alongside but independent of settlement) on every check, even for a bet
that doesn't fully resolve this round — so PlacedBetCard colors each leg's own outcome label
green or red the moment ITS match concludes, regardless of what the other legs (or the bet's own
overall badge) are still waiting on. A parlay that ends up Lost because one leg busted still
shows its other, individually-winning legs in green — the per-leg color and the bet-level badge
are reporting two different, both-true things, not disagreeing.
A settled bet's unfinished legs keep resolving. This is the part that's easy to get wrong, and
did used to be: a parlay settles Lost the instant one leg busts, and its other legs can easily
still be hours from kicking off at that point. Freezing everything the moment settlement was set
— which settlementRefs, resolvePendingSettlements and applyLegResults each did independently
— meant a leg that went on to win stayed "pending" for good. It never turned green on the bet or
in Home's recent-bets list, and, less visibly but worse, the
Edge Score never counted it at all, since
that only ever reads legs that actually resolved: a busted parlay silently erased every correct
call sitting alongside the bad one. Now only the bet's own settlement is frozen — status,
payout and settledAt are decided once and never revisited, so a lost parlay stays lost however
well its remaining legs do — while legResults keeps filling in underneath it. Legs whose kickoff
is older than the server's own lookback (MAX_SETTLEMENT_LOOKBACK_MS) drop out of the poll rather
than being chased forever, since no result can come back for them anyway.
Whenever a bet settles Won, lib/celebration.ts fires a one-time congratulations overlay. It's
kept completely separate from the app's real analysis pipeline — no user-selected model, no betting
context at all — down to a lightweight OpenRouter call to Gemini (lib/clubVibe.ts), shaped
differently depending on whether it's a single bet or a parlay:
getClubVibe) asks for exactly 5 emojis
and 2 hex colors matching that club's vibe (a plain double-chance win collapses to the one real
side it locks in: 1X → the home club, X2 → the away club; a bare draw win has no club at all, so
it's skipped). All 5 emojis fall in the rain animation, and both colors become the background.getClubVibes) asks for one emoji + one color per team, matched
back to the caller's own team list purely by position (a draw leg is skipped, same as above). Every
team's emoji falls in the rain, and the background gradient is 2 colors picked at random from
the full set the teams returned (pickTwoRandom) — a different pair, in a different order, each
time.WinCelebration.tsx renders whichever shape it's given — a single "Congratulations! X came through"
line for one team, or a per-team list (each team's name in its own color) captioned "Every leg came
through" for a parlay — as a full-screen falling-emoji animation over a panel styled to match the Lab
bet slip's own violet "modern sportsbook" look (the same --slip-* tokens BetSlipBar.tsx uses,
re-established via a wrapping .lab class so they resolve correctly even when triggered from Home,
which isn't normally inside that scope). Fires at most once per bet — resolvePendingSettlements
only ever reports a bet in its newlyWon list the one time it transitions from unsettled to Won,
never again on a later reload — and a failed/empty vibe fetch just means no celebration shows, never
an error over what should be a purely happy moment.
lib/edgeScore.ts answers one question — across every football leg that's actually resolved, are
we ahead of what the market's own odds would imply, or behind? For each resolved leg, the running
score is multiplied by:
1 / p — the market's own decimal payout for that pick. A longshot that hits rewards
the score far more than a favorite that hits, exactly as a real payout would.(1 - p) — not p, and not the payout of the side that actually happened. This is
"how surprising was this miss": losing a bet the market gave 90% to (a real upset) shrinks the
score to a tenth; losing a longshot the market only gave 20% to (the likely result most of the
time) barely dents it. Every win multiplies by something ≥ 1, every loss by something < 1, so
the running product only grows on genuinely good calls, never on a lucky miss.A leg counts the moment it resolves, independent of whether the parlay it's part of has (see above) — a calm favorite that won still counts toward "calm" even bundled into a bet that lost overall, and a mega longshot that lost still counts toward "mega" even inside a bet whose other legs won. Only football legs that actually settle are counted; a Discover/market leg never resolves in this app at all, so it's excluded rather than treated as forever pending.
EdgeScorePanel (Home) shows the overall score as a signed percentage against break-even ("+34%
vs. fair odds"), the raw multiplier underneath ("1.34× running"), and a breakdown by risk tier —
see below — so it's possible to see whether one tier in particular is carrying (or dragging) the
results, not just how the bets as placed happened to turn out.
lib/riskModes.ts is the single classification behind every "Calm/Easy/Normal/Risky/Mega" label in
the app — Lab's one-tap slip presets (above), any game card's tiny
badge, its border color, and the Home Edge Score breakdown all read the same function
(riskModeFor), so a "Calm" pick means the exact same thing everywhere it appears. There used to be
two separate scales sharing these five names — this one (edge ranges plus a favorite-only axis, for
Lab's search over a pool of picks) and a second, simpler one invented for the single-recommendation
case because it didn't have "is this the favorite" readily at hand. It does — isMarketFavorite
answers that from the same market probabilities every consumer already has — so the second scale
was retired rather than kept in parallel.
riskModeFor(edge, isFavorite) walks five tiers, boldest first, matching on edge (AI probability
minus market probability, for whichever outcome was recommended) and — for the two boldest tiers —
whether that outcome is also the match's own market favorite:
Calm and Easy share this favorite restriction because "safer" tiers are meant to mean the AI backed what the market itself already thinks is likeliest, just with real conviction behind it — a huge edge on a rank underdog is still a real signal, but it's a different, bolder kind of claim than agreeing with the market's own favorite, so it can never read as Calm or Easy no matter how large the edge gets; it lands in Normal instead, the best a non-favorite claim can be rated. A double-chance leg (1X/X2) is never counted as "the favorite" either, by the same logic Lab's own presets already used — a combo is definitionally more likely than either single side it covers, so it would trivially satisfy "favorite" without actually being what the market picked.
Any game card that's been analyzed at least once (saved or not — the same lastAnalysis cache the
last-analysis panel reads) shows its tier as a tiny colored label in the card header, using the edge
and market prices from comparison.edges/market at the moment it was analyzed, not anything
recomputed against a live-updating price — a card with no clear edge (bestValue "none") shows no
tier at all. The Edge Score breakdown reads the same way, from each resolved leg's own
aiProb - marketProb and whether it backed that game's favorite (SlipLeg.isFavorite, set once
when the leg is added to a slip) — a leg placed before that field existed reads as "not the
favorite" by default, the conservative choice: it can only ever land in a wider tier it genuinely
still qualifies for, never a narrower favorite-only one it might not have actually earned.
COMPARE_SYSTEM_PROMPT (lib/openrouter.ts) tells the model to flag a bestValue at all only once
its own edge clears roughly 5pp, so in practice most real recommendations land in Calm/Easy/Normal —
Risky and Mega mark the genuine edge cases where the model went with a thinner margin anyway.
A rated card (one with a riskLevel, above) gets a thin, tier-colored border and a soft matching
glow (.risk-border-<tier>, app/globals.css) — nothing else. No background layer, no extra DOM
element, no animation of any kind: components/GameCard.tsx just adds one class name alongside
whichever base surface the card already has (ucl-card or surface-lift).
This is the fifth build of this system, and by far the simplest. The previous four (a
gradient-blob-and-ribbon wash, a scrolling waveform, a set of light-scattering orbs/rings/glints, a
faint constellation overlay) were all some kind of animated background layer, which meant every one
of them had to solve the same recurring problem: a Champions League fixture's own blue wash
(.ucl-card, above) competing with the risk tier's own background for the same visual space. A
border sidesteps that entirely — it's a different CSS property from background, so it was never
going to conflict in the first place, no faintness tuning or composition logic required.
It's also about as cheap as a per-card visual can be: one color, painted once at mount, never
repainted or recomposited again — no @keyframes, nothing to trace, nothing for
prefers-reduced-motion to even need to disable. Every tier uses the identical border weight and
glow strength; only the color (--tier-rgb, shared with lib/riskModes.ts's own palette) changes,
since the badge label already states the recommendation's strength in words — the border doesn't
need to re-encode it in decoration.
The Edge Score panel (above) only ever counts legs from bets you actually placed. Overview
(/overview, app/overview/page.tsx) answers a different question: how would every match the AI
has ever analyzed — saved or not, staked or not — have done if you'd bet each one individually,
one flat stake per match, the moment the AI called it? lib/overview.ts reads the same
lastAnalysis cache the last-analysis panel and GameCard's badge already use (every entry that's
ever been through saveLastAnalysis, auto-written on every analysis regardless of whether it was
saved as a Pick), so nothing extra needs to be tracked for this page to work — it's a different lens
on data the app was already keeping.
Two independent strategies per game, never sharing a leg:
comparison.bestValue), the exact
same read the GameCard badge and Edge Score breakdown already use.bestValue (a "draw" recommendation has no natural
double-chance analog — both combos equally contain it). Instead it independently searches the two
double-chance combos for whichever has the better edge, the same edge-maximization
lib/riskModes.ts's own bestCandidateForPick already uses for Lab's "any outcome" presets, just
narrowed to the combo pool alone.Both strategies are classified through the exact same riskModeFor used everywhere else in the app
(above), which means a combo — never counted as backing
the match's own favorite — can never land in Calm or Easy. That's not a gap in this page's data; the
grid shows it as an explicit "N/A — a combo can never be the favorite" rather than a misleading "no
data yet", since more analyses would never fill those two cells in no matter how many piled up.
A game only enters the grid at all once the AI found something worth recommending
(bestValue !== "none") — "unclassed" is the one real exclusion; every classed game lands in
exactly one of the five tiers per strategy, riskModeFor's own Mega tier being the catch-all
bottom, same as everywhere else.
Settlement reuses the exact same real-match-result pipeline placed bets already use
(lib/settlement.ts's legResult//api/bets/settlement-scores, batched by league and each
league's earliest still-unresolved kickoff) — nothing here is ever inferred from a live-trading
market price, only a confirmed final score. A match still in progress, or one football-data.org's
own lookback window can't reach any more, shows as pending rather than a guess, and doesn't
affect that cell's score at all. Because this reads real match identity (league/homeTeam/awayTeam/
startTime), lastAnalysis had to start carrying that alongside the analysis itself
(lib/lastAnalysis.ts) — an entry saved before this existed is simply skipped, the same
"undefined means predates this field" contract every other evolving-shape cache in this app already
uses, never guessed at.
Tapping Log in (top-right of Home, components/AccountButton.tsx) opens a create-account/log-in
sheet (components/AuthSheet.tsx) with exactly two fields — username and password. There's no email
anywhere in this flow, by design: no address to collect, verify, or leak, and no "forgot password"
recovery flow that would need one (a lost password today just means a new account).
This is fully custom auth, not Firebase Authentication's own email-based system wearing a disguise —
lib/auth/ hashes passwords with bcrypt (lib/auth/passwords.ts, 12 salt rounds) and stores one
document per user directly in Firestore (lib/auth/users.ts), keyed by a lowercased, trimmed
username so "Alice" and "alice" are the same account rather than two confusingly separate ones.
The session itself is a plain HMAC-SHA256-signed cookie (lib/auth/session.ts) — one claim (which
username), one property (unforgeable without the server's SESSION_SECRET) — rather than pulling in
a JWT library for a feature this small. POST /api/auth/signup fails with 409 if the username's
taken (an atomic Firestore .create(), not a check-then-write that could race); POST /api/auth/login returns the identical error for a wrong password and a nonexistent username on
purpose, since telling them apart is exactly what lets an attacker enumerate real accounts.
Bringing your existing data in. A browser that already has local activity (analyzed games,
picks, placed bets, portfolio deposits, the open slip, ...) gets an extra checkbox on signup:
"Import what's already on this device." Checking it (the default, only shown when there's actually
something to bring over — lib/auth/localSnapshot.ts's hasLocalDataToMigrate) collects every known
localStorage key — never Polymarket's own games/markets lists or the club-logo cache, which are pure
refetchable caches and never really "yours" — and sends it along in the same signup request.
lib/auth/migrate.ts writes most data types into their own single Firestore document
(users/{id}/data/{key}), but the types that can genuinely grow large — lastAnalysis/
lastMarketAnalysis (map-shaped, up to 150 full entries) and picks/marketPicks/placedBets
(arrays of already-ided objects) — are split one Firestore document per record
(users/{id}/{key}/{recordId}, chunked into batches of 400 writes) instead, since a single Firestore
document has a 1MB cap that a real history of any of these could otherwise exceed (a real signup
whose import hit that cap used to report "could not create your account" even though the account had
already been created — the account-creation step and the best-effort import are now independently
wrapped so an import failure can never look like a failed signup). GET /api/account/last-analysis
reads that per-record lastAnalysis collection back for the logged-in user; the Overview page
merges it with whatever's already local (local wins on a collision) so a freshly-imported account's
history shows up there immediately, without waiting on a full sync system. This account system
currently covers signup, login, and that one-time import — every other localStorage-backed
feature in this app (Picks, Lab, Home's portfolio) still reads/writes local storage exactly as it
always has once you're logged in; keeping those continuously synced to the logged-in account across
devices is a deliberately separate, larger piece of work layered on top of this foundation, not
bundled into it.
Lab has a Paper / Real toggle at the top. Paper is everything described above — a simulated
stake against the live market, settled later against the real match result, no money anywhere.
Real places a genuine order on Polymarket itself, with real USDC, through
@polymarket/clob-client (Polymarket's own official
SDK) talking directly to clob.polymarket.com from the browser.
Fully non-custodial — this app's server never sees the private key, in any form, ever.
Everything in lib/realMoney/ and components/RealWalletSheet.tsx runs client-side only. Tapping
Real for the first time opens a sheet asking for the Polymarket trading wallet's private key, a
passphrase to protect it with, and which of Polymarket's three signing setups the account actually
uses (a directly-connected browser wallet, an email/Magic login, or an explicit Gnosis Safe — the
same three the CLOB SDK itself distinguishes as SignatureType.EOA / POLY_PROXY /
POLY_GNOSIS_SAFE, since an email/Magic or Safe account trades through a separate proxy address
its own EOA merely controls, not through the EOA's address directly). The key is encrypted right
there in the browser (lib/realMoney/crypto.ts: AES-256-GCM, a PBKDF2-derived key at 210,000
rounds — OWASP's 2023 floor for PBKDF2-HMAC-SHA256 — so a weak passphrase still costs real work to
brute-force) and the ciphertext is the only thing that ever reaches localStorage
(betintelligence.realWallet.v1). The decrypted key itself only ever exists in the Lab page's own
React state, for the rest of that browser session — gone on a reload, a tap of Lock, or
Disconnect (which also wipes the stored ciphertext). There is deliberately no password-recovery
flow for a lost passphrase; the encrypted key would need to be re-imported from scratch, same as
losing access to any other non-custodial wallet.
Polymarket's own CLOB API credentials (a key/secret/passphrase triple, separate from the wallet's
own passphrase above) are derived fresh from one signature every time Real mode unlocks
(createOrDeriveApiKey) rather than stored anywhere — they're deterministic per wallet, so
re-deriving costs one extra request and never needs a second secret sitting next to the private
key. A Test connection button in the wallet sheet calls Polymarket's balance endpoint
read-only — confirms the key/signature-type/funder combination is actually accepted and shows the
real USDC balance behind it, without moving anything, before ever risking it on a real order.
v1 is intentionally narrow: one market order at a time. A real order is always a single-leg,
immediate FOK (Fill-Or-Kill) buy at the current market price — it either fills completely right
now or nothing happens at all, never a resting limit order left open on the book. Two things paper
mode allows that real mode doesn't, both enforced in components/BetSlipBar.tsx with an explained
reason rather than a silently disabled button: a multi-leg parlay (Polymarket has no atomic
multi-leg order type — each leg is its own independent market, so "one order" can only ever mean
one leg), and a 1X/X2 double-chance combo (no single CLOB token prices a double chance; only a
genuine home/draw/away pick carries a tokenId to actually buy). Placing a real order needs
a second, explicit confirmation step showing the exact stake and price before it submits — a single
tap is never enough to move real money. A successful order gets recorded locally exactly like a
paper bet (PlacedBet.real, shown with a REAL badge and the same shining border effect the
Paper/Real toggle itself gets in Real mode) so it shows up under My Bets and gets graded by the same
real-match-result settlement pipeline as everything else — but this app never handles the actual
payout. A win is claimed on Polymarket itself; "View on Polymarket" on a real bet's card links
straight to the account's own portfolio there.
Ongoing continuous sync of a real position across devices, resting limit orders, and multi-leg real parlays are all deliberately out of scope for this first pass, same spirit as the Accounts section above scoping down to signup/login/one-time-import first.
Every card — a Discover market or a Sports match — has a collapsed Odds history dropdown that expands into a small line chart of how its outcomes' prices moved over the past week, one line per outcome (home/draw/away for football; the market's top few outcomes, capped at 4, for Discover), in the same colors used everywhere else on that card so the chart reads as an extension of it rather than a new visual language. Dragging or tapping along the chart shows a crosshair with the exact value of every line at that point in time. The dropdown only appears when Polymarket actually gave us a price-history token for at least one outcome — some thin or brand-new markets don't have one yet, and there's nothing to chart in that case.
A row of small 7D / 1D / 3H buttons under the chart switches how much history is shown — and
each one fetches its own CLOB interval/fidelity rather than just re-slicing the same 7-day series,
so 3H is genuinely higher resolution than 1D, which is higher resolution than 7D, not the same
coarse buckets zoomed in (WINDOW_CONFIG in lib/oddsHistoryServer.ts: 1w/3-hour buckets,
1d/30-minute buckets, and 6h/5-minute buckets trimmed down to the labeled 3h client-side, since
CLOB has no exact "3h" interval of its own). Only the 1w combination has actually been confirmed
against the real API (from before these buttons existed); 1d and 6h are CLOB's documented
shorter intervals but haven't been confirmed the same way — if either turns out wrong, a button
just falls back to the same "not enough trading history" message a thin/brand-new market already
gets, never a crash. Each window's data is fetched once per card and cached for the rest of that
card's session, so switching between already-visited windows is instant.
A football match that has actually kicked off gets a 4th button, LIVE: 1-minute-fidelity data
(the same 6h CLOB interval as 3H, over-fetched and trimmed the same way, just at its finest
fidelity) trimmed to exactly this match's own elapsed time since kickoff rather than a fixed
span — 12 minutes in, it's a 12-minute window; two hours in, a two-hour one. There's no server-side
concept of "since kickoff" here at all: the request is identical in shape to 3H's, and the chart
(components/OddsHistoryChart.tsx) does the actual trimming client-side using that specific game's
own startTime, the same way it already trims 3H's over-fetched 6h down to 3 hours. The button
disappears again for a market with no kickoff to speak of (Discover) or a match that hasn't started
yet — kickoffTime is an optional prop precisely so those callers can omit it.
This is backed by Polymarket's own CLOB order-book API
(clob.polymarket.com/prices-history), proxied through /api/odds-history
(lib/oddsHistoryServer.ts) so the real endpoint and any caching stay server-side — the browser
never talks to Polymarket directly for this. The token id each outcome needs for that lookup
(clobTokenIds, distinct from the market's own id) is threaded through from Gamma's event data
during parsing (lib/polymarket.ts, lib/allMarkets.ts) alongside the price itself; an outcome
Gamma didn't give a token for simply doesn't get a line. In local mock mode (MOCK_GAMES=1 /
MOCK_MARKETS=1) the route synthesizes a deterministic pseudo-random trend that lands exactly on
the mock price you're already seeing (lib/mockOddsHistory.ts), so the chart is testable without
hitting Polymarket at all.
Tapping AI Analyze runs against deepseek/deepseek-v4-flash-0731
(or whichever model is selected) via OpenRouter, but the first step differs between football (Sports) and Discover markets.
Football (lib/footballData.ts, lib/openrouter.ts):
dateFrom/dateTo window rather than
leaving it to the API's own narrow implicit default, then sorts and takes the most recent 5 itself rather than trusting
the API's own ordering. Without that explicit window, a club whose actual last match fell outside it (a common gap
around a Champions League/Europa League matchday, an international break, or a lighter domestic schedule) would come
back with no form at all — the bug this fixed. The league-STANDINGS side of the digest had two related bugs of its
own, both specific to Champions League: first, a competition split into groups repeats type: "TOTAL" once PER GROUP
rather than once overall, and taking only the first such block (the old code) silently lost any team not in that one
group — fixed by flattening every TOTAL block before searching for a team's row, which is a no-op for a single-table
domestic league. Second, a standings row with zero games played (the normal state for a Champions League fixture before
its league-phase has started for that team this season) used to render as a real-looking #1 · 0 pts · 0pl card — since
every team in that state ties at the identical values, it's genuine data but carries zero signal, so it's now treated
the same as no row at all (TeamStandingsSummary.tsx shows "Hasn't played in this competition yet" instead) — while the
team's actual recent FORM, a separate fetch unrelated to this specific competition's own table, stays visible regardless.
There's no fallback to web search if a team or fixture can't be matched there — the analysis fails with a clear error
rather than quietly falling back to a shakier source. A league with no football-data.org code at all
(Belgian Pro League, Europa League, the Nations League) is different: rather than failing the analysis outright, it
runs with a plainly-labeled "structured match data isn't available for this league" digest instead
(NO_DATA_DIGEST_TEXT in lib/footballData.ts) — the independent read and the market comparison still both run, just
reasoning from general knowledge and the market odds alone rather than real standings/form/head-to-head. (This app first
tried instead — its free plan looked equivalent on paper, but locks every
endpoint to old completed seasons in practice, making it useless for a current match. football-data.org's free tier has
no such season lock, covering 7 of the 8 domestic leagues below plus the Champions League, the World Cup, and the
Euro — everything except Belgian Pro League (no free-tier code), Europa League, and the Nations League (neither on
the free plan at all). supplies the injuries/availability section
football-data.org can't (it has no injuries endpoint at any tier): Premier League, La Liga, Bundesliga, Serie A,
Ligue 1, and the Champions League — not Primeira Liga, Eredivisie, Belgian Pro League, Europa League, or any of the
three national-team competitions.
This one is an enrichment layer rather than a core source: an unset key, an uncovered league, or a failed request all
just make the digest say injury data isn't available for that match, rather than failing the whole analysis the way a
football-data.org miss does — that data was never guaranteed before, so its absence is a known, honest gap, not a
regression.The research step also folds in a Starting Lineups section (lib/lineups.ts) once each side's confirmed XI has
been posted — from the same ESPN site API that already powers live scores, no separate key. ESPN announces a
match's lineup itself, typically somewhere from about an hour before kickoff for the top-5 leagues to sometimes much
closer to it for smaller ones, never on any guaranteed schedule — so "not announced yet" is the normal state right up
until it isn't, both in the text the model reads and in the infogram described below.
Alongside the AI's own read, the same one-time digest fetch (/api/analyze/football-digest) also returns two small,
factual infograms — real data, not an AI opinion, so they render even before the independent read finishes:
components/TeamStandingsSummary.tsx) — each team's current table position, points, games played,
goals for/against, and a compact 5-match form strip (oldest → most recent), from football-data.org's standings endpoint
combined with the same recent-results fetch the text digest already uses for form (TeamStanding in lib/types.ts,
built in fetchFootballDigest). An enrichment, not a core fact: a standings-endpoint failure or a team missing from the
table just means that side shows "not available" rather than failing the whole analysis.components/TeamInjuriesSummary.tsx) — each team's currently-unavailable players from Big Balls
Sports Data, with whatever reason the source actually gave (it mostly only flags a bare "unavailable" with no reason at
all, so the fallback says exactly that rather than implying more was checked — see lib/bigBallsData.ts). Built by
fetchInjurySummary, a structured counterpart to the text digest's own injuries section that reads the same cached
fetchers rather than costing a second real request. Renders nothing at all (not two empty "None reported" lists) when
the league isn't covered, there's no key, or team-name resolution fails — an empty state there would misleadingly imply
data was checked and found clean.components/TeamLineupsSummary.tsx) — each side's confirmed lineup (with formation, when ESPN gave
one) from fetchMatchLineups (lib/lineups.ts), the same structured-counterpart pattern as the two enrichments
above. Renders nothing at all when neither side has a lineup posted yet, rather than two "not announced" boxes —
that's the ordinary pre-match state, not something worth flagging on every single card.All three are attached to a SavedPick (homeStanding/awayStanding/homeInjuries/awayInjuries/homeLineup/awayLineup)
so a saved pick's read-only detail view (PickDetailSheet.tsx) shows the exact same infograms later, not just the live
analysis sheet. Batch analysis doesn't fetch any of them (its condensed cards already omit the pros/cons/summary
breakdown too), and a pick saved before a given field existed just doesn't have it at all — every component renders
nothing rather than guessing.
The Sports page polls separately for whichever games are worth asking about right now: anything less than an hour
from kickoff, plus anything already live (kicked off but not yet over — a lineup is just as relevant mid-match as it
is beforehand, and checking early doesn't help if you only open the app once a match is already underway). That set
gets asked about every 5 minutes (/api/games/lineups, backed by getLineupAvailability in lib/lineups.ts — one
ESPN scoreboard request per league covered among those games, not one per game), immediately on page load, and again
right away whenever the tab regains focus (the same reasoning refresh already follows for odds — a backgrounded
tab's timers can be throttled or paused outright by the browser, so returning to it shouldn't mean waiting out
whatever's left of that 5-minute window). The moment a lineup shows up for a match, its card gets a small 11s Are
Here! badge next to the league name (components/GameCard.tsx) — purely a heads-up that the next analysis will
have real squad data to work with, not a lineup preview itself (that's what tapping Analyze, or the Starting XI
infogram above, is for). A match drops out of the poll once it has a lineup or is over, whichever comes first.
Discover markets (lib/openrouterMarkets.ts) — any non-football question, where no equivalent structured API exists —
keep the original three-step shape:
:online plugin) that does nothing but research the question and
organize what it finds into a plain-text digest: recent news, relevant data, expert analysis, and historical base rates.
This step's system prompt explicitly forbids it from ever mentioning betting odds, bookmaker lines, or prediction-market
prices anywhere in that digest — if a source it reads mentions a price, it's instructed to silently omit that part and
keep only the underlying facts.Every one of these calls — system prompt and user prompt alike, every step, football and Discover markets both — also
carries the real current date and time (nowLine/withNow in lib/openrouter.ts, computed fresh on every call rather than
baked into a prompt string built once at server start). A model has no reliable sense of "today" from its training data
alone, and Discover's research step is told to weigh the most recent news far more heavily than older context, trusting
whichever source is more recent when two disagree — the whole point of giving it live web access is to catch what's
actually current, not to average it in with stale context. Every system prompt also ends with a short "Soul" line setting
the model's persona for the read it's about to give: "You are wise, you are advanced, you are super intelligent and smart,
you are logical and certain, you are bold, you go for it, you trust your decision."
All results are shown in the UI as a guided reveal, and the whole thing can be saved to My Picks.
Saving is keyed by the match/market's own id (lib/picks.ts, lib/marketPicks.ts), so re-analyzing
and re-saving the same game or market replaces its previous save rather than adding a duplicate —
My Picks always holds your latest read on a given match or market, never several stale ones side by side.
Why Discover still splits research and judgment into two calls: OpenRouter's :online step isn't an isolated browsing
session — it's a single search pass that runs before the model answers, and whatever it finds is merged straight into the
same context the model then writes its estimate from. A single model doing both "search the web" and "form an opinion" in
one call could plausibly see the real odds mid-search (a betting aggregator, a news piece citing the market price, or the
very Polymarket market being asked about) and have them sitting right there in context while it writes a number it's
supposed to have reached independently. Splitting research and judgment into two separate calls means the step that forms
the actual estimate has no web access at all — it can only ever see what the research step chose to hand it, and that
step's entire job is a summary with the odds already stripped out. It's still not a mathematical guarantee (the digest step
could in principle slip up), so both steps carry an explicit "never mention/disregard odds" instruction as defense in
depth, but it's a meaningfully stronger boundary than trusting one model to browse and then talk itself out of what it just
saw. Football sidesteps this problem a different way: there's no web search in its pipeline to anchor on in the first
place.
Next to each card's Analyze button is a small stepper (1×-5×) for how many times to
run the independent-research step before comparing to the market (lib/researchRuns.ts, one
global preference like the model choice). At 1× nothing changes. Above that, the app fires
that many independent, from-scratch research passes all at once (they don't depend on each other,
so there's no reason to wait for one before starting the next) and waits for every one to land,
then averages them (lib/aggregate.ts) into the single read that gets compared against the market
— the result shows each run's own numbers plus how much they agreed with each other (a plain-
language agreement label plus the per-run breakdown), so you can tell a stable read from one that's
basically a coin flip. That merged, multi-run read is what gets saved with the pick.
For football specifically, since research is no longer an LLM call, running it more than once means
several independent-read passes over the same football-data.org digest rather than several fresh
searches — still meaningfully different runs, since the predict step's own sampling varies each
time, but the diversity comes entirely from that step now rather than from re-researching too.
Those N runs are N separate HTTP requests to /api/analyze/predict, and Vercel can (and does) route
concurrent requests to separate serverless instances that don't share any in-process state — a
server-side cache or in-flight-request coalescing inside lib/footballData.ts only helps when calls
land on the same warm instance, so relying on that alone let N parallel runs multiply real
football-data.org calls by N under real concurrent load, exhausting the free tier's
10-requests/minute budget almost immediately. The actual fix is architectural, not caching:
components/AnalysisSheet.tsx fetches the digest exactly once via a dedicated
/api/analyze/football-digest call, then fires all N predict calls with that same digest text
already attached — so N runs cost exactly one round of football-data.org calls by construction,
regardless of how Vercel happens to distribute those N requests across instances. (Batch analysis
and a single run don't have this redundancy risk in the first place — they still fetch the digest
inline via the same getIndependentPrediction convenience path as before.) On top of that, every
call to football-data.org still goes through an in-process sliding-window throttle (10 requests per
rolling 60 seconds) and reads football-data.org's own "Wait N seconds" message for a single informed
retry if a 429 does slip through — both useful defense-in-depth for other traffic (a single-run
analysis, batch analysis of several different matches, the live-scores/live-odds polling below), but
neither was ever going to fix the N-runs-per-match case on its own.
The "researching" screen itself is a small centered popup — not the full-width sheet used once
results are in, and with no way to dismiss it mid-analysis — with a pulsing radar animation rather
than a checklist (components/ResearchOverlay.tsx), and a run-progress indicator ("2 of 3 runs
done") when more than one pass is in flight.
There is no Save button anywhere, and nothing to forget to tap: a finished analysis goes straight
into Picks on its own, single (components/AnalysisSheet.tsx) and batch
(components/BatchAnalysisSheet.tsx) alike, and the sheet just confirms it did. An analysis costs
real money to run (see Cost tracking), so "paid for it and then lost it by closing
the sheet" shouldn't have been a state the app could get into at all. savePick (lib/picks.ts) is
keyed by the match's own id and replaces any earlier entry for it, so re-analyzing a match updates
that one pick in place rather than stacking duplicates of the same fixture.
For the single-analysis sheet this deliberately runs in its own effect keyed on the "compared" stage, rather than inline where the comparison lands: the standings/injuries/lineups it saves alongside the read live in state, and the closure that fetched them still holds the nulls they started as. By the time the stage flips, they've all rendered.
Analyzing a match or market also caches the result against that match/market's id
(lib/lastAnalysis.ts for Sports, lib/lastMarketAnalysis.ts for Discover) — a lighter-weight
record than a saved pick, used only to redraw the card. The card shows a one-line summary ("AI: Arsenal
52% · +4pp edge · 2h ago") with a dropdown that expands into the full read: AI vs.
market for every outcome, confidence, the verdict, and the multi-run agreement breakdown if it was
researched more than once. Re-analyzing overwrites the cached entry; each cache is capped at the
150 most recently analyzed matches/markets to keep it from growing unbounded. For Sports
specifically, this summary stops showing on a card the moment that match's kickoff passes — see
Stale analysis disappears at kickoff.
Every analysis shows what it actually cost. OpenRouter's usage-accounting opt-in
(usage: { include: true } on every request, lib/openrouter.ts) returns a real dollar figure per
call, and since the research and predict calls are two separate API calls, the app sums their costs
rather than reporting just one side. A wasted attempt still costs money too — if a call comes back
empty or truncated and gets retried, that attempt's cost is added to the total rather than
discarded, so the number reflects everything OpenRouter actually billed for that analysis, not just
the call that finally succeeded. Running research more than once sums across all runs, since each
one is its own independent research+predict pair (lib/aggregate.ts) — unlike the probability
estimates, cost is never averaged.
The figure appears everywhere an analysis does: in the results sheet once comparison finishes, on
the card's cached last-analysis summary, and on a saved pick's detail view. It's formatted with
however many decimals actually show something (formatCostUsd, lib/format.ts) — analyses
typically cost a fraction of a cent, so a flat two-decimal format would round almost everything to
"$0.00". A provider that doesn't report cost (or local mock-data mode) simply shows nothing rather
than a placeholder.
The model button in the header (Discover and Sports both show it — it's one global choice, since
both feeds run through the same analysis pipeline) lets you pick which model powers every
analysis: deepseek/deepseek-v4-flash-0731
(the default), z-ai/glm-5.3-flash,
google/gemini-3.8-flash, or
nvidia/nemotron-3-ultra-550b-a55b. The
choice is remembered in your browser (lib/models.ts) and sent with every predict/compare request;
the API routes resolve it against a small server-side whitelist, so nothing free-text ever reaches
OpenRouter as a model id.
Every OpenRouter request asks for the lightest reasoning effort OpenRouter allows
(reasoning: { effort: "low" } in lib/openrouter.ts). Reasoning-capable releases — DeepSeek's in
particular — default to a "high" reasoning effort, silently burning a large hidden chain-of-thought
token budget before the model ever writes the prose or JSON we actually asked for; that's most of
why an analysis could feel like it hangs, and it made the length-triggered retry (see above) fail
the same way on every attempt since the budget kept going to reasoning instead of the answer. This
is a request, not a hard disable, on purpose: some providers mandate reasoning for their endpoint
and reject an outright enabled: false with a 400 ("Reasoning is mandatory ... cannot be
disabled"), which briefly broke every model except DeepSeek before this was caught. callOpenRouter
also carries a one-shot fallback for that exact error — it retries once with no reasoning field
at all — so one provider's quirk can't take the whole roster down again.
Tap the select icon in the header to pick up to 10 matches and analyze them all in one batch — they run through the same two-step process sequentially (not in parallel, to stay well within OpenRouter's rate limits) and each result appears in the sheet as it finishes.
lib/topTeams.ts. Combines with the league selection.lib/clubLogos.ts, /api/logos). Resolved names are cached in localStorage indefinitely (crests
essentially never change), so a club is only ever looked up once across the app's lifetime; any club
with no crest, or whose image fails to load, falls back to its initials.
strTeam and TheSportsDB's own strAlternate field, checked the same tiered way
every other provider's name is matched — lib/teamNameMatching.ts); an unverifiable single
result is rejected (falls back to initials) rather than risk showing someone else's badge.lib/topTeams.ts) — every
other spelling that club is already known by (e.g. "PSG" for Paris Saint-Germain, whose actual
TheSportsDB page is titled "Paris SG" — a name that shares no word with "Paris Saint-Germain" at
all, which is why the plain literal lookup found nothing). A curated alias is trusted outright
on a single confident result rather than re-verified against a name it was never going to
resemble; a generic accent/hyphen variant still goes through the same verification as the
original query.123 in SPORTS_DB_BASE) as restricted for searchteams.php — unreliable
or limited for most queries. Set SPORTS_DB_API_KEY to a free personal key (registration, no
payment, no code change needed beyond the env var) if crests are still wrong or missing for a
lot of clubs after the fixes above; this sandbox couldn't confirm the restriction directly since
thesportsdb.com isn't reachable from it, so this is a recommendation, not a verified fix.The bottom nav (components/BottomNav.tsx) is a floating rounded bar inset from both screen
edges, not a full-width strip flush against them. Behind it — on every page, since both live in
the root layout — sits BottomFade, a screen-wide gradient that's fully transparent at the nav's
own top edge and fades to 95% opaque black by the bottom of the screen, so ordinary content
scrolling underneath darkens progressively as it nears the nav rather than cutting off abruptly.
The nav is the only thing rendered above that gradient; the fade's height is kept in exact sync
with the nav's own rendered height (which varies by device, mainly the safe-area inset) via a
--bottom-nav-height CSS custom property BottomNav publishes from a ResizeObserver on itself,
rather than duplicating its padding math in a second place.
Rather than each animation picking its own curve, everything in the app moves on three tokens
(app/globals.css), which is what makes the whole thing feel like one object instead of a pile of
separately-tuned effects:
--ease-spring overshoots its target and settles back. That's the bounce, and it belongs on
transforms — scale and translate — where overshooting is the entire point and can't break
anything. Every tappable thing (.press) squashes instantly on touch and springs back on
release; popups, cards and reward flourishes all spring in.--ease-spring-soft is the same idea with a gentler overshoot, for larger elements where a full
bounce reads as flapping rather than springy (.rise-in, used by every list card).--ease-soft is a plain expo-out with no overshoot, for the cases where going past the
target is either invisible or actively wrong. There are three, and each is a real bug avoided
rather than a stylistic preference: border-radius, which clamps at zero and flashes square
corners on the way past (see the bet slip above); a bar whose width encodes a real value, where
overshooting would briefly draw a number that isn't true; and a sheet resting flush against the
bottom of the screen, which would lift clear of the edge and flash the page through the gap
underneath.The rule of thumb: spring the transform, ease the geometry.
BetIntelligence is a PWA: Add to Home Screen on iOS or Install on Android/desktop and it
runs standalone with its own icon, no browser chrome, and an app shell that still opens offline
(cached by public/sw.js — API responses are never cached, since a stale price is a wrong price).
Icons are generated by npm run icons from scripts/generate-icons.ts.
Every 10-30 seconds, on any page, a small winged coin flies across the screen
(components/FlyingMoney.tsx). Tap it before it flies off and it pays out a token reward between
€0.0001 and €0.01 (lib/luckyMoney.ts) — miss it and it just flies away, and a new one gets
scheduled at another random delay. Purely for fun: there's no wallet, no balance, and nothing is
persisted — the reward is just a celebratory popup that fades after a couple seconds.
npm install
cp .env.example .env.local
# add your OpenRouter API key to .env.local
npm run dev
Open http://localhost:3000.
| Variable | Required | Description |
|---|---|---|
OPENROUTER_API_KEY | Yes (for real analysis) | API key from openrouter.ai/keys. |
FOOTBALL_DATA_API_KEY | Yes (for football analysis) | Free key from football-data.org. Powers football's core research step (form, head-to-head, fixture status/score); Discover markets don't use it. |
BIG_BALLS_API_KEY | No | Free key from bigballsdata.com. Adds the injuries/availability section to football's research step. Left unset, that section just says injury data isn't available. |
SPORTS_DB_API_KEY | No | Free personal key from TheSportsDB (registration, no payment). Left unset, club-crest lookups use TheSportsDB's shared public demo key, which their own docs/forum describe as restricted for searchteams.php — see Club crests. |
NEXT_PUBLIC_APP_URL | No | Sent to OpenRouter as the app's referer/title for their dashboards. |
FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY | Yes (for accounts) | A Firebase service account's three fields (Accounts) — create a project at console.firebase.google.com, enable Firestore, then Project settings → Service accounts → Generate new private key. |
SESSION_SECRET | Yes (for accounts) | Signs the login session cookie. Generate with ; never reuse the placeholder. |
No API key or account is needed to browse games — Polymarket's Gamma API is public. A key is only required to run AI analysis.
gamma-api.polymarket.com), no
auth required.
lib/polymarket.ts (Sports) fetches upcoming soccer events, matches them against a keyword
list for the Premier League, La Liga, Bundesliga, Serie A, Ligue 1, Primeira Liga, Eredivisie,
Belgian Pro League, the Champions League, the Europa League, and three national-team
competitions — the World Cup, the Euro, and the UEFA Nations League — and derives 1X2
probabilities from each match's three moneyline sub-markets. The three national-team leagues'
keywords carry real collision risk (a bare "euro" would catch currency markets, Eurovision,
and this app's own Europa League; "world cup" would catch the differently-run FIFA Club World
Cup) — lib/leagues.ts's excludeKeywords handle each one explicitly, verified in
scripts/leagues-selftest.ts rather than assumed safe. This snapshot is only ever the seed/fallback for what's actually
displayed — see the CLOB bullet below and
The displayed odds always match CLOB's real current price.lib/allMarkets.ts (Discover) sweeps the highest-volume active events across every category,
with no fixed category list — categories shown are derived from whatever tags Polymarket
actually returns on the fetched markets.clob.polymarket.com/prices-history) — lib/oddsHistoryServer.ts/lib/livePrices.ts, proxied
through /api/odds-history. This is the actual number shown on a card's odds bars (see above)
and the source the odds-history chart draws its own line from, so the two can never disagree.clob.polymarket.com), but this one call is never
proxied through this app's own server at all — lib/realMoney/clob.ts talks to it directly from
the browser via Polymarket's own @polymarket/clob-client SDK, signed locally with the connected
wallet. See Real money.app/
page.tsx Home (default route) — portfolio balance, value graph, recent bets
discover/page.tsx Deactivated for now — redirects to Home, see "Discover" above
sports/page.tsx Sports — the original football-only game list
picks/page.tsx Every saved football analysis, sorted by when you saved it
lab/page.tsx Build a single/multi football bet from any saved pick and buy it
api/markets/route.ts Fetches + normalizes Discover's trending markets
api/analyze/market/predict/route.ts Discover step 1: independent AI prediction (any market)
api/analyze/market/compare/route.ts Discover step 2: compare prediction against market odds
api/games/route.ts Fetches + normalizes Sports' Polymarket games
api/analyze/predict/route.ts Sports step 1: independent AI prediction
api/analyze/compare/route.ts Sports step 2: compare prediction against market odds
api/odds-history/route.ts Proxies Polymarket's CLOB price-history, per outcome token
api/live-odds/route.ts Proxies Polymarket's CLOB midpoint, for the dedicated live-odds poll
components/ UI components (game/market cards, analysis sheets, etc.)
lib/ Polymarket + OpenRouter integrations, types, formatting, storage
Next.js (App Router) + TypeScript + Tailwind CSS v4. No database — game data is fetched fresh on
each request, and saved picks live in the browser's localStorage.
Adding a leg surfaces a floating pill at the bottom of the screen — how many bets, the combined
decimal odds, and the combined edge, all live — over a slow, dim gold sheen that drifts across
the pill (and the expanded sheet behind it) so the slip always feels quietly alive rather than
static. Tapping it doesn't swap in a separate sheet — the same element morphs in place: its
border-radius eases from a full pill to a rounded sheet while its body grows open via a
grid-template-rows transition (0fr → 1fr, components/BetSlipBar.tsx), so the whole
thing reads as one shape stretching open rather than two different elements swapping places, and
the page behind it dims and blurs (.lab-slip-scrim) so the open slip reads as a layer over the
app — tapping the dimmed area closes it. That radius transition is pointedly not springy,
and that's load-bearing: it runs 999px → 28px, so a spring's ~10% overshoot of a 971px
range lands far below zero, where border-radius clamps — which is exactly why the corners used
to snap hard-square partway through every open before rounding off again. The bounce moved to the
sheet's contents, which spring up a beat behind the shape and can overshoot for free.
Tapping the pill again morphs it back shut the same way. Open, it shows the full slip: every leg with its own live odds/edge, a stake
picker (quick chips of €10/€25/€50/€100), and — with 2+ legs — the combined market and AI
probability, each the product of every leg's own probability for its chosen outcome
(combineSlip, lib/betslip.ts). This math doesn't care what kind of market a leg came from — with Discover
deactivated the Build tab only ever offers football legs for now, but a parlay placed back when Discover was
active, freely mixing a football result with, say, a crypto price target, still displays and reprices correctly.
Buy: tapping the gold Buy €N at Nx button places the slip as a paper bet at today's
market price (not whatever it was at analysis time) — a brief "placing" animation, then a
confetti-and-receipt confirmation, and the slip clears. The stake is spent from the play-money
balance Home tracks (see below); lib/placedBets.ts snapshots the legs, the live entry price,
and the stake into local storage.
My Bets tab lists every bet you've placed as a ticket-style card — legs, live odds/edge (or
the real payout and P&L once settled — see
Settling football bets against the real result
below), a status pill (Pending / Won / Lost), and how much you staked on it. Each card
has its own delete button (removePlacedBet, lib/placedBets.ts) to clear out a bet you no
longer want tracked.
A glint slides along the surface on an eased cycle, like sun catching the water, moved by translating a gradient rather than redrawing anything.
When the portfolio is up it's tropical water — turquoise green shifting to blue
(--water-1/--water-2); down stays the app's plain red, since a portfolio underwater isn't the
kind of water this is for.
Two things keep this honest rather than decorative. The displacement tapers to exactly zero at
both ends, so the first and last points — the latter carrying the "now" dot — sit precisely on
their real values while only the stretch between them moves; and the crest is a few percent of
the plot height, far too small to change what the line says about the numbers underneath it. The
hover readout always reports the true stored value, never a waved one. It's driven by writing d
straight onto the paths from a requestAnimationFrame loop rather than through React state,
since putting a 60fps clock into state would re-render the whole chart every frame for nothing —
and it doesn't run at all for anyone whose OS asks for reduced motion, who gets the same chart
held still.
Recent bets lists your last 5 placed bets — legs, stake, current live value, and P&L in both
€ and % — with a Show 10 toggle to see more. Nothing here is a real trade; it's the same
paper-trade philosophy as the rest of the app, just tracked in one place with real numbers instead
of just probabilities. Each row leads with the odds the bet was actually taken at, in place of
what used to be a generic ticket icon that said nothing: the one number that says what the bet was
worth doing, colored green if it won, red if it lost, neutral while it's still open. The leg labels
next to it (Arsenal, 1X, X2 + 1X…) are colored the same way but individually, each by its
own result rather than the bet's — so a parlay that's already lost still shows at a glance which of
its legs came in. That relies on per-leg results continuing to resolve after the bet itself has
settled; see A parlay's individual legs settle independently, too.
homeAssessment/awayAssessment in
IndependentPrediction) plus one overall summary — shown as two per-team cards (components/TeamAssessmentSummary.tsx,
shared between the live analysis sheet and a saved pick's read-only detail view) rather than a single flat bullet list,
so it's clear which point is about which team. Running research more than once merges pros/cons per team across runs
the same way it already deduped a flat list (lib/aggregate.ts), and the market-comparison step below is shown each
team's merged pros/cons alongside the summary for full context, not just the summary alone.GameCard's Analyze button and the Sports page's
batch-analysis flow both carry that live price into the Game object handed to the analysis sheet, so a live match
whose odds have moved since kickoff gets compared against where the market actually is right now, not where it was
when the general sweep last ran. Both this step and the independent-read step above are given a pre-computed,
plainly labeled Current Game Time line (gameTimeLine, lib/openrouter.ts) — "NOT STARTED YET", "LIVE / IN
PROGRESS" with the real elapsed minutes, or "PROBABLY OVER" — instead of being left to work that out for
themselves from the kickoff time and nowLine()'s current date/time separately, which is exactly what the model
kept getting wrong. Without it, a live match's already-moved market odds could read as the market simply
disagreeing with the pre-match independent view, instead of what it actually is: the market pricing in a match
that's already underway. When a real live score is available for the match being analyzed — the exact clock and
score its own Sports-page card is showing right now (ESPN, lib/liveScores.ts) — that gets reported directly
instead ("LIVE — 63', current score 2-1"), explicitly marked as real data rather than an estimate; the wall-clock
guess above is only the fallback for whenever no live score has come in yet. GameCard's Analyze button and the
batch-analysis flow both carry that same live score into the analysis sheet, threaded through to both this step
and the independent-read step as a liveScore field on the request (validated server-side by
parseLiveScoreInput).localStorage, so reopening the app renders instantly
with no network request. There's no automatic timer: the general odds sweep runs on the initial
load, when you return to a tab that's gone stale, and whenever you tap the refresh button in the
header — throttled to at most once every 10 minutes either way (see
The general odds sweep is click-triggered).
A currently-live game's own odds are never touched by this sweep at all — they're owned entirely
by its dedicated 10-second live-odds poll. If a refresh fails, the last known odds stay on screen
with a notice rather than being replaced by an error.openssl rand -base64 32.env.exampleMOCK_GAMES | No | Set to 1 to serve built-in sample matches instead of calling Polymarket. Useful for local UI work without network access. |
MOCK_MARKETS | No | Set to 1 to serve built-in sample Discover markets instead of calling Polymarket. |
MOCK_AI | No | Set to 1 to return a canned analysis instead of calling OpenRouter (covers both Sports and Discover's analysis flows). Useful for testing without spending API credits. |
lib/openrouter.tslib/openrouterMarkets.tslib/openrouter.tsapi.football-data.org/v4)
— lib/footballData.ts fetches each team's recent form, head-to-head history, and the fixture's
pre-match status, and feeds that straight into the football research digest above. No fallback to
web search on a miss (a team/fixture that can't be resolved within a covered league fails the
analysis with a clear error); a league with no free-tier code at all instead runs analysis with a
plainly-labeled "no structured data" digest rather than failing outright — see
How the AI analysis works.lib/liveScores.ts powers the games list's live-score badge and clock
(getLiveScores, one request per league in play) and bet settlement's
real final score (getMatchResultsSince), both across all 13 covered leagues. The three
national-team competitions' ESPN slugs (fifa.world, uefa.euro, uefa.nations) follow ESPN's
own <confederation>.<competition> naming convention but, unlike every domestic/club league's
slug, could not be confirmed against a real response — ESPN's domain is blocked by this project's
dev sandbox network policy, the same caveat as football-data.org and Big Balls Sports Data below.
If a national-team match's live score or settlement doesn't show up once deployed, this is the
first place to check. See
Live scores and match clock.api.bigballsdata.com/v1) — lib/bigBallsData.ts fetches each covered league's current injury
list plus a team-id-to-name lookup (a player record carries only an opaque team id, no name —
confirmed against a real response via /api/debug/injuries, not guessed), resolves the two
teams in the match, and appends an "Injuries / Availability" section to the digest above. Covers
Premier League, La Liga, Bundesliga, Serie A, Ligue 1, and the Champions League; an uncovered
league, an unset BIG_BALLS_API_KEY, or a failed request all render as an honest "not available"
line rather than failing the analysis — this source is an enrichment layer, not a required one.
If the team-name lookup itself fails, the digest still surfaces the full unmatched player list
league-wide rather than silently claiming neither team has any reported injuries. Every failure
mode is logged via console.error, and /api/debug/injuries?league=<id> (not linked from the
UI) returns the raw responses for both endpoints directly — useful if this ever looks wrong again
once deployed, since this provider's exact contract can't be tested live from this project's dev
environment (its domain is blocked by that sandbox's network policy the same way
football-data.org's docs were).lib/lineups.ts's fetchMatchLineups finds ESPN's own event id for the fixture (a
scoreboard request, matched by team name) and then reads that event's summary for a posted
roster, and getLineupAvailability does the same in batch across many games at once for the
Sports page's "11s Are Here!" poll. No key, same undocumented-endpoint caveat as live scores; a
lineup not yet posted, an uncovered league, or a failed request all mean "not available right
now" rather than breaking the analysis.