{"id":51441840,"url":"https://github.com/soulin02/linedrift","last_synced_at":"2026-07-05T13:00:20.531Z","repository":{"id":364632536,"uuid":"1268646160","full_name":"souliN02/linedrift","owner":"souliN02","description":"Football odds tracker: snapshots bookmaker odds over time, computes no-vig consensus probabilities, and flags value. Educational analytics project.","archived":false,"fork":false,"pushed_at":"2026-07-02T22:20:03.000Z","size":3856,"stargazers_count":0,"open_issues_count":3,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-03T00:15:14.132Z","etag":null,"topics":["data-pipeline","drizzle","football","neon","nextjs","no-vig","odds","postgres","recharts","sports-betting","typescript","vercel"],"latest_commit_sha":null,"homepage":"https://linedrift.vercel.app","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/souliN02.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-13T19:20:57.000Z","updated_at":"2026-07-02T22:20:06.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/souliN02/linedrift","commit_stats":null,"previous_names":["soulin02/oddslens","soulin02/linedrift"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/souliN02/linedrift","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/souliN02%2Flinedrift","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/souliN02%2Flinedrift/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/souliN02%2Flinedrift/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/souliN02%2Flinedrift/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/souliN02","download_url":"https://codeload.github.com/souliN02/linedrift/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/souliN02%2Flinedrift/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35154748,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-05T02:00:06.290Z","response_time":100,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["data-pipeline","drizzle","football","neon","nextjs","no-vig","odds","postgres","recharts","sports-betting","typescript","vercel"],"created_at":"2026-07-05T13:00:19.232Z","updated_at":"2026-07-05T13:00:20.329Z","avatar_url":"https://github.com/souliN02.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# LineDrift\n\n[![CI](https://github.com/souliN02/linedrift/actions/workflows/ci.yml/badge.svg)](https://github.com/souliN02/linedrift/actions/workflows/ci.yml)\n\n**A football odds tracker that builds its own historical dataset** — snapshotting\nbookmaker odds every 4 hours, computing no-vig consensus probabilities, and\nflagging prices that beat the market.\n\n**Live demo → https://linedrift.vercel.app** · **How it works → [/about](https://linedrift.vercel.app/about)**\n\n![LineDrift demo — dashboard, closing-line report, odds-movement chart toggles](docs/demo.gif)\n\nFree odds APIs only give you the _current_ price; history is paywalled. LineDrift\nmanufactures its own history under a real constraint — **500 free API credits a\nmonth** — which is the point of the project: a data pipeline sized around a hard\nbudget, not a CRUD demo.\n\n## Features\n\n- **Odds snapshots every 4 hours** — Premier League, Danish Superliga, and the\n  FIFA World Cup while it's on — into Postgres via a scheduled GitHub Actions job.\n- **Dashboard** of upcoming matches with best available price per outcome, the\n  offering bookmaker, lowest overround, and value badges.\n- **Match detail** with a Recharts odds-movement chart (line per bookmaker,\n  Home/Draw/Away toggle) and a per-bookmaker comparison table.\n\n  ![Match detail — odds movement chart and bookmaker table](docs/match-detail.png)\n\n- **Value engine** — implied probability, overround (vig), no-vig fair\n  probabilities, market consensus, and edge-vs-consensus flags, all\n  [pure and unit-tested](src/lib/odds-math.ts).\n- **Closing line value** — every finished match gets a closing-line report:\n  opening vs closing price per bookmaker and outcome, the biggest move\n  headlined, and the closing no-vig consensus. The classic test of whether\n  \"value\" was real — powered entirely by the self-built history.\n- **Pipeline health** — every ingestion run records what it saw and what was\n  left of the monthly credit budget, shown live on\n  [/about](https://linedrift.vercel.app/about); failed runs included.\n- **`/about`** page explaining the pipeline and the maths with a live worked example.\n- TypeScript strict throughout, Zod-validated API boundary, 150+ unit tests plus\n  a Playwright e2e smoke, green CI.\n\n## Architecture\n\n```mermaid\nflowchart LR\n  GHA[GitHub Actions cron - every 4h] --\u003e|POST + Bearer CRON_SECRET| SNAP[\"/api/cron/snapshot\"]\n  SNAP --\u003e|fetch h2h odds, 3 sport keys| ODDS[The Odds API]\n  SNAP --\u003e|Zod validate + upsert| DB[(Neon Postgres)]\n  WEB[Next.js server components] --\u003e DB\n  CHART[\"/api/matches/id/history\"] --\u003e DB\n```\n\nPages read Postgres directly in React Server Components (no client fetching for\nthe initial render). The only mutating endpoint is the snapshot route, guarded\nby a bearer secret. Raw API JSON is validated by Zod in a single boundary file so\nthe rest of the app never sees an untrusted shape.\n\n## Stack\n\nNext.js (App Router) · TypeScript strict · Tailwind CSS + shadcn/ui · Drizzle\nORM + Neon Postgres · Zod · Recharts · Vitest + Testing Library · GitHub\nActions + Vercel.\n\n## Local setup\n\nUses [pnpm](https://pnpm.io) (pinned via the `packageManager` field).\n\n```bash\ngit clone https://github.com/souliN02/linedrift.git\ncd linedrift\npnpm install\ncp .env.example .env        # fill in DATABASE_URL (see below)\n\npnpm db:migrate            # apply Drizzle migrations to the DB\npnpm db:seed               # load tests/fixtures/odds-response.json into the DB\npnpm dev                   # http://localhost:3000\n```\n\n`pnpm db:seed` populates the UI entirely from the saved fixture — **development\nand tests never call the live API** (the free tier is reserved for the scheduled\njob). You only need a `DATABASE_URL`; `ODDS_API_KEY` and `CRON_SECRET` are only\nrequired to run the live ingestion job.\n\n### Run the tests\n\n```bash\npnpm test         # vitest run — odds math, Zod boundary, snapshot route, dashboard render\npnpm test:e2e     # Playwright smoke — drives the real app; needs DATABASE_URL + a running dev server\npnpm typecheck    # tsc --noEmit\npnpm lint         # eslint\n```\n\n### Scripts\n\n```bash\npnpm dev             # local dev server\npnpm build           # production build\npnpm lint            # eslint\npnpm typecheck       # tsc --noEmit\npnpm test            # vitest run\npnpm format          # prettier --write .\npnpm db:generate     # drizzle-kit generate migrations\npnpm db:migrate      # apply migrations\npnpm db:seed         # seed local/dev DB from the fixture\n```\n\n## Ingestion pipeline\n\n- **`POST /api/cron/snapshot`** is the only mutating endpoint. It requires\n  `Authorization: Bearer ${CRON_SECRET}`, fetches h2h odds for every configured\n  league (EPL, Superliga, and the World Cup while it runs) from\n  [The Odds API](https://the-odds-api.com), validates them through the Zod\n  boundary in [`src/lib/odds-api.ts`](src/lib/odds-api.ts), upserts via\n  [`src/db/ingest.ts`](src/db/ingest.ts), logs `x-requests-remaining`, and\n  returns `{ matches, snapshots, creditsRemaining }`. A unique\n  `(match, bookmaker, captured_at)` index makes duplicate runs harmless.\n- **[`.github/workflows/snapshot.yml`](.github/workflows/snapshot.yml)** curls\n  that endpoint on a `0 */4 * * *` schedule (and on manual `workflow_dispatch`).\n\n### Configuration\n\n| Where  | Name           | Value                                                   |\n| ------ | -------------- | ------------------------------------------------------- |\n| Vercel | `DATABASE_URL` | Neon connection string                                  |\n| Vercel | `ODDS_API_KEY` | the-odds-api.com key                                    |\n| Vercel | `CRON_SECRET`  | long random string                                      |\n| GitHub | `CRON_SECRET`  | secret — same value as Vercel                           |\n| GitHub | `SNAPSHOT_URL` | variable — `https://\u003capp\u003e.vercel.app/api/cron/snapshot` |\n\nAfter setting these, trigger `snapshot.yml` via **workflow_dispatch** to confirm\nrows land in Neon and credits are logged in the Vercel function logs.\n\n## Engineering decisions\n\n- **Snapshots instead of a paywalled historical API.** The Odds API charges 10×\n  for history. Recording the live `h2h` market costs `regions × markets` credits\n  per league per run — 2 off-season (EPL + Superliga) and 3 while the FIFA World\n  Cup has fixtures (its key bills 0 between tournaments). Every 4 hours is 6\n  runs/day ≈ **360 of the free tier's 500 credits/month**, rising to ~486 in a\n  World Cup month — still inside the tier. The schedule is the single knob.\n- **Zod at the boundary.** All external JSON is parsed and validated in\n  [`src/lib/odds-api.ts`](src/lib/odds-api.ts) before touching the app, so a\n  provider change (or a malformed payload) is contained to one file.\n- **`numeric`, not float, for odds.** Prices are stored as Postgres `numeric` to\n  avoid binary-float drift; Drizzle returns them as strings and they are converted\n  to `number` in exactly one place ([`src/db/queries.ts`](src/db/queries.ts)),\n  never inline.\n- **Odds maths is pure and test-first.** Every calculation lives in\n  [`src/lib/odds-math.ts`](src/lib/odds-math.ts) as a pure function with\n  table-driven tests (implied, overround, no-vig, consensus, edge, best price),\n  covering edge cases like fewer than three bookmakers and missing draw prices.\n- **GitHub Actions as the scheduler, not Vercel Cron.** Vercel's Hobby plan caps\n  cron at one run per day; the pipeline needs every 4 hours, so the schedule lives\n  in GitHub Actions (a few minutes of drift is fine here).\n- **Server components read the DB directly.** No client data fetching for the\n  initial render; interactivity (chart toggles) is the only client-side code.\n\n## Roadmap\n\nDeliberately out of scope for now (closing-line value graduated from this list\nin v1.1):\n\n- Email alerts when an edge over _X_% appears\n- More markets (totals) or arbitrage detection across bookmakers\n- A public, rate-limited JSON API\n\n## Disclaimer\n\nEducational analytics project. **Not betting advice.**\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsoulin02%2Flinedrift","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsoulin02%2Flinedrift","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsoulin02%2Flinedrift/lists"}