Skip to content

Add a Live Tennis API sports provider - #12

Merged
vincenzo-afk merged 1 commit into
vincenzo-afk:mainfrom
bensynapse:livetennis-provider
Sep 12, 2026
Merged

vincenzo-afk merged 1 commit into
vincenzo-afk:mainfrom
bensynapse:livetennis-provider

Conversation

@bensynapse

Copy link
Copy Markdown
Contributor

Vendor disclosure first: I work on the Live Tennis API, the vendor this adapter integrates. I am proposing it because SPORTS_PROVIDER.md invites exactly this contribution, and I have tried to make it reviewable on its own terms: one adapter file, one registry case, two documented environment variables, and nothing else touched. If you would rather not carry a vendor-authored adapter, close this and no hard feelings. AI tooling was used while writing it; the code and the checks below are mine.

SPORTS_PROVIDER.md says:

When you provide the API name, documentation, and key, I will add one adapter, register it, and configure the provider. The score UI, favorites, filters, match detail view, and standings routes do not need to change.

So: the API is the Live Tennis API, covering ATP, WTA, Challenger and ITF. Docs are at https://docs.livetennisapi.com/openapi.yaml and https://docs.livetennisapi.com/llms.txt. There is a self-serve free tier with no card at https://livetennisapi.com/subscribe/free, so you can exercise this without talking to me. I wrote the adapter and left the key to you. The score UI, favorites, filters, match detail view and standings routes are unchanged, as promised.

What is here

  • server/sports/liveTennisProvider.ts implements SportsProvider from shared/sports.ts.
  • server/sports/providerRegistry.ts gains one case "livetennis": in the existing style. The default throw is untouched.
  • .env.example documents LIVETENNIS_API_KEY, an optional base-URL override, and SPORTS_PROVIDER=livetennis commented out. SPORTS_PROVIDER still defaults to demo, so with no key and no env change the app behaves exactly as it does today.
  • server/sports/liveTennisProvider.test.ts has 54 tests mirroring gateway.test.ts, stubbing the fetch boundary. No live call, so no key is needed in CI.

shared/sports.ts is not modified at all.

On the CONTRIBUTING rule about provider fields

The rule is that provider-specific response fields must not reach client components. The upstream shapes are type declarations local to the adapter file and are never exported. A test asserts the mapped objects carry only SportsMatch, Team and SportsLeague keys, and that our own field names do not appear anywhere in the serialised result.

Two mapping decisions worth your eye

No new EventKind. Our event vocabulary is structural, covering breaks, set and game wins, momentum runs and stoppages, and it contains no ace. Those map to the existing neutral "period" kind with the meaning in the title. The only ace figure we publish anywhere is a whole-match measured total, so it becomes a single "ace" event per player labelled Match, rather than a fabricated per-point event with an invented minute.

Standings return empty rather than throwing when the key's plan does not cover rankings. Our rank-ordered rankings listing is a paid feature. client/src/pages/League.tsx treats a standings error as a failure of the whole page, so throwing would take the league header and fixture list down with the table. An empty array lands in your existing "No table yet" panel instead. The refusal is named once on the server log so it is not silent, and remembered per process so a free key pays one upstream refusal rather than one per page view. Two cases stay distinguishable: a tour with no official table returns empty with no HTTP call at all, while a plan refusal returns empty with the warning. Every other failure still throws, because a failed request must never reach the UI as "there is nothing here".

Tennis has no league table, so getStandings serves the official ranking table for the league's own tour. A ranking table records no fixtures, so played, won, drawn and lost are zero, and the signed weekly rank movement goes in goalDifference, the one delta field the contract offers. Happy to drop getStandings to a flat empty array if you would rather not stretch StandingRow that way.

Pacing

Left entirely to SportsGateway, per the table in SPORTS_PROVIDER.md. No second limiter. There is a comment on what the free tier's 100 requests a day implies: the per-minute ceiling never binds, the daily one does, and the gateway's cache TTL is the knob that matters.

Test evidence

Node 22, pnpm 10.4.1, your four CI commands:

pnpm install --frozen-lockfile   Done in 1s
pnpm check                       tsc --noEmit, no output
pnpm test                        Test Files 4 passed (4) · Tests 54 passed (54)
pnpm build                       built in 4.12s · dist/index.js 65.9kb

Covered: the live-match mapping, player-major set arrays, fixtures becoming upcoming matches, tournaments becoming leagues, rankings becoming standings, empty data, a non-2xx response, a non-JSON body, a completed match with null points and empty games arrays, and each plan refusal.

One note for you rather than for me: tsconfig.json excludes **/*.test.ts, so pnpm check does not type-check the new test. I checked it separately through a temporary config and it is clean, but you may want to decide whether that exclusion is intentional.

Two notes on edge cases

SportsMatch.startTime is a non-nullable string, and a fixture can legitimately carry no stated time at all. Rather than invent one or let the match card render an invalid date, the adapter drops such a row. Say the word if you would rather have a nullable startTime and keep the row.

Paged endpoints are walked using the API's own end-of-data signal with a bounded page count, rather than trusting a single response. A transient upstream failure is raised rather than being reported to the UI as a missing league, since those are different things.

Not verified on my side: no live call was made, because the key lives on the vendor side rather than on my build machine. Every response shape in the tests is built from our published schema, so field names and nullability are verified against the spec but not against an observed response. I also did not run the app in a browser.


Housekeeping note: an earlier revision of this branch was briefly open as #11 and was closed by mistake on my side, before any review. GitHub would not let me reopen it, so this is the same work as a fresh pull request. The code here is a later revision than what #11 contained: an internal review pass found and fixed thirteen defects, including the invalid-date case above, an unhandled pagination signal, and branching on the wrong freshness field. Sorry for the noise in your notifications.

Implement SportsProvider against the Live Tennis API (ATP, WTA, Challenger
and ITF) behind the existing adapter boundary, register it as "livetennis",
and document its environment variables. SPORTS_PROVIDER still defaults to
demo, so an install with no key behaves exactly as before.

Mapping, all verified against the published openapi.yaml and llms.txt:

- Score.games is player-major, so [[6,4,2],[3,6,1]] becomes the segments
  6-3, 4-6, 2-1. The game in progress is appended only while the match is
  live and is labelled Tie-break when is_tiebreak says so, and exactly one
  segment is ever active because the client highlights all of them.
- Score.points entries are nullable, and a completed match can carry null
  points with empty games arrays, so points are read through a guard.
- Fixtures become upcoming matches. Every match route shares one id space,
  so a fixture id resolves at /matches/{id} and its detail page works
  before the match starts. A fixture's status is free text rather than the
  Match status enum, so anything the words do not place elsewhere stays
  upcoming instead of being filed under results.
- Tournaments become leagues. A fixture publishes no tournament_id, so its
  league id is a slug of the tournament name, which getLeague resolves
  through the catalogue's substring search and confirms by slug.
- The rank-ordered rankings listing becomes standings, chosen by the
  league's own tour. A ranking table records no fixtures, so played, won,
  drawn and lost are 0 and the signed weekly rank movement is the delta.
- The event vocabulary holds no ace, so events map to the neutral period
  kind and the title carries the meaning. The only ace figure the API
  publishes is the measured match total, labelled as a total and read from
  the measured family's own coverage as the spec directs. No new EventKind
  is introduced.
- A cancelled or never-played match shows no score rather than 0-0, and a
  measured zero is kept, because it is not the same as not measured.

Tier handling is explicit: /matches (live, upcoming), /matches/{id},
/fixtures and /tournaments are FREE; the completed listing is BASIC, match
events and rankings are PRO, in-play statistics ULTRA. A 403 is remembered
per process, so a free key pays one refusal rather than one per read, and
the tier is named once on the server log so the degradation is not silent.
Any other upstream failure is raised instead, so an outage cannot reach the
UI as "there is nothing here".

Filters the upstream query cannot express walk a bounded number of pages
and read meta.has_more, the documented end-of-data signal, rather than
filtering one possibly truncated page and reporting no matches. Rows are
de-duplicated across listings and a row with no stated start time is
dropped, since startTime is a required string the cards format as a date.

No provider field reaches the client: the upstream shapes are local to the
adapter and a test asserts the mapped objects carry only contract keys.
Pacing is left to the gateway, with a note on what the free tier's 100
requests per day imply for its cache TTL.

Tests stub the fetch boundary, so they make no live call, and cover the
mapping, empty data, a non-2xx response, a non-JSON body, the null-points
completed match, pagination, de-duplication and the tier refusals.
@vincenzo-afk
vincenzo-afk merged commit be4411c into vincenzo-afk:main Sep 12, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants