Repository navigation
Add a Live Tennis API sports provider - #12
Merged
Merged
Conversation
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
approved these changes
Sep 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Vendor disclosure first: I work on the Live Tennis API, the vendor this adapter integrates. I am proposing it because
SPORTS_PROVIDER.mdinvites 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.mdsays:So: the API is the Live Tennis API, covering ATP, WTA, Challenger and ITF. Docs are at
https://docs.livetennisapi.com/openapi.yamlandhttps://docs.livetennisapi.com/llms.txt. There is a self-serve free tier with no card athttps://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.tsimplementsSportsProviderfromshared/sports.ts.server/sports/providerRegistry.tsgains onecase "livetennis":in the existing style. The default throw is untouched..env.exampledocumentsLIVETENNIS_API_KEY, an optional base-URL override, andSPORTS_PROVIDER=livetenniscommented out.SPORTS_PROVIDERstill defaults todemo, so with no key and no env change the app behaves exactly as it does today.server/sports/liveTennisProvider.test.tshas 54 tests mirroringgateway.test.ts, stubbing the fetch boundary. No live call, so no key is needed in CI.shared/sports.tsis 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
typedeclarations local to the adapter file and are never exported. A test asserts the mapped objects carry onlySportsMatch,TeamandSportsLeaguekeys, 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 labelledMatch, 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.tsxtreats 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
getStandingsserves 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 ingoalDifference, the one delta field the contract offers. Happy to dropgetStandingsto a flat empty array if you would rather not stretchStandingRowthat way.Pacing
Left entirely to
SportsGateway, per the table inSPORTS_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:
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.jsonexcludes**/*.test.ts, sopnpm checkdoes 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.startTimeis 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 nullablestartTimeand 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.