cito-mcp 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -1
- package/dist/tools/insight.js +126 -7
- package/dist/tools/leaderboard.js +18 -2
- package/dist/tools/match.js +75 -5
- package/dist/tools/meta.js +6 -6
- package/dist/tools/normalize.js +221 -15
- package/dist/tools/odds.js +122 -20
- package/dist/tools/player.js +88 -7
- package/dist/tools/rankings.js +75 -10
- package/dist/tools/resolve.js +121 -8
- package/dist/tools/schedule.js +101 -12
- package/dist/tools/standings.js +63 -10
- package/dist/tools/tournaments.js +70 -4
- package/package.json +1 -1
package/dist/tools/rankings.js
CHANGED
|
@@ -82,6 +82,8 @@ Do not use when: current top-N snapshot → standings with game tennis; player h
|
|
|
82
82
|
|
|
83
83
|
Tennis-only. Direction up (climbers, default), down (fallers), or both (largest absolute change). Date pins an older ranking list (YYYY-MM-DD); default is the latest, which the API resolves past zero-diff cloned lists.
|
|
84
84
|
|
|
85
|
+
The two lists are NOT always one week apart: previousDate is whatever the archive holds next, and the archive has holes. Read gapDays / comparisonWindow (and comparisonsAreWeekly) before describing a delta as weekly — a warning is raised whenever the gap exceeds ten days.
|
|
86
|
+
|
|
85
87
|
Parallel-safe: yes. Upstream cost: 1.`,
|
|
86
88
|
inputSchema: {
|
|
87
89
|
type: 'object',
|
|
@@ -195,6 +197,39 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
195
197
|
const newEntries = (Array.isArray(body.new_entries) ? body.new_entries : []).map(normalizeEdge);
|
|
196
198
|
const dropped = (Array.isArray(body.dropped) ? body.dropped : []).map(normalizeEdge);
|
|
197
199
|
const total = typeof body.total === 'number' ? body.total : items.length;
|
|
200
|
+
/**
|
|
201
|
+
* How far apart the two lists actually are.
|
|
202
|
+
*
|
|
203
|
+
* `previous_date` is simply the next list the archive holds — it is NOT
|
|
204
|
+
* guaranteed to be last week. On 2026-09-12 it was 2026-06-08 against a
|
|
205
|
+
* current 2026-08-31: an 84-day gap, because the weekly archive ends
|
|
206
|
+
* 2026-06-08 and the only snapshots after it are the current ESPN-sourced
|
|
207
|
+
* pair. Calling that "week-over-week movers" told every caller that a
|
|
208
|
+
* three-month climb was a one-week climb. The interval is measured and
|
|
209
|
+
* reported instead of assumed.
|
|
210
|
+
*/
|
|
211
|
+
const rankingDate = pickString(body.ranking_date) ?? null;
|
|
212
|
+
const previousDate = pickString(body.previous_date) ?? null;
|
|
213
|
+
const gapDays = (() => {
|
|
214
|
+
if (!rankingDate || !previousDate)
|
|
215
|
+
return null;
|
|
216
|
+
const a = Date.parse(rankingDate);
|
|
217
|
+
const b = Date.parse(previousDate);
|
|
218
|
+
if (!Number.isFinite(a) || !Number.isFinite(b))
|
|
219
|
+
return null;
|
|
220
|
+
return Math.round((a - b) / 86_400_000);
|
|
221
|
+
})();
|
|
222
|
+
const comparisonWindow = gapDays === null
|
|
223
|
+
? null
|
|
224
|
+
: gapDays <= 8
|
|
225
|
+
? 'week-over-week'
|
|
226
|
+
: `${Math.round(gapDays / 7)}-week gap`;
|
|
227
|
+
const warnings = [];
|
|
228
|
+
if (gapDays !== null && gapDays > 10) {
|
|
229
|
+
warnings.push(`The compared lists are ${gapDays} days apart (${previousDate} -> ${rankingDate}), not one week. ` +
|
|
230
|
+
`Every "movement" here accumulated over ${Math.round(gapDays / 7)} weeks; there is no published ` +
|
|
231
|
+
`ranking snapshot strictly between those two dates. Treat the deltas as period-over-period, not weekly.`);
|
|
232
|
+
}
|
|
198
233
|
return successEnvelope({
|
|
199
234
|
pagination: {
|
|
200
235
|
limit,
|
|
@@ -210,13 +245,19 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
210
245
|
tookMs: Date.now() - started,
|
|
211
246
|
upstreamCalls: 1,
|
|
212
247
|
rateLimit: res.headers,
|
|
248
|
+
warnings: warnings.length ? warnings : undefined,
|
|
213
249
|
data: {
|
|
214
250
|
title: `${tourParse.tour} ranking movers (${dirParse.direction})`,
|
|
215
251
|
tour: tourParse.tour,
|
|
216
252
|
direction: dirParse.direction,
|
|
217
253
|
within,
|
|
218
|
-
rankingDate
|
|
219
|
-
previousDate
|
|
254
|
+
rankingDate,
|
|
255
|
+
previousDate,
|
|
256
|
+
/** Real interval between the two lists; null when only one list exists. */
|
|
257
|
+
gapDays,
|
|
258
|
+
/** 'week-over-week' only when the gap really is about a week. */
|
|
259
|
+
comparisonWindow,
|
|
260
|
+
comparisonsAreWeekly: gapDays !== null && gapDays <= 8,
|
|
220
261
|
items,
|
|
221
262
|
newEntries,
|
|
222
263
|
dropped,
|
|
@@ -237,8 +278,10 @@ Prefer over: standings (latest snapshot only); rankings_movers (week-over-week d
|
|
|
237
278
|
Do not use when: the current top-N table → standings with game tennis.
|
|
238
279
|
|
|
239
280
|
Tennis-only. Rows arrive oldest-first (chronological), which is the order a chart
|
|
240
|
-
wants; the newest entries are the tail.
|
|
241
|
-
|
|
281
|
+
wants; the newest entries are the tail. limit keeps the most RECENT N rows (the
|
|
282
|
+
route itself ignores ?limit=, so the bound is applied here) — hasMore and
|
|
283
|
+
oldestReturned/newestReturned say exactly which window came back, and
|
|
284
|
+
omittedOlder counts the rows left behind at the old end.
|
|
242
285
|
|
|
243
286
|
Parallel-safe: yes. Upstream cost: 1.`,
|
|
244
287
|
inputSchema: {
|
|
@@ -322,7 +365,7 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
322
365
|
const root = asRecord(res.data) ?? {};
|
|
323
366
|
const body = asRecord(root.data) ?? root;
|
|
324
367
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
325
|
-
|
|
368
|
+
const allHistory = (Array.isArray(body.history) ? body.history : []).map((row) => {
|
|
326
369
|
const r = asRecord(row) ?? {};
|
|
327
370
|
return {
|
|
328
371
|
date: pickString(r.date) ?? null,
|
|
@@ -330,8 +373,23 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
330
373
|
points: num(r.points),
|
|
331
374
|
};
|
|
332
375
|
});
|
|
333
|
-
|
|
334
|
-
|
|
376
|
+
const inWindow = sinceRaw
|
|
377
|
+
? allHistory.filter((h) => h.date !== null && h.date >= sinceRaw)
|
|
378
|
+
: allHistory;
|
|
379
|
+
/**
|
|
380
|
+
* The route IGNORES ?limit= — verified: `?limit=4` returned all 342 rows of
|
|
381
|
+
* Alcaraz's career with total:342 and hasMore:false. Asking for 4 and
|
|
382
|
+
* receiving 342 is not a bound, it is a payload the caller cannot control,
|
|
383
|
+
* so the bound is applied here.
|
|
384
|
+
*
|
|
385
|
+
* Rows arrive oldest-first because that is the order a chart wants. limit
|
|
386
|
+
* therefore keeps the most RECENT N (the tail), matching every other tool
|
|
387
|
+
* in this server: `player_matches` returns the newest rows and reports
|
|
388
|
+
* hasMore when older ones exist. The older end is what gets dropped, and
|
|
389
|
+
* oldestReturned/newestReturned say exactly which window came back.
|
|
390
|
+
*/
|
|
391
|
+
const total = inWindow.length;
|
|
392
|
+
const history = total > limit ? inWindow.slice(total - limit) : inWindow;
|
|
335
393
|
// Best rank actually present in the returned window, so a caller can label a
|
|
336
394
|
// chart without recomputing. Null when the window is empty.
|
|
337
395
|
const best = history.reduce((acc, h) => {
|
|
@@ -344,9 +402,9 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
344
402
|
return successEnvelope({
|
|
345
403
|
pagination: {
|
|
346
404
|
limit,
|
|
347
|
-
offset:
|
|
348
|
-
total
|
|
349
|
-
hasMore:
|
|
405
|
+
offset: total - history.length,
|
|
406
|
+
total,
|
|
407
|
+
hasMore: total > history.length,
|
|
350
408
|
nextCursor: null,
|
|
351
409
|
prevCursor: null,
|
|
352
410
|
},
|
|
@@ -367,6 +425,13 @@ Parallel-safe: yes. Upstream cost: 1.`,
|
|
|
367
425
|
since: sinceRaw || null,
|
|
368
426
|
bestRankInWindow: best,
|
|
369
427
|
history,
|
|
428
|
+
// The bound is applied locally because the route ignores ?limit=; state
|
|
429
|
+
// it so a caller does not read a short series as a short career.
|
|
430
|
+
totalInWindow: total,
|
|
431
|
+
returned: history.length,
|
|
432
|
+
omittedOlder: total - history.length,
|
|
433
|
+
oldestReturned: history.length ? history[0].date : null,
|
|
434
|
+
newestReturned: history.length ? history[history.length - 1].date : null,
|
|
370
435
|
},
|
|
371
436
|
});
|
|
372
437
|
},
|
package/dist/tools/resolve.js
CHANGED
|
@@ -5,6 +5,13 @@ import { clampInt, decodeCursor, encodeCursor, extractRows, fetchJson, gameNotIn
|
|
|
5
5
|
import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
|
|
6
6
|
import { editionYear, entityRef, rankScore } from './normalize.js';
|
|
7
7
|
import { boolSchema, gameSchema, isPrimaryGame, limitSchema, parseGame, PRIMARY_GAMES, stringSchema, } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Minimum relevance a search result must reach to be returned when a query was
|
|
10
|
+
* given. rankScore returns 0 for "no part of the query appears anywhere in this
|
|
11
|
+
* row", so 1 means "must have matched something". See the floor note in
|
|
12
|
+
* searchEntities for the live evidence.
|
|
13
|
+
*/
|
|
14
|
+
export const RELEVANCE_FLOOR = 1;
|
|
8
15
|
/**
|
|
9
16
|
* Ranking order whose tiebreak never depends on the order upstream happened to
|
|
10
17
|
* return rows in.
|
|
@@ -37,7 +44,11 @@ const ORG_ALIASES = {
|
|
|
37
44
|
skt1: 't1',
|
|
38
45
|
};
|
|
39
46
|
function pushCandidates(out, game, type, rows, q, limit) {
|
|
47
|
+
// Position within the upstream's own result list. That ordering is a real
|
|
48
|
+
// signal we otherwise throw away.
|
|
49
|
+
let index = -1;
|
|
40
50
|
for (const row of rows) {
|
|
51
|
+
index += 1;
|
|
41
52
|
const ref = entityRef(row, type, game);
|
|
42
53
|
const r = asRecord(row) ?? {};
|
|
43
54
|
const nickname = pickString(r.nickname, asRecord(r.profile)?.nickname, ref.meta?.nickname);
|
|
@@ -49,9 +60,26 @@ function pushCandidates(out, game, type, rows, q, limit) {
|
|
|
49
60
|
if (score > 0 && Number.isFinite(currentRank) && currentRank > 0) {
|
|
50
61
|
score += currentRank <= 100 ? 8 : currentRank <= 1000 ? 4 : 2;
|
|
51
62
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
63
|
+
/**
|
|
64
|
+
* The upstream's FIRST row is allowed to match on something we cannot see.
|
|
65
|
+
*
|
|
66
|
+
* /tennis/players/search is a dedicated name search and it knows nicknames
|
|
67
|
+
* and aliases that the payload does not carry: `q=Nole` returns Novak
|
|
68
|
+
* Djokovic first, and nothing in the string "Novak Djokovic" resembles
|
|
69
|
+
* "nole", so rankScore scores it 0. The old code floored every unmatched row
|
|
70
|
+
* to 1 and then sorted, which let a mere substring collision win —
|
|
71
|
+
* `resolve_entity { q: "Nole" }` answered **M Canoles** (a WTA player whose
|
|
72
|
+
* name contains "noles") and search_entities dropped Djokovic entirely.
|
|
73
|
+
*
|
|
74
|
+
* So when our own scorer finds no name match at all but the dedicated search
|
|
75
|
+
* put the row first, the row is kept as the alias answer and labelled. Only
|
|
76
|
+
* the first row, and only when our scorer is silent, so this can never
|
|
77
|
+
* outrank a query that genuinely matches a name.
|
|
78
|
+
*/
|
|
79
|
+
let matchedBy;
|
|
80
|
+
if (q && score <= 0 && index === 0) {
|
|
81
|
+
score = 95;
|
|
82
|
+
matchedBy = 'upstream-first';
|
|
55
83
|
}
|
|
56
84
|
const secondary = {};
|
|
57
85
|
for (const key of ['lolPlayerId', 'codPlayerId', 'matchId', 'boutId', 'tournamentId', 'eventId']) {
|
|
@@ -61,6 +89,8 @@ function pushCandidates(out, game, type, rows, q, limit) {
|
|
|
61
89
|
}
|
|
62
90
|
if (nickname)
|
|
63
91
|
secondary.nickname = nickname;
|
|
92
|
+
if (matchedBy)
|
|
93
|
+
secondary.matchedBy = matchedBy;
|
|
64
94
|
out.push({
|
|
65
95
|
game,
|
|
66
96
|
type: pickString(r.entity_type, r.type, type) ?? type,
|
|
@@ -840,11 +870,69 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
|
840
870
|
seen.add(key);
|
|
841
871
|
return true;
|
|
842
872
|
});
|
|
873
|
+
/**
|
|
874
|
+
* Relevance floor.
|
|
875
|
+
*
|
|
876
|
+
* The upstream player/tournament search is a substring match with no floor
|
|
877
|
+
* of its own: `q=Sinner` returns ten rows, of which only "Jannik Sinner" and
|
|
878
|
+
* "Martin Sinner" have anything to do with the query. The tool scored them
|
|
879
|
+
* all and then SORTED rather than filtered, so "A Winner", "J Skinner",
|
|
880
|
+
* "E Skinner", "C Sinkler", "Dr Sinnett", "H C Skinner", "Mrs Skinner" and
|
|
881
|
+
* "J J Sinnott" came back as equals — an agent asking for one player got
|
|
882
|
+
* eight strangers to ignore, and a caller paging the results paid for them.
|
|
883
|
+
*
|
|
884
|
+
* rankScore already separates them cleanly (Jannik 100, Martin 48, every
|
|
885
|
+
* other row 0), so the floor is simply "must have matched something at all".
|
|
886
|
+
* It is applied only when a query was given: with no q this tool is a browse
|
|
887
|
+
* list, and a floor there would delete the catalogue.
|
|
888
|
+
*/
|
|
889
|
+
const scoreOf = (it) => typeof it.meta?.score === 'number'
|
|
890
|
+
? it.meta.score
|
|
891
|
+
: rankScore(q, it.name, it.id, it.slug, it.meta?.nickname);
|
|
892
|
+
const upstreamTotal = total;
|
|
893
|
+
let droppedBelowFloor = [];
|
|
894
|
+
/**
|
|
895
|
+
* The first row the upstream returned is exempt from the floor.
|
|
896
|
+
*
|
|
897
|
+
* The upstream's dedicated search knows aliases the payload does not carry:
|
|
898
|
+
* `q=Nole` returns Novak Djokovic first, and rankScore cannot see why, so a
|
|
899
|
+
* pure floor would delete the correct answer and keep substring collisions
|
|
900
|
+
* instead. Exactly one row is protected, and only when a query was given, so
|
|
901
|
+
* the floor still removes all the padding it was added for.
|
|
902
|
+
*/
|
|
903
|
+
const protectedRow = q && items.length > 0 ? items[0] : null;
|
|
904
|
+
const protectedKey = protectedRow ? `${protectedRow.game}:${protectedRow.type}:${protectedRow.id}` : null;
|
|
905
|
+
if (q) {
|
|
906
|
+
// Publish the score on every row, not just the ones the scored branch
|
|
907
|
+
// produced, so the floor is visible rather than mysterious.
|
|
908
|
+
items = items.map((it) => {
|
|
909
|
+
const key = `${it.game}:${it.type}:${it.id}`;
|
|
910
|
+
const isProtected = protectedKey !== null && key === protectedKey;
|
|
911
|
+
const raw = scoreOf(it);
|
|
912
|
+
return {
|
|
913
|
+
...it,
|
|
914
|
+
meta: {
|
|
915
|
+
...(it.meta ?? {}),
|
|
916
|
+
score: raw,
|
|
917
|
+
...(isProtected && raw < RELEVANCE_FLOOR ? { matchedBy: 'upstream-first' } : {}),
|
|
918
|
+
},
|
|
919
|
+
};
|
|
920
|
+
});
|
|
921
|
+
const keep = (it) => it.meta?.score >= RELEVANCE_FLOOR || it.meta?.matchedBy === 'upstream-first';
|
|
922
|
+
droppedBelowFloor = items
|
|
923
|
+
.filter((it) => !keep(it))
|
|
924
|
+
.map((it) => ({ id: it.id, name: it.name, score: it.meta?.score }));
|
|
925
|
+
if (droppedBelowFloor.length)
|
|
926
|
+
items = items.filter(keep);
|
|
927
|
+
}
|
|
928
|
+
// An explicit (possibly rank-0) alias match beats a higher-scoring substring
|
|
929
|
+
// collision: the upstream's own top hit is the answer to a query we cannot
|
|
930
|
+
// score. This is what makes "Nole" resolve to Djokovic instead of M Canoles.
|
|
843
931
|
if (q && !preserveUpstreamOrder) {
|
|
844
932
|
items.sort((a, b) => {
|
|
845
|
-
const
|
|
846
|
-
const
|
|
847
|
-
return
|
|
933
|
+
const am = a.meta?.matchedBy === 'upstream-first' ? 1 : 0;
|
|
934
|
+
const bm = b.meta?.matchedBy === 'upstream-first' ? 1 : 0;
|
|
935
|
+
return bm - am || scoreOf(b) - scoreOf(a) || a.name.localeCompare(b.name);
|
|
848
936
|
});
|
|
849
937
|
}
|
|
850
938
|
// pagination over ranked result set
|
|
@@ -852,21 +940,46 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
|
852
940
|
const hasMore = upstreamPaged
|
|
853
941
|
? (total != null ? offset + pageItems.length < total : pageItems.length >= limit)
|
|
854
942
|
: offset + pageItems.length < items.length;
|
|
943
|
+
const floorWarnings = [];
|
|
944
|
+
if (droppedBelowFloor.length) {
|
|
945
|
+
const sample = droppedBelowFloor
|
|
946
|
+
.slice(0, 5)
|
|
947
|
+
.map((d) => `${d.name} (${d.id})`)
|
|
948
|
+
.join(', ');
|
|
949
|
+
floorWarnings.push(`Dropped ${droppedBelowFloor.length} result(s) that do not match "${q}" at all — the upstream search is a ` +
|
|
950
|
+
`substring match with no relevance floor, so it pads every page: ${sample}. ` +
|
|
951
|
+
`If one of these is what you wanted, query it by name or id directly.`);
|
|
952
|
+
}
|
|
855
953
|
return successEnvelope({
|
|
856
954
|
source: 'search_entities',
|
|
857
955
|
game,
|
|
858
956
|
requestId,
|
|
859
957
|
tookMs: Date.now() - started,
|
|
860
958
|
upstreamCalls,
|
|
959
|
+
warnings: floorWarnings.length ? floorWarnings : undefined,
|
|
861
960
|
pagination: {
|
|
862
961
|
limit,
|
|
863
962
|
offset,
|
|
864
|
-
total:
|
|
963
|
+
total: q ? items.length : (total ?? null),
|
|
865
964
|
hasMore,
|
|
866
965
|
nextCursor: hasMore ? encodeCursor({ offset: offset + limit }) : null,
|
|
867
966
|
prevCursor: offset > 0 ? encodeCursor({ offset: Math.max(0, offset - limit) }) : null,
|
|
868
967
|
},
|
|
869
|
-
data: {
|
|
968
|
+
data: {
|
|
969
|
+
items: pageItems,
|
|
970
|
+
...(q
|
|
971
|
+
? {
|
|
972
|
+
relevance: {
|
|
973
|
+
query: q,
|
|
974
|
+
floor: RELEVANCE_FLOOR,
|
|
975
|
+
matched: items.length,
|
|
976
|
+
droppedBelowFloor: droppedBelowFloor.length,
|
|
977
|
+
dropped: droppedBelowFloor.slice(0, 25),
|
|
978
|
+
upstreamTotal: upstreamTotal ?? null,
|
|
979
|
+
},
|
|
980
|
+
}
|
|
981
|
+
: {}),
|
|
982
|
+
},
|
|
870
983
|
});
|
|
871
984
|
},
|
|
872
985
|
};
|
package/dist/tools/schedule.js
CHANGED
|
@@ -33,12 +33,26 @@ function normalizeScheduled(row) {
|
|
|
33
33
|
round: pickString(r.round) ?? null,
|
|
34
34
|
roundName: pickString(r.round_name) ?? null,
|
|
35
35
|
startsAt: pickString(r.starts_at, r.commence_time, r.match_date) ?? null,
|
|
36
|
+
/**
|
|
37
|
+
* Historical days report every match at T00:00:00 with
|
|
38
|
+
* `start_time_known: false` — the date is real, the clock is a placeholder.
|
|
39
|
+
* Emitting the midnight without this flag invites a caller to render "12:00
|
|
40
|
+
* AM" for a semi-final that finished at 3pm. Null when the route does not
|
|
41
|
+
* state it (the completed half does not).
|
|
42
|
+
*/
|
|
43
|
+
startTimeKnown: typeof r.start_time_known === 'boolean' ? r.start_time_known : null,
|
|
36
44
|
status: pickString(r.status, r.outcome) ?? null,
|
|
37
45
|
player1: { id: pickString(p1.id, p1.player_id) ?? null, name: pickString(p1.name) ?? null },
|
|
38
46
|
player2: { id: pickString(p2.id, p2.player_id) ?? null, name: pickString(p2.name) ?? null },
|
|
39
47
|
score: pickString(r.score, r.score_raw) ?? null,
|
|
40
48
|
};
|
|
41
49
|
}
|
|
50
|
+
/** A row is "played" when the feed says so, in either spelling it uses. */
|
|
51
|
+
function isPlayed(row) {
|
|
52
|
+
const r = asRecord(row) ?? {};
|
|
53
|
+
const s = (pickString(r.status, r.outcome) ?? '').toLowerCase();
|
|
54
|
+
return /complete|finished|ended|final|closed|done|official|retire|walkover|default/.test(s);
|
|
55
|
+
}
|
|
42
56
|
export const tennisSchedule = {
|
|
43
57
|
name: 'tennis_schedule',
|
|
44
58
|
description: `One day of tennis: the day's schedule and/or the matches completed that day, across ATP and WTA.
|
|
@@ -54,6 +68,12 @@ Tennis-only. 'date' defaults to today (UTC) and MUST be YYYY-MM-DD. Choose with
|
|
|
54
68
|
'include': both (default), scheduled, or completed. Both halves are fetched in
|
|
55
69
|
parallel, so either can fail on its own without losing the other.
|
|
56
70
|
|
|
71
|
+
'limit' bounds EACH half (the completed route ignores ?limit= upstream, so the
|
|
72
|
+
bound is applied here); scheduledCount/completedCount report how many rows the
|
|
73
|
+
day held and *Returned how many came back. The two halves overlap: the day's
|
|
74
|
+
list includes matches already played, so the same matchId can appear in both —
|
|
75
|
+
see the overlap block before de-duplicating or counting.
|
|
76
|
+
|
|
57
77
|
Parallel-safe: yes. Upstream cost: 1 or 2.`,
|
|
58
78
|
inputSchema: {
|
|
59
79
|
type: 'object',
|
|
@@ -63,7 +83,7 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
|
|
|
63
83
|
game: gameSchema({ allowAll: false, required: true }),
|
|
64
84
|
date: stringSchema('Day to fetch, YYYY-MM-DD. Defaults to today (UTC).', '2026-09-12'),
|
|
65
85
|
include: stringSchema('both (default) | scheduled | completed.', 'both'),
|
|
66
|
-
limit: limitSchema({ default: 20, max: 50, description: 'Rows per half (default 20, max 50).' }),
|
|
86
|
+
limit: limitSchema({ default: 20, max: 50, description: 'Rows per half, applied here (default 20, max 50). scheduledCount/completedCount still report the full day.' }),
|
|
67
87
|
},
|
|
68
88
|
},
|
|
69
89
|
handler: async (args, ctx) => {
|
|
@@ -121,27 +141,44 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
|
|
|
121
141
|
const limit = clampInt(args.limit, 20, 1, 50);
|
|
122
142
|
const wantScheduled = includeRaw === 'both' || includeRaw === 'scheduled';
|
|
123
143
|
const wantCompleted = includeRaw === 'both' || includeRaw === 'completed';
|
|
144
|
+
/**
|
|
145
|
+
* Ask for the whole day, then bound locally.
|
|
146
|
+
*
|
|
147
|
+
* /tennis/schedule honours ?limit= but /tennis/matches/completed ignores it,
|
|
148
|
+
* so passing `limit` upstream made `scheduledCount` mean "rows this call
|
|
149
|
+
* asked for" on one half and "rows the day held" on the other. Both halves
|
|
150
|
+
* are now fetched at the API's own page ceiling and sliced here, which makes
|
|
151
|
+
* scheduledCount/completedCount a fact about the day and *Returned a fact
|
|
152
|
+
* about the response.
|
|
153
|
+
*/
|
|
154
|
+
const upstreamPage = 200;
|
|
124
155
|
const settled = await Promise.allSettled([
|
|
125
156
|
wantScheduled
|
|
126
|
-
? fetchJson(ctx, '/tennis/schedule', { query: { date, limit } })
|
|
157
|
+
? fetchJson(ctx, '/tennis/schedule', { query: { date, limit: upstreamPage } })
|
|
127
158
|
: Promise.resolve(null),
|
|
128
159
|
wantCompleted
|
|
129
|
-
? fetchJson(ctx, '/tennis/matches/completed', { query: { date, limit } })
|
|
160
|
+
? fetchJson(ctx, '/tennis/matches/completed', { query: { date, limit: upstreamPage } })
|
|
130
161
|
: Promise.resolve(null),
|
|
131
162
|
]);
|
|
132
163
|
const rejected = [];
|
|
133
164
|
const rows = (value) => {
|
|
134
165
|
const root = asRecord(value);
|
|
135
166
|
const body = asRecord(root?.data) ?? root ?? {};
|
|
136
|
-
|
|
167
|
+
const items = Array.isArray(body.items) ? body.items : [];
|
|
168
|
+
return { items, total: typeof body.total === 'number' ? body.total : null };
|
|
137
169
|
};
|
|
138
170
|
let scheduled = [];
|
|
139
171
|
let completed = [];
|
|
172
|
+
let scheduledTotal = null;
|
|
173
|
+
let completedTotal = null;
|
|
140
174
|
const [schedRes, compRes] = settled;
|
|
141
175
|
if (schedRes.status === 'fulfilled' && schedRes.value) {
|
|
142
176
|
const v = schedRes.value;
|
|
143
|
-
if (v.ok)
|
|
144
|
-
|
|
177
|
+
if (v.ok) {
|
|
178
|
+
const parsed = rows(v.data);
|
|
179
|
+
scheduled = parsed.items;
|
|
180
|
+
scheduledTotal = parsed.total;
|
|
181
|
+
}
|
|
145
182
|
else
|
|
146
183
|
rejected.push({
|
|
147
184
|
section: 'schedule',
|
|
@@ -159,8 +196,11 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
|
|
|
159
196
|
}
|
|
160
197
|
if (compRes.status === 'fulfilled' && compRes.value) {
|
|
161
198
|
const v = compRes.value;
|
|
162
|
-
if (v.ok)
|
|
163
|
-
|
|
199
|
+
if (v.ok) {
|
|
200
|
+
const parsed = rows(v.data);
|
|
201
|
+
completed = parsed.items;
|
|
202
|
+
completedTotal = parsed.total;
|
|
203
|
+
}
|
|
164
204
|
else
|
|
165
205
|
rejected.push({
|
|
166
206
|
section: 'completed',
|
|
@@ -190,15 +230,64 @@ Parallel-safe: yes. Upstream cost: 1 or 2.`,
|
|
|
190
230
|
recover: ['Check the date is a real calendar day', 'Retry, or widen include to both'],
|
|
191
231
|
});
|
|
192
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* /tennis/matches/completed IGNORES ?limit= — verified: `limit=3` returned
|
|
235
|
+
* all 13 rows of the day with page_size still 50. /tennis/schedule does
|
|
236
|
+
* honour it, but both halves are bounded here so one call site cannot drift
|
|
237
|
+
* from the other and `completedCount` stops being a number the caller asked
|
|
238
|
+
* to be smaller.
|
|
239
|
+
*/
|
|
240
|
+
const scheduledWindow = scheduled.slice(0, limit);
|
|
241
|
+
const completedWindow = completed.slice(0, limit);
|
|
242
|
+
/**
|
|
243
|
+
* /tennis/schedule is the DAY's list, not an "upcoming" list: it carries
|
|
244
|
+
* matches that have already finished, with status COMPLETED. So the two
|
|
245
|
+
* halves of this response legitimately overlap, and the old key name
|
|
246
|
+
* ("scheduled") plus a status of COMPLETED inside it read as a
|
|
247
|
+
* contradiction. The overlap is now counted and the statuses summarised, so
|
|
248
|
+
* a caller can join or de-duplicate deliberately instead of discovering it.
|
|
249
|
+
*/
|
|
250
|
+
const completedBySchedule = scheduled.filter(isPlayed);
|
|
251
|
+
const completedIds = new Set(completed
|
|
252
|
+
.map((row) => pickString(asRecord(row)?.id, asRecord(row)?.match_id))
|
|
253
|
+
.filter((id) => Boolean(id)));
|
|
254
|
+
const overlapIds = [
|
|
255
|
+
...new Set(completedBySchedule
|
|
256
|
+
.map((row) => pickString(asRecord(row)?.id, asRecord(row)?.match_id))
|
|
257
|
+
.filter((id) => typeof id === 'string' && completedIds.has(id))),
|
|
258
|
+
];
|
|
259
|
+
const statusCounts = {};
|
|
260
|
+
for (const row of scheduled) {
|
|
261
|
+
const key = (pickString(asRecord(row)?.status, asRecord(row)?.outcome) ?? 'UNKNOWN').toUpperCase();
|
|
262
|
+
statusCounts[key] = (statusCounts[key] ?? 0) + 1;
|
|
263
|
+
}
|
|
264
|
+
const anyUnknownClock = scheduled.some((row) => asRecord(row)?.start_time_known === false);
|
|
193
265
|
const data = {
|
|
194
266
|
title: `Tennis — ${date}`,
|
|
195
267
|
date,
|
|
196
268
|
include: includeRaw,
|
|
197
|
-
|
|
198
|
-
scheduledCount
|
|
199
|
-
|
|
200
|
-
|
|
269
|
+
// Everything the day's list returned, then the bounded window. Both are
|
|
270
|
+
// present so `scheduledCount` never silently means two different things.
|
|
271
|
+
scheduled: scheduledWindow.map(normalizeScheduled),
|
|
272
|
+
scheduledCount: scheduledTotal ?? scheduled.length,
|
|
273
|
+
scheduledReturned: scheduledWindow.length,
|
|
274
|
+
completed: completedWindow.map(normalizeScheduled),
|
|
275
|
+
completedCount: completedTotal ?? completed.length,
|
|
276
|
+
completedReturned: completedWindow.length,
|
|
277
|
+
statusBreakdown: statusCounts,
|
|
278
|
+
overlap: {
|
|
279
|
+
count: overlapIds.length,
|
|
280
|
+
matchIds: overlapIds.slice(0, 50),
|
|
281
|
+
note: overlapIds.length
|
|
282
|
+
? 'These matches appear in BOTH halves: /tennis/schedule is the day\'s full list (played matches included) and /tennis/matches/completed is the played subset. Join on matchId and de-duplicate.'
|
|
283
|
+
: 'The two halves returned no shared matchId on this date.',
|
|
284
|
+
},
|
|
201
285
|
timezoneNote: 'The API groups by UTC date. For a local "today", pass the date explicitly rather than relying on the default.',
|
|
286
|
+
...(anyUnknownClock
|
|
287
|
+
? {
|
|
288
|
+
startTimeNote: 'Rows with startTimeKnown:false carry a date-only startsAt (T00:00:00) — the clock is a placeholder, not a midnight start. The completed half does not report the flag at all.',
|
|
289
|
+
}
|
|
290
|
+
: {}),
|
|
202
291
|
};
|
|
203
292
|
const upstreamCalls = (wantScheduled ? 1 : 0) + (wantCompleted ? 1 : 0);
|
|
204
293
|
// A half that failed is reported alongside the half that worked rather than
|
package/dist/tools/standings.js
CHANGED
|
@@ -126,6 +126,11 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
|
|
|
126
126
|
stage: stringSchema('Stage key (COD / LoL).'),
|
|
127
127
|
division: stringSchema('UFC division key.'),
|
|
128
128
|
limit: limitSchema({ default: 50, max: 100 }),
|
|
129
|
+
page: {
|
|
130
|
+
type: 'integer',
|
|
131
|
+
minimum: 1,
|
|
132
|
+
description: 'Tennis only: page over the full published ranking list. With limit=5 the ATP list is 30 pages (150 ranked players); the response reports total/rankedPlayers/totalPages/hasMore.',
|
|
133
|
+
},
|
|
129
134
|
cursor: stringSchema('Opaque cursor from pagination.nextCursor. UFC world scope spans every division; page with it rather than raising limit.'),
|
|
130
135
|
},
|
|
131
136
|
},
|
|
@@ -155,6 +160,10 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
|
|
|
155
160
|
const season = typeof args.season === 'string' ? args.season : undefined;
|
|
156
161
|
const stage = typeof args.stage === 'string' ? args.stage : undefined;
|
|
157
162
|
const division = typeof args.division === 'string' ? args.division : undefined;
|
|
163
|
+
// Tennis rankings are pageable now that the API reports the real list size.
|
|
164
|
+
const tennisPage = typeof args.page === 'number' && Number.isFinite(args.page) && args.page >= 1
|
|
165
|
+
? Math.trunc(args.page)
|
|
166
|
+
: 1;
|
|
158
167
|
let path = '';
|
|
159
168
|
let query = {};
|
|
160
169
|
let title = `${game} standings`;
|
|
@@ -247,7 +256,7 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
|
|
|
247
256
|
// rank_movement/previous_rank on every row. Pass tour via division.
|
|
248
257
|
const tour = String(division ?? 'ATP').toUpperCase() === 'WTA' ? 'WTA' : 'ATP';
|
|
249
258
|
path = '/tennis/standings';
|
|
250
|
-
query = { tour, top_n: Math.min(limit, 100) };
|
|
259
|
+
query = { tour, top_n: Math.min(limit, 100), page: tennisPage };
|
|
251
260
|
effectiveScope = 'world';
|
|
252
261
|
title = `${tour} singles rankings`;
|
|
253
262
|
}
|
|
@@ -355,16 +364,36 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
|
|
|
355
364
|
return rest;
|
|
356
365
|
});
|
|
357
366
|
}
|
|
367
|
+
const obj = asRecord(res.data);
|
|
368
|
+
const upstreamMeta = asRecord(obj?.meta) ?? {};
|
|
369
|
+
/**
|
|
370
|
+
* `total` is the size of the published table, NOT the size of this page.
|
|
371
|
+
*
|
|
372
|
+
* This read `total: allRows.length` — the rows that happened to arrive — so
|
|
373
|
+
* a tennis table of 150 ranked players reported total:5, hasMore:false and
|
|
374
|
+
* looked complete and unpaginated. The API now returns the real figure
|
|
375
|
+
* (total/ranked_players/total_pages/has_next); this prefers it and only
|
|
376
|
+
* falls back to the received count when the route does not state one.
|
|
377
|
+
*
|
|
378
|
+
* These are read BEFORE the pagination block, because that block had the
|
|
379
|
+
* identical bug: it published total: allRows.length too, so fixing only the
|
|
380
|
+
* data block left pagination.total disagreeing with data.total on the same
|
|
381
|
+
* response. Two counts, one response, only one of them true.
|
|
382
|
+
*/
|
|
383
|
+
const upstreamTotal = typeof obj?.total === 'number' ? obj.total : null;
|
|
384
|
+
const rankedPlayers = typeof obj?.ranked_players === 'number' ? obj.ranked_players : null;
|
|
385
|
+
const totalPages = typeof obj?.total_pages === 'number' ? obj.total_pages : null;
|
|
386
|
+
const upstreamHasNext = typeof obj?.has_next === 'boolean' ? obj.has_next : null;
|
|
387
|
+
const effectiveTotal = upstreamTotal ?? allRows.length;
|
|
388
|
+
const effectiveHasMore = upstreamHasNext ?? offset + rows.length < effectiveTotal;
|
|
358
389
|
const standingsPagination = {
|
|
359
390
|
limit,
|
|
360
391
|
offset,
|
|
361
|
-
total:
|
|
362
|
-
hasMore:
|
|
363
|
-
nextCursor:
|
|
392
|
+
total: effectiveTotal,
|
|
393
|
+
hasMore: effectiveHasMore,
|
|
394
|
+
nextCursor: effectiveHasMore ? String(offset + rows.length) : null,
|
|
364
395
|
prevCursor: offset > 0 ? String(Math.max(0, offset - limit)) : null,
|
|
365
396
|
};
|
|
366
|
-
const obj = asRecord(res.data);
|
|
367
|
-
const upstreamMeta = asRecord(obj?.meta) ?? {};
|
|
368
397
|
const dataQuality = asRecord(upstreamMeta.dataQuality) ?? {};
|
|
369
398
|
const warnings = [];
|
|
370
399
|
const pushWarning = (value) => {
|
|
@@ -405,10 +434,34 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
|
|
|
405
434
|
season: season ?? null,
|
|
406
435
|
stage: stage ?? null,
|
|
407
436
|
rows,
|
|
408
|
-
total:
|
|
409
|
-
hasMore:
|
|
410
|
-
|
|
411
|
-
|
|
437
|
+
total: effectiveTotal,
|
|
438
|
+
hasMore: effectiveHasMore,
|
|
439
|
+
...(game === 'tennis'
|
|
440
|
+
? {
|
|
441
|
+
// How deep the published list is, independent of this page.
|
|
442
|
+
rankedPlayers: rankedPlayers ?? effectiveTotal,
|
|
443
|
+
totalPages,
|
|
444
|
+
page: tennisPage,
|
|
445
|
+
}
|
|
446
|
+
: {}),
|
|
447
|
+
...(game === 'tennis'
|
|
448
|
+
? {
|
|
449
|
+
/**
|
|
450
|
+
* Tennis rankings are a dated weekly SNAPSHOT, and the route names
|
|
451
|
+
* the date in `ranking_date`. updatedAt was read only from
|
|
452
|
+
* updatedAt/lastUpdated/meta.syncedAt/meta.fetchedAt, none of which
|
|
453
|
+
* the tennis route emits, so every tennis table claimed
|
|
454
|
+
* updatedAt:null while the very date a caller needs sat in the
|
|
455
|
+
* payload — and a consumer showing a stale-looking table had no
|
|
456
|
+
* way to find out it was a week old.
|
|
457
|
+
*/
|
|
458
|
+
rankingDate: pickString(obj?.ranking_date) ?? null,
|
|
459
|
+
updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated, upstreamMeta.syncedAt, upstreamMeta.fetchedAt, obj?.ranking_date) ?? null,
|
|
460
|
+
}
|
|
461
|
+
: {
|
|
462
|
+
updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated, upstreamMeta.syncedAt, upstreamMeta.fetchedAt) ??
|
|
463
|
+
null,
|
|
464
|
+
}),
|
|
412
465
|
...(dataFreshness ? { dataFreshness } : {}),
|
|
413
466
|
...(status ? { status } : {}),
|
|
414
467
|
...(upstreamMeta.warning || dataQuality.warning
|