cito-mcp 0.3.21 → 0.4.1
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 +42 -2
- package/dist/tools/index.js +8 -0
- package/dist/tools/insight.js +95 -4
- package/dist/tools/match.js +75 -5
- package/dist/tools/meta.js +62 -2
- package/dist/tools/normalize.js +221 -15
- package/dist/tools/odds.js +295 -0
- package/dist/tools/player.js +423 -3
- package/dist/tools/rankings.js +214 -3
- package/dist/tools/resolve.js +67 -7
- package/dist/tools/schedule.js +323 -0
- package/dist/tools/standings.js +18 -2
- package/dist/tools/tournaments.js +319 -0
- package/package.json +2 -2
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.
|
|
@@ -840,33 +847,86 @@ Example: { "game": "ufc", "q": "Jon Jones", "type": "fighter", "limit": 10 }`,
|
|
|
840
847
|
seen.add(key);
|
|
841
848
|
return true;
|
|
842
849
|
});
|
|
850
|
+
/**
|
|
851
|
+
* Relevance floor.
|
|
852
|
+
*
|
|
853
|
+
* The upstream player/tournament search is a substring match with no floor
|
|
854
|
+
* of its own: `q=Sinner` returns ten rows, of which only "Jannik Sinner" and
|
|
855
|
+
* "Martin Sinner" have anything to do with the query. The tool scored them
|
|
856
|
+
* all and then SORTED rather than filtered, so "A Winner", "J Skinner",
|
|
857
|
+
* "E Skinner", "C Sinkler", "Dr Sinnett", "H C Skinner", "Mrs Skinner" and
|
|
858
|
+
* "J J Sinnott" came back as equals — an agent asking for one player got
|
|
859
|
+
* eight strangers to ignore, and a caller paging the results paid for them.
|
|
860
|
+
*
|
|
861
|
+
* rankScore already separates them cleanly (Jannik 100, Martin 48, every
|
|
862
|
+
* other row 0), so the floor is simply "must have matched something at all".
|
|
863
|
+
* It is applied only when a query was given: with no q this tool is a browse
|
|
864
|
+
* list, and a floor there would delete the catalogue.
|
|
865
|
+
*/
|
|
866
|
+
const scoreOf = (it) => typeof it.meta?.score === 'number'
|
|
867
|
+
? it.meta.score
|
|
868
|
+
: rankScore(q, it.name, it.id, it.slug, it.meta?.nickname);
|
|
869
|
+
const upstreamTotal = total;
|
|
870
|
+
let droppedBelowFloor = [];
|
|
871
|
+
if (q) {
|
|
872
|
+
// Publish the score on every row, not just the ones the scored branch
|
|
873
|
+
// produced, so the floor is visible rather than mysterious.
|
|
874
|
+
items = items.map((it) => ({ ...it, meta: { ...(it.meta ?? {}), score: scoreOf(it) } }));
|
|
875
|
+
droppedBelowFloor = items
|
|
876
|
+
.filter((it) => it.meta?.score < RELEVANCE_FLOOR)
|
|
877
|
+
.map((it) => ({ id: it.id, name: it.name, score: it.meta?.score }));
|
|
878
|
+
if (droppedBelowFloor.length) {
|
|
879
|
+
items = items.filter((it) => it.meta?.score >= RELEVANCE_FLOOR);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
843
882
|
if (q && !preserveUpstreamOrder) {
|
|
844
|
-
items.sort((a, b) =>
|
|
845
|
-
const sa = typeof a.meta?.score === 'number' ? a.meta.score : rankScore(q, a.name, a.id, a.slug, a.meta?.nickname);
|
|
846
|
-
const sb = typeof b.meta?.score === 'number' ? b.meta.score : rankScore(q, b.name, b.id, b.slug, b.meta?.nickname);
|
|
847
|
-
return sb - sa || a.name.localeCompare(b.name);
|
|
848
|
-
});
|
|
883
|
+
items.sort((a, b) => scoreOf(b) - scoreOf(a) || a.name.localeCompare(b.name));
|
|
849
884
|
}
|
|
850
885
|
// pagination over ranked result set
|
|
851
886
|
const pageItems = upstreamPaged ? items.slice(0, limit) : items.slice(offset, offset + limit);
|
|
852
887
|
const hasMore = upstreamPaged
|
|
853
888
|
? (total != null ? offset + pageItems.length < total : pageItems.length >= limit)
|
|
854
889
|
: offset + pageItems.length < items.length;
|
|
890
|
+
const floorWarnings = [];
|
|
891
|
+
if (droppedBelowFloor.length) {
|
|
892
|
+
const sample = droppedBelowFloor
|
|
893
|
+
.slice(0, 5)
|
|
894
|
+
.map((d) => `${d.name} (${d.id})`)
|
|
895
|
+
.join(', ');
|
|
896
|
+
floorWarnings.push(`Dropped ${droppedBelowFloor.length} result(s) that do not match "${q}" at all — the upstream search is a ` +
|
|
897
|
+
`substring match with no relevance floor, so it pads every page: ${sample}. ` +
|
|
898
|
+
`If one of these is what you wanted, query it by name or id directly.`);
|
|
899
|
+
}
|
|
855
900
|
return successEnvelope({
|
|
856
901
|
source: 'search_entities',
|
|
857
902
|
game,
|
|
858
903
|
requestId,
|
|
859
904
|
tookMs: Date.now() - started,
|
|
860
905
|
upstreamCalls,
|
|
906
|
+
warnings: floorWarnings.length ? floorWarnings : undefined,
|
|
861
907
|
pagination: {
|
|
862
908
|
limit,
|
|
863
909
|
offset,
|
|
864
|
-
total:
|
|
910
|
+
total: q ? items.length : (total ?? null),
|
|
865
911
|
hasMore,
|
|
866
912
|
nextCursor: hasMore ? encodeCursor({ offset: offset + limit }) : null,
|
|
867
913
|
prevCursor: offset > 0 ? encodeCursor({ offset: Math.max(0, offset - limit) }) : null,
|
|
868
914
|
},
|
|
869
|
-
data: {
|
|
915
|
+
data: {
|
|
916
|
+
items: pageItems,
|
|
917
|
+
...(q
|
|
918
|
+
? {
|
|
919
|
+
relevance: {
|
|
920
|
+
query: q,
|
|
921
|
+
floor: RELEVANCE_FLOOR,
|
|
922
|
+
matched: items.length,
|
|
923
|
+
droppedBelowFloor: droppedBelowFloor.length,
|
|
924
|
+
dropped: droppedBelowFloor.slice(0, 25),
|
|
925
|
+
upstreamTotal: upstreamTotal ?? null,
|
|
926
|
+
},
|
|
927
|
+
}
|
|
928
|
+
: {}),
|
|
929
|
+
},
|
|
870
930
|
});
|
|
871
931
|
},
|
|
872
932
|
};
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tennis_schedule — one day of tennis, scheduled and completed (TENNIS-26).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* /tennis/schedule and /tennis/matches/completed are the two routes that require
|
|
6
|
+
* ?date=. They were the exact pair producing "route is broken" support tickets
|
|
7
|
+
* from callers who omitted it, which is why the REST layer now returns a hint
|
|
8
|
+
* with a working example URL. But neither route had a tool either — so an agent
|
|
9
|
+
* driving the MCP had no way to ask "what tennis is on today?" at all.
|
|
10
|
+
*
|
|
11
|
+
* This tool owns the required date, defaults it to today, validates the format
|
|
12
|
+
* before spending an upstream call, and on a bad date returns the same
|
|
13
|
+
* paste-able example the REST hint does.
|
|
14
|
+
*/
|
|
15
|
+
import { asRecord, clampInt, fetchJson, pickString, } from '../client.js';
|
|
16
|
+
import { errorEnvelope, mapHttpToCode, newRequestId, partialFromRejection, successEnvelope, } from '../envelope.js';
|
|
17
|
+
import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
|
|
18
|
+
/** ISO date, no time component, already validated by the caller. */
|
|
19
|
+
function todayIso() {
|
|
20
|
+
return new Date().toISOString().slice(0, 10);
|
|
21
|
+
}
|
|
22
|
+
function normalizeScheduled(row) {
|
|
23
|
+
const r = asRecord(row) ?? {};
|
|
24
|
+
const p1 = asRecord(r.player1) ?? asRecord(r.home) ?? {};
|
|
25
|
+
const p2 = asRecord(r.player2) ?? asRecord(r.away) ?? {};
|
|
26
|
+
return {
|
|
27
|
+
matchId: pickString(r.id, r.match_id) ?? null,
|
|
28
|
+
tournamentId: pickString(r.tournament_id) ?? null,
|
|
29
|
+
tournamentName: pickString(r.tournament_name, r.tournament) ?? null,
|
|
30
|
+
tour: pickString(r.tour) ?? null,
|
|
31
|
+
level: pickString(r.level, r.tier) ?? null,
|
|
32
|
+
surface: pickString(r.surface) ?? null,
|
|
33
|
+
round: pickString(r.round) ?? null,
|
|
34
|
+
roundName: pickString(r.round_name) ?? null,
|
|
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,
|
|
44
|
+
status: pickString(r.status, r.outcome) ?? null,
|
|
45
|
+
player1: { id: pickString(p1.id, p1.player_id) ?? null, name: pickString(p1.name) ?? null },
|
|
46
|
+
player2: { id: pickString(p2.id, p2.player_id) ?? null, name: pickString(p2.name) ?? null },
|
|
47
|
+
score: pickString(r.score, r.score_raw) ?? null,
|
|
48
|
+
};
|
|
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
|
+
}
|
|
56
|
+
export const tennisSchedule = {
|
|
57
|
+
name: 'tennis_schedule',
|
|
58
|
+
description: `One day of tennis: the day's schedule and/or the matches completed that day, across ATP and WTA.
|
|
59
|
+
|
|
60
|
+
When to use:
|
|
61
|
+
- "What tennis is on today?"; a specific day's fixtures; results completed on a given date; building a daily digest.
|
|
62
|
+
|
|
63
|
+
Prefer over: call_api for /tennis/schedule and /tennis/matches/completed (both require ?date=, which this tool supplies and validates); live_matches (in-progress only, right now); upcoming_schedule (a forward window, not a specific day).
|
|
64
|
+
|
|
65
|
+
Do not use when: matches currently in play → live_matches; a named player's history → player_matches.
|
|
66
|
+
|
|
67
|
+
Tennis-only. 'date' defaults to today (UTC) and MUST be YYYY-MM-DD. Choose with
|
|
68
|
+
'include': both (default), scheduled, or completed. Both halves are fetched in
|
|
69
|
+
parallel, so either can fail on its own without losing the other.
|
|
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
|
+
|
|
77
|
+
Parallel-safe: yes. Upstream cost: 1 or 2.`,
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
additionalProperties: false,
|
|
81
|
+
required: ['game'],
|
|
82
|
+
properties: {
|
|
83
|
+
game: gameSchema({ allowAll: false, required: true }),
|
|
84
|
+
date: stringSchema('Day to fetch, YYYY-MM-DD. Defaults to today (UTC).', '2026-09-12'),
|
|
85
|
+
include: stringSchema('both (default) | scheduled | completed.', 'both'),
|
|
86
|
+
limit: limitSchema({ default: 20, max: 50, description: 'Rows per half, applied here (default 20, max 50). scheduledCount/completedCount still report the full day.' }),
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
handler: async (args, ctx) => {
|
|
90
|
+
const started = Date.now();
|
|
91
|
+
const requestId = newRequestId();
|
|
92
|
+
const gameParse = parseGame(args.game, { allowAll: false, required: true });
|
|
93
|
+
if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
|
|
94
|
+
return errorEnvelope({
|
|
95
|
+
code: gameParse.error?.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
|
|
96
|
+
message: gameParse.error ?? 'game is required',
|
|
97
|
+
game: null,
|
|
98
|
+
source: 'tennis_schedule',
|
|
99
|
+
requestId,
|
|
100
|
+
tookMs: Date.now() - started,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const game = gameParse.game;
|
|
104
|
+
if (game !== 'tennis') {
|
|
105
|
+
return errorEnvelope({
|
|
106
|
+
code: 'NOT_IMPLEMENTED',
|
|
107
|
+
message: `tennis_schedule is tennis-only (game '${game}' has its own schedule tool)`,
|
|
108
|
+
game,
|
|
109
|
+
source: 'tennis_schedule',
|
|
110
|
+
requestId,
|
|
111
|
+
tookMs: Date.now() - started,
|
|
112
|
+
recover: ['Use upcoming_schedule for this game', 'Retry with game tennis'],
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const rawDate = typeof args.date === 'string' ? args.date.trim() : '';
|
|
116
|
+
const date = rawDate || todayIso();
|
|
117
|
+
// Validate before spending an upstream call, and echo the same paste-able
|
|
118
|
+
// example the REST layer returns so the two surfaces teach identically.
|
|
119
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
120
|
+
return errorEnvelope({
|
|
121
|
+
code: 'VALIDATION',
|
|
122
|
+
message: `date must be YYYY-MM-DD (got '${rawDate}')`,
|
|
123
|
+
game,
|
|
124
|
+
source: 'tennis_schedule',
|
|
125
|
+
requestId,
|
|
126
|
+
tookMs: Date.now() - started,
|
|
127
|
+
recover: [`Try date=${todayIso()}`, 'This route requires a date; omit it to default to today'],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const includeRaw = typeof args.include === 'string' ? args.include.trim().toLowerCase() : 'both';
|
|
131
|
+
if (!['both', 'scheduled', 'completed'].includes(includeRaw)) {
|
|
132
|
+
return errorEnvelope({
|
|
133
|
+
code: 'VALIDATION',
|
|
134
|
+
message: `include must be both, scheduled, or completed (got '${args.include}')`,
|
|
135
|
+
game,
|
|
136
|
+
source: 'tennis_schedule',
|
|
137
|
+
requestId,
|
|
138
|
+
tookMs: Date.now() - started,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const limit = clampInt(args.limit, 20, 1, 50);
|
|
142
|
+
const wantScheduled = includeRaw === 'both' || includeRaw === 'scheduled';
|
|
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;
|
|
155
|
+
const settled = await Promise.allSettled([
|
|
156
|
+
wantScheduled
|
|
157
|
+
? fetchJson(ctx, '/tennis/schedule', { query: { date, limit: upstreamPage } })
|
|
158
|
+
: Promise.resolve(null),
|
|
159
|
+
wantCompleted
|
|
160
|
+
? fetchJson(ctx, '/tennis/matches/completed', { query: { date, limit: upstreamPage } })
|
|
161
|
+
: Promise.resolve(null),
|
|
162
|
+
]);
|
|
163
|
+
const rejected = [];
|
|
164
|
+
const rows = (value) => {
|
|
165
|
+
const root = asRecord(value);
|
|
166
|
+
const body = asRecord(root?.data) ?? root ?? {};
|
|
167
|
+
const items = Array.isArray(body.items) ? body.items : [];
|
|
168
|
+
return { items, total: typeof body.total === 'number' ? body.total : null };
|
|
169
|
+
};
|
|
170
|
+
let scheduled = [];
|
|
171
|
+
let completed = [];
|
|
172
|
+
let scheduledTotal = null;
|
|
173
|
+
let completedTotal = null;
|
|
174
|
+
const [schedRes, compRes] = settled;
|
|
175
|
+
if (schedRes.status === 'fulfilled' && schedRes.value) {
|
|
176
|
+
const v = schedRes.value;
|
|
177
|
+
if (v.ok) {
|
|
178
|
+
const parsed = rows(v.data);
|
|
179
|
+
scheduled = parsed.items;
|
|
180
|
+
scheduledTotal = parsed.total;
|
|
181
|
+
}
|
|
182
|
+
else
|
|
183
|
+
rejected.push({
|
|
184
|
+
section: 'schedule',
|
|
185
|
+
code: mapHttpToCode(v.status),
|
|
186
|
+
message: `schedule HTTP ${v.status}`,
|
|
187
|
+
httpStatus: v.status,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
else if (schedRes.status === 'rejected') {
|
|
191
|
+
rejected.push({
|
|
192
|
+
section: 'schedule',
|
|
193
|
+
code: 'UPSTREAM',
|
|
194
|
+
message: String(schedRes.reason).slice(0, 200),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (compRes.status === 'fulfilled' && compRes.value) {
|
|
198
|
+
const v = compRes.value;
|
|
199
|
+
if (v.ok) {
|
|
200
|
+
const parsed = rows(v.data);
|
|
201
|
+
completed = parsed.items;
|
|
202
|
+
completedTotal = parsed.total;
|
|
203
|
+
}
|
|
204
|
+
else
|
|
205
|
+
rejected.push({
|
|
206
|
+
section: 'completed',
|
|
207
|
+
code: mapHttpToCode(v.status),
|
|
208
|
+
message: `completed HTTP ${v.status}`,
|
|
209
|
+
httpStatus: v.status,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
else if (compRes.status === 'rejected') {
|
|
213
|
+
rejected.push({
|
|
214
|
+
section: 'completed',
|
|
215
|
+
code: 'UPSTREAM',
|
|
216
|
+
message: String(compRes.reason).slice(0, 200),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
// Both halves failed -> a real error, not a partial.
|
|
220
|
+
if (rejected.length > 0 && scheduled.length === 0 && completed.length === 0) {
|
|
221
|
+
const first = rejected[0];
|
|
222
|
+
return errorEnvelope({
|
|
223
|
+
code: first.code,
|
|
224
|
+
message: `Tennis schedule failed for ${date}: ${rejected.map((r) => r.message).join('; ')}`,
|
|
225
|
+
game,
|
|
226
|
+
source: 'tennis_schedule',
|
|
227
|
+
requestId,
|
|
228
|
+
tookMs: Date.now() - started,
|
|
229
|
+
upstreamCalls: rejected.length,
|
|
230
|
+
recover: ['Check the date is a real calendar day', 'Retry, or widen include to both'],
|
|
231
|
+
});
|
|
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);
|
|
265
|
+
const data = {
|
|
266
|
+
title: `Tennis — ${date}`,
|
|
267
|
+
date,
|
|
268
|
+
include: includeRaw,
|
|
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
|
+
},
|
|
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
|
+
: {}),
|
|
291
|
+
};
|
|
292
|
+
const upstreamCalls = (wantScheduled ? 1 : 0) + (wantCompleted ? 1 : 0);
|
|
293
|
+
// A half that failed is reported alongside the half that worked rather than
|
|
294
|
+
// being silently dropped: an empty scheduled[] means two different things.
|
|
295
|
+
if (rejected.length > 0) {
|
|
296
|
+
return successEnvelope({
|
|
297
|
+
source: 'tennis_schedule',
|
|
298
|
+
game,
|
|
299
|
+
requestId,
|
|
300
|
+
tookMs: Date.now() - started,
|
|
301
|
+
upstreamCalls,
|
|
302
|
+
data,
|
|
303
|
+
partial: rejected.map((r) => partialFromRejection(r.section, {
|
|
304
|
+
code: r.code,
|
|
305
|
+
message: r.message,
|
|
306
|
+
...(r.httpStatus !== undefined ? { httpStatus: r.httpStatus } : {}),
|
|
307
|
+
})),
|
|
308
|
+
warnings: [
|
|
309
|
+
`${rejected.length} of ${upstreamCalls} schedule section(s) failed; the rest of the day is still returned.`,
|
|
310
|
+
],
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return successEnvelope({
|
|
314
|
+
source: 'tennis_schedule',
|
|
315
|
+
game,
|
|
316
|
+
requestId,
|
|
317
|
+
tookMs: Date.now() - started,
|
|
318
|
+
upstreamCalls,
|
|
319
|
+
data,
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
export const scheduleTools = [tennisSchedule];
|
package/dist/tools/standings.js
CHANGED
|
@@ -407,8 +407,24 @@ Example: { "game": "cod", "season": "2026", "limit": 50 }`,
|
|
|
407
407
|
rows,
|
|
408
408
|
total: allRows.length,
|
|
409
409
|
hasMore: allRows.length > rows.length,
|
|
410
|
-
|
|
411
|
-
|
|
410
|
+
...(game === 'tennis'
|
|
411
|
+
? {
|
|
412
|
+
/**
|
|
413
|
+
* Tennis rankings are a dated weekly SNAPSHOT, and the route names
|
|
414
|
+
* the date in `ranking_date`. updatedAt was read only from
|
|
415
|
+
* updatedAt/lastUpdated/meta.syncedAt/meta.fetchedAt, none of which
|
|
416
|
+
* the tennis route emits, so every tennis table claimed
|
|
417
|
+
* updatedAt:null while the very date a caller needs sat in the
|
|
418
|
+
* payload — and a consumer showing a stale-looking table had no
|
|
419
|
+
* way to find out it was a week old.
|
|
420
|
+
*/
|
|
421
|
+
rankingDate: pickString(obj?.ranking_date) ?? null,
|
|
422
|
+
updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated, upstreamMeta.syncedAt, upstreamMeta.fetchedAt, obj?.ranking_date) ?? null,
|
|
423
|
+
}
|
|
424
|
+
: {
|
|
425
|
+
updatedAt: pickString(obj?.updatedAt, obj?.lastUpdated, upstreamMeta.syncedAt, upstreamMeta.fetchedAt) ??
|
|
426
|
+
null,
|
|
427
|
+
}),
|
|
412
428
|
...(dataFreshness ? { dataFreshness } : {}),
|
|
413
429
|
...(status ? { status } : {}),
|
|
414
430
|
...(upstreamMeta.warning || dataQuality.warning
|