fitzroy 1.1.1 → 1.2.0

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/dist/cli.js CHANGED
@@ -1301,6 +1301,27 @@ var init_coaches_votes = __esm({
1301
1301
  }
1302
1302
  });
1303
1303
 
1304
+ // src/lib/concurrency.ts
1305
+ async function batchedMap(items, fn, options) {
1306
+ const batchSize = options?.batchSize ?? 5;
1307
+ const delayMs = options?.delayMs ?? 0;
1308
+ const results = [];
1309
+ for (let i = 0; i < items.length; i += batchSize) {
1310
+ const batch = items.slice(i, i + batchSize);
1311
+ const batchResults = await Promise.all(batch.map(fn));
1312
+ results.push(...batchResults);
1313
+ if (delayMs > 0 && i + batchSize < items.length) {
1314
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1315
+ }
1316
+ }
1317
+ return results;
1318
+ }
1319
+ var init_concurrency = __esm({
1320
+ "src/lib/concurrency.ts"() {
1321
+ "use strict";
1322
+ }
1323
+ });
1324
+
1304
1325
  // src/lib/validation.ts
1305
1326
  import { z } from "zod/v4";
1306
1327
  var AflApiTokenSchema, CompetitionSchema, CompetitionListSchema, CompseasonSchema, CompseasonListSchema, RoundSchema, RoundListSchema, ScoreSchema, PeriodScoreSchema, TeamScoreSchema, CfsMatchTeamSchema, CfsMatchSchema, CfsScoreSchema, CfsVenueSchema, MatchItemSchema, MatchItemListSchema, CfsPlayerInnerSchema, statNum, PlayerGameStatsSchema, PlayerStatsItemSchema, PlayerStatsListSchema, RosterPlayerSchema, TeamPlayersSchema, MatchRosterSchema, TeamItemSchema, TeamListSchema, SquadPlayerInnerSchema, SquadPlayerItemSchema, SquadSchema, SquadListSchema, WinLossRecordSchema, LadderEntryRawSchema, LadderResponseSchema;
@@ -1589,6 +1610,7 @@ var TOKEN_URL, API_BASE, CFS_BASE, AflApiClient;
1589
1610
  var init_afl_api = __esm({
1590
1611
  "src/sources/afl-api.ts"() {
1591
1612
  "use strict";
1613
+ init_concurrency();
1592
1614
  init_errors();
1593
1615
  init_result();
1594
1616
  init_validation();
@@ -1599,6 +1621,7 @@ var init_afl_api = __esm({
1599
1621
  fetchFn;
1600
1622
  tokenUrl;
1601
1623
  cachedToken = null;
1624
+ pendingAuth = null;
1602
1625
  constructor(options) {
1603
1626
  this.fetchFn = options?.fetchFn ?? globalThis.fetch;
1604
1627
  this.tokenUrl = options?.tokenUrl ?? TOKEN_URL;
@@ -1606,9 +1629,21 @@ var init_afl_api = __esm({
1606
1629
  /**
1607
1630
  * Authenticate with the WMCTok token endpoint and cache the token.
1608
1631
  *
1632
+ * Concurrent callers share the same in-flight request to avoid
1633
+ * redundant token fetches (thundering herd prevention).
1634
+ *
1609
1635
  * @returns The access token on success, or an error Result.
1610
1636
  */
1611
1637
  async authenticate() {
1638
+ if (this.pendingAuth) {
1639
+ return this.pendingAuth;
1640
+ }
1641
+ this.pendingAuth = this.doAuthenticate().finally(() => {
1642
+ this.pendingAuth = null;
1643
+ });
1644
+ return this.pendingAuth;
1645
+ }
1646
+ async doAuthenticate() {
1612
1647
  try {
1613
1648
  const response = await this.fetchFn(this.tokenUrl, {
1614
1649
  method: "POST",
@@ -1863,7 +1898,7 @@ var init_afl_api = __esm({
1863
1898
  return roundsResult;
1864
1899
  }
1865
1900
  const providerIds = roundsResult.data.flatMap((r) => r.providerId ? [r.providerId] : []);
1866
- const results = await Promise.all(providerIds.map((id) => this.fetchRoundMatchItems(id)));
1901
+ const results = await batchedMap(providerIds, (id) => this.fetchRoundMatchItems(id));
1867
1902
  const allItems = [];
1868
1903
  for (const result of results) {
1869
1904
  if (!result.success) {
@@ -2221,8 +2256,9 @@ async function fetchFixture(query) {
2221
2256
  const roundProviderIds = roundsResult.data.flatMap(
2222
2257
  (r) => r.providerId ? [{ providerId: r.providerId, roundNumber: r.roundNumber }] : []
2223
2258
  );
2224
- const roundResults = await Promise.all(
2225
- roundProviderIds.map((r) => client.fetchRoundMatchItems(r.providerId))
2259
+ const roundResults = await batchedMap(
2260
+ roundProviderIds,
2261
+ (r) => client.fetchRoundMatchItems(r.providerId)
2226
2262
  );
2227
2263
  const fixtures = [];
2228
2264
  for (let i = 0; i < roundResults.length; i++) {
@@ -2238,6 +2274,7 @@ async function fetchFixture(query) {
2238
2274
  var init_fixture = __esm({
2239
2275
  "src/api/fixture.ts"() {
2240
2276
  "use strict";
2277
+ init_concurrency();
2241
2278
  init_errors();
2242
2279
  init_result();
2243
2280
  init_team_mapping();
@@ -3034,8 +3071,9 @@ async function fetchLineup(query) {
3034
3071
  if (matchItems.data.length === 0) {
3035
3072
  return err(new AflApiError(`No matches found for round ${query.round}`));
3036
3073
  }
3037
- const rosterResults = await Promise.all(
3038
- matchItems.data.map((item) => client.fetchMatchRoster(item.match.matchId))
3074
+ const rosterResults = await batchedMap(
3075
+ matchItems.data,
3076
+ (item) => client.fetchMatchRoster(item.match.matchId)
3039
3077
  );
3040
3078
  const lineups = [];
3041
3079
  for (const rosterResult of rosterResults) {
@@ -3047,6 +3085,7 @@ async function fetchLineup(query) {
3047
3085
  var init_lineup2 = __esm({
3048
3086
  "src/api/lineup.ts"() {
3049
3087
  "use strict";
3088
+ init_concurrency();
3050
3089
  init_errors();
3051
3090
  init_result();
3052
3091
  init_afl_api();
@@ -3365,8 +3404,9 @@ async function fetchPlayerStats(query) {
3365
3404
  teamIdMap.set(item.match.homeTeamId, item.match.homeTeam.name);
3366
3405
  teamIdMap.set(item.match.awayTeamId, item.match.awayTeam.name);
3367
3406
  }
3368
- const statsResults = await Promise.all(
3369
- matchItemsResult.data.map((item) => client.fetchPlayerStats(item.match.matchId))
3407
+ const statsResults = await batchedMap(
3408
+ matchItemsResult.data,
3409
+ (item) => client.fetchPlayerStats(item.match.matchId)
3370
3410
  );
3371
3411
  const allStats = [];
3372
3412
  for (let i = 0; i < statsResults.length; i++) {
@@ -3434,6 +3474,7 @@ async function fetchPlayerStats(query) {
3434
3474
  var init_player_stats2 = __esm({
3435
3475
  "src/api/player-stats.ts"() {
3436
3476
  "use strict";
3477
+ init_concurrency();
3437
3478
  init_errors();
3438
3479
  init_result();
3439
3480
  init_afl_api();
@@ -3556,6 +3597,94 @@ var init_index = __esm({
3556
3597
  }
3557
3598
  });
3558
3599
 
3600
+ // src/cli/flags.ts
3601
+ var SEASON_FLAG, OPTIONAL_SEASON_FLAG, ROUND_FLAG, REQUIRED_ROUND_FLAG, SOURCE_FLAG, COMPETITION_FLAG, OPTIONAL_COMPETITION_FLAG, OUTPUT_FLAGS, REQUIRED_TEAM_FLAG, TEAM_FLAG, PLAYER_FLAG;
3602
+ var init_flags = __esm({
3603
+ "src/cli/flags.ts"() {
3604
+ "use strict";
3605
+ SEASON_FLAG = {
3606
+ season: {
3607
+ type: "string",
3608
+ description: "Season year (e.g. 2025)",
3609
+ required: true,
3610
+ alias: "s"
3611
+ }
3612
+ };
3613
+ OPTIONAL_SEASON_FLAG = {
3614
+ season: {
3615
+ type: "string",
3616
+ description: "Season year (e.g. 2025)",
3617
+ alias: "s"
3618
+ }
3619
+ };
3620
+ ROUND_FLAG = {
3621
+ round: {
3622
+ type: "string",
3623
+ description: "Round number",
3624
+ alias: "r"
3625
+ }
3626
+ };
3627
+ REQUIRED_ROUND_FLAG = {
3628
+ round: {
3629
+ type: "string",
3630
+ description: "Round number",
3631
+ required: true,
3632
+ alias: "r"
3633
+ }
3634
+ };
3635
+ SOURCE_FLAG = {
3636
+ source: {
3637
+ type: "string",
3638
+ description: "Data source",
3639
+ default: "afl-api"
3640
+ }
3641
+ };
3642
+ COMPETITION_FLAG = {
3643
+ competition: {
3644
+ type: "string",
3645
+ description: "Competition code (AFLM or AFLW)",
3646
+ default: "AFLM",
3647
+ alias: "c"
3648
+ }
3649
+ };
3650
+ OPTIONAL_COMPETITION_FLAG = {
3651
+ competition: {
3652
+ type: "string",
3653
+ description: "Competition code (AFLM or AFLW)",
3654
+ alias: "c"
3655
+ }
3656
+ };
3657
+ OUTPUT_FLAGS = {
3658
+ json: { type: "boolean", description: "Output as JSON", alias: "j" },
3659
+ csv: { type: "boolean", description: "Output as CSV" },
3660
+ format: { type: "string", description: "Output format: table, json, csv" },
3661
+ full: { type: "boolean", description: "Show all columns in table output" }
3662
+ };
3663
+ REQUIRED_TEAM_FLAG = {
3664
+ team: {
3665
+ type: "string",
3666
+ description: "Team name, abbreviation, or ID (e.g. Carlton, CARL, 5)",
3667
+ required: true,
3668
+ alias: "t"
3669
+ }
3670
+ };
3671
+ TEAM_FLAG = {
3672
+ team: {
3673
+ type: "string",
3674
+ description: "Filter by team name",
3675
+ alias: "t"
3676
+ }
3677
+ };
3678
+ PLAYER_FLAG = {
3679
+ player: {
3680
+ type: "string",
3681
+ description: "Filter by player name",
3682
+ alias: "p"
3683
+ }
3684
+ };
3685
+ }
3686
+ });
3687
+
3559
3688
  // src/cli/formatters/csv.ts
3560
3689
  function escapeField(value) {
3561
3690
  if (value.includes(",") || value.includes('"') || value.includes("\n") || value.includes("\r")) {
@@ -3822,6 +3951,29 @@ function resolveTeamIdentifier(raw, teams) {
3822
3951
  const validNames = teams.map((t) => `${t.name} (${t.abbreviation})`).join(", ");
3823
3952
  throw new Error(`Unknown team: "${raw}" \u2014 valid teams are: ${validNames}`);
3824
3953
  }
3954
+ function resolveMatchByTeam(teamSearch, matchItems) {
3955
+ const normalised = normaliseTeamName(teamSearch);
3956
+ const lower = teamSearch.toLowerCase();
3957
+ const matches = matchItems.filter((item) => {
3958
+ const home = item.match.homeTeam.name;
3959
+ const away = item.match.awayTeam.name;
3960
+ return normaliseTeamName(home) === normalised || normaliseTeamName(away) === normalised || home.toLowerCase().includes(lower) || away.toLowerCase().includes(lower);
3961
+ });
3962
+ const singleMatch = matches[0];
3963
+ if (matches.length === 1 && singleMatch) {
3964
+ return singleMatch.match.matchId;
3965
+ }
3966
+ if (matches.length === 0) {
3967
+ const available = matchItems.map((item) => `${item.match.homeTeam.name} vs ${item.match.awayTeam.name}`).join(", ");
3968
+ throw new Error(
3969
+ `No match found for "${teamSearch}" in this round. Available matches: ${available}`
3970
+ );
3971
+ }
3972
+ const ambiguous = matches.map((item) => `${item.match.homeTeam.name} vs ${item.match.awayTeam.name}`).join(", ");
3973
+ throw new Error(
3974
+ `Multiple matches found for "${teamSearch}": ${ambiguous}. Please be more specific.`
3975
+ );
3976
+ }
3825
3977
  var VALID_SOURCES, VALID_COMPETITIONS, VALID_FORMATS;
3826
3978
  var init_validation2 = __esm({
3827
3979
  "src/cli/validation.ts"() {
@@ -3844,6 +3996,7 @@ var init_matches = __esm({
3844
3996
  "src/cli/commands/matches.ts"() {
3845
3997
  "use strict";
3846
3998
  init_index();
3999
+ init_flags();
3847
4000
  init_formatters();
3848
4001
  init_ui();
3849
4002
  init_validation2();
@@ -3862,18 +4015,11 @@ var init_matches = __esm({
3862
4015
  description: "Fetch match results for a season"
3863
4016
  },
3864
4017
  args: {
3865
- season: { type: "string", description: "Season year (e.g. 2025)", required: true },
3866
- round: { type: "string", description: "Round number" },
3867
- source: { type: "string", description: "Data source", default: "afl-api" },
3868
- competition: {
3869
- type: "string",
3870
- description: "Competition code (AFLM or AFLW)",
3871
- default: "AFLM"
3872
- },
3873
- json: { type: "boolean", description: "Output as JSON" },
3874
- csv: { type: "boolean", description: "Output as CSV" },
3875
- format: { type: "string", description: "Output format: table, json, csv" },
3876
- full: { type: "boolean", description: "Show all columns in table output" }
4018
+ ...SEASON_FLAG,
4019
+ ...ROUND_FLAG,
4020
+ ...SOURCE_FLAG,
4021
+ ...COMPETITION_FLAG,
4022
+ ...OUTPUT_FLAGS
3877
4023
  },
3878
4024
  async run({ args }) {
3879
4025
  const season = validateSeason(args.season);
@@ -3903,6 +4049,203 @@ var init_matches = __esm({
3903
4049
  }
3904
4050
  });
3905
4051
 
4052
+ // src/lib/fuzzy.ts
4053
+ function levenshteinDistance(a, b) {
4054
+ const la = a.length;
4055
+ const lb = b.length;
4056
+ if (la === 0) return lb;
4057
+ if (lb === 0) return la;
4058
+ if (la < lb) return levenshteinDistance(b, a);
4059
+ const row = Array.from({ length: lb + 1 }, (_, i) => i);
4060
+ for (let i = 1; i <= la; i++) {
4061
+ let prev = i;
4062
+ for (let j = 1; j <= lb; j++) {
4063
+ const current = row[j - 1];
4064
+ const rowJ = row[j];
4065
+ if (current === void 0 || rowJ === void 0) continue;
4066
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
4067
+ const val = Math.min(rowJ + 1, prev + 1, current + cost);
4068
+ row[j - 1] = prev;
4069
+ prev = val;
4070
+ }
4071
+ row[lb] = prev;
4072
+ }
4073
+ return row[lb] ?? 0;
4074
+ }
4075
+ function fuzzySearch(query, candidates, keySelector, options) {
4076
+ const maxResults = options?.maxResults ?? 10;
4077
+ const threshold = options?.threshold ?? 0.4;
4078
+ const lowerQuery = query.toLowerCase();
4079
+ const results = [];
4080
+ for (const item of candidates) {
4081
+ const key = keySelector(item).toLowerCase();
4082
+ if (key === lowerQuery) {
4083
+ results.push({ item, score: 0 });
4084
+ continue;
4085
+ }
4086
+ if (key.startsWith(lowerQuery)) {
4087
+ results.push({ item, score: 0.1 });
4088
+ continue;
4089
+ }
4090
+ if (key.includes(lowerQuery)) {
4091
+ results.push({ item, score: 0.3 });
4092
+ continue;
4093
+ }
4094
+ const maxLen = Math.max(lowerQuery.length, key.length);
4095
+ if (maxLen === 0) continue;
4096
+ const distance = levenshteinDistance(lowerQuery, key);
4097
+ const normalised = distance / maxLen;
4098
+ if (normalised <= threshold) {
4099
+ const score = 0.4 + normalised / threshold * 0.6;
4100
+ results.push({ item, score });
4101
+ }
4102
+ }
4103
+ results.sort((a, b) => {
4104
+ if (a.score !== b.score) return a.score - b.score;
4105
+ return keySelector(a.item).localeCompare(keySelector(b.item));
4106
+ });
4107
+ return results.slice(0, maxResults);
4108
+ }
4109
+ var init_fuzzy = __esm({
4110
+ "src/lib/fuzzy.ts"() {
4111
+ "use strict";
4112
+ }
4113
+ });
4114
+
4115
+ // src/cli/resolvers.ts
4116
+ import { isCancel, select } from "@clack/prompts";
4117
+ async function resolveTeamOrPrompt(query, teams) {
4118
+ try {
4119
+ return resolveTeamIdentifier(query, teams);
4120
+ } catch {
4121
+ }
4122
+ const matches = fuzzySearch(query.trim(), teams, (t) => t.name, {
4123
+ maxResults: 5,
4124
+ threshold: 0.4
4125
+ });
4126
+ const abbrevMatches = fuzzySearch(query.trim(), teams, (t) => t.abbreviation, {
4127
+ maxResults: 5,
4128
+ threshold: 0.4
4129
+ });
4130
+ const seen = new Set(matches.map((m) => m.item.teamId));
4131
+ for (const m of abbrevMatches) {
4132
+ if (!seen.has(m.item.teamId)) {
4133
+ matches.push(m);
4134
+ seen.add(m.item.teamId);
4135
+ }
4136
+ }
4137
+ matches.sort((a, b) => a.score - b.score);
4138
+ return disambiguate(
4139
+ query.trim(),
4140
+ matches.map((m) => ({ value: m.item.teamId, label: m.item.name, score: m.score })),
4141
+ teams.map((t) => `${t.name} (${t.abbreviation})`),
4142
+ "team"
4143
+ );
4144
+ }
4145
+ async function resolveTeamNameOrPrompt(query, teamNames) {
4146
+ const trimmed = query.trim();
4147
+ const canonical = normaliseTeamName(trimmed);
4148
+ const candidates = teamNames ?? [...AFL_SENIOR_TEAMS];
4149
+ if (candidates.includes(canonical)) {
4150
+ return canonical;
4151
+ }
4152
+ const items = candidates.map((name) => ({ name }));
4153
+ const matches = fuzzySearch(trimmed, items, (t) => t.name, {
4154
+ maxResults: 5,
4155
+ threshold: 0.4
4156
+ });
4157
+ return disambiguate(
4158
+ trimmed,
4159
+ matches.map((m) => ({ value: m.item.name, label: m.item.name, score: m.score })),
4160
+ candidates,
4161
+ "team"
4162
+ );
4163
+ }
4164
+ async function resolveMatchOrPrompt(query, matchItems) {
4165
+ try {
4166
+ return resolveMatchByTeam(query, matchItems);
4167
+ } catch {
4168
+ }
4169
+ const labelledItems = matchItems.map((item) => ({
4170
+ item,
4171
+ label: `${item.match.homeTeam.name} vs ${item.match.awayTeam.name}`
4172
+ }));
4173
+ const matches = fuzzySearch(query, labelledItems, (l) => l.label, {
4174
+ maxResults: 5,
4175
+ threshold: 0.5
4176
+ });
4177
+ const homeMatches = fuzzySearch(query, matchItems, (i) => i.match.homeTeam.name, {
4178
+ maxResults: 5,
4179
+ threshold: 0.4
4180
+ });
4181
+ const awayMatches = fuzzySearch(query, matchItems, (i) => i.match.awayTeam.name, {
4182
+ maxResults: 5,
4183
+ threshold: 0.4
4184
+ });
4185
+ const seen = new Set(matches.map((m) => m.item.item.match.matchId));
4186
+ for (const m of homeMatches) {
4187
+ if (!seen.has(m.item.match.matchId)) {
4188
+ const label = `${m.item.match.homeTeam.name} vs ${m.item.match.awayTeam.name}`;
4189
+ matches.push({ item: { item: m.item, label }, score: m.score });
4190
+ seen.add(m.item.match.matchId);
4191
+ }
4192
+ }
4193
+ for (const m of awayMatches) {
4194
+ if (!seen.has(m.item.match.matchId)) {
4195
+ const label = `${m.item.match.homeTeam.name} vs ${m.item.match.awayTeam.name}`;
4196
+ matches.push({ item: { item: m.item, label }, score: m.score });
4197
+ seen.add(m.item.match.matchId);
4198
+ }
4199
+ }
4200
+ matches.sort((a, b) => a.score - b.score);
4201
+ const available = matchItems.map(
4202
+ (item) => `${item.match.homeTeam.name} vs ${item.match.awayTeam.name}`
4203
+ );
4204
+ return disambiguate(
4205
+ query,
4206
+ matches.map((m) => ({
4207
+ value: m.item.item.match.matchId,
4208
+ label: m.item.label,
4209
+ score: m.score
4210
+ })),
4211
+ available,
4212
+ "match"
4213
+ );
4214
+ }
4215
+ async function disambiguate(query, options, allLabels, entityName) {
4216
+ const best = options[0];
4217
+ if (!best) {
4218
+ throw new Error(
4219
+ `No ${entityName} found for "${query}". Valid options: ${allLabels.join(", ")}`
4220
+ );
4221
+ }
4222
+ if (best.score < 0.2 || options.length === 1) {
4223
+ return best.value;
4224
+ }
4225
+ if (isTTY2) {
4226
+ const choice = await select({
4227
+ message: `Multiple ${entityName}s matched "${query}". Which did you mean?`,
4228
+ options: options.map((o) => ({ value: o.value, label: o.label }))
4229
+ });
4230
+ if (isCancel(choice)) {
4231
+ process.exit(0);
4232
+ }
4233
+ return choice;
4234
+ }
4235
+ console.error(`Matched "${query}" \u2192 ${best.label}`);
4236
+ return best.value;
4237
+ }
4238
+ var isTTY2;
4239
+ var init_resolvers = __esm({
4240
+ "src/cli/resolvers.ts"() {
4241
+ "use strict";
4242
+ init_fuzzy();
4243
+ init_team_mapping();
4244
+ init_validation2();
4245
+ isTTY2 = process.stdout.isTTY === true;
4246
+ }
4247
+ });
4248
+
3906
4249
  // src/cli/commands/stats.ts
3907
4250
  var stats_exports = {};
3908
4251
  __export(stats_exports, {
@@ -3914,7 +4257,11 @@ var init_stats = __esm({
3914
4257
  "src/cli/commands/stats.ts"() {
3915
4258
  "use strict";
3916
4259
  init_index();
4260
+ init_fuzzy();
4261
+ init_afl_api();
4262
+ init_flags();
3917
4263
  init_formatters();
4264
+ init_resolvers();
3918
4265
  init_ui();
3919
4266
  init_validation2();
3920
4267
  DEFAULT_COLUMNS2 = [
@@ -3932,27 +4279,32 @@ var init_stats = __esm({
3932
4279
  description: "Fetch player statistics for a season"
3933
4280
  },
3934
4281
  args: {
3935
- season: { type: "string", description: "Season year (e.g. 2025)", required: true },
3936
- round: { type: "string", description: "Round number" },
3937
- "match-id": { type: "string", description: "Specific match ID" },
3938
- source: { type: "string", description: "Data source", default: "afl-api" },
3939
- competition: {
3940
- type: "string",
3941
- description: "Competition code (AFLM or AFLW)",
3942
- default: "AFLM"
3943
- },
3944
- json: { type: "boolean", description: "Output as JSON" },
3945
- csv: { type: "boolean", description: "Output as CSV" },
3946
- format: { type: "string", description: "Output format: table, json, csv" },
3947
- full: { type: "boolean", description: "Show all columns in table output" }
4282
+ ...SEASON_FLAG,
4283
+ ...ROUND_FLAG,
4284
+ match: { type: "string", description: "Filter by team name to find a specific match" },
4285
+ "match-id": { type: "string", description: "Specific match provider ID (advanced)" },
4286
+ ...SOURCE_FLAG,
4287
+ ...COMPETITION_FLAG,
4288
+ ...PLAYER_FLAG,
4289
+ ...OUTPUT_FLAGS
3948
4290
  },
3949
4291
  async run({ args }) {
3950
4292
  const season = validateSeason(args.season);
3951
4293
  const round = args.round ? validateRound(args.round) : void 0;
3952
- const matchId = args["match-id"];
3953
4294
  const source = validateSource(args.source);
3954
4295
  const competition = validateCompetition(args.competition);
3955
4296
  const format = validateFormat(args.format);
4297
+ let matchId = args["match-id"];
4298
+ if (!matchId && args.match && round != null) {
4299
+ const client = new AflApiClient();
4300
+ const seasonResult = await client.resolveCompSeason(competition, season);
4301
+ if (!seasonResult.success) throw seasonResult.error;
4302
+ const itemsResult = await client.fetchRoundMatchItemsByNumber(seasonResult.data, round);
4303
+ if (!itemsResult.success) throw itemsResult.error;
4304
+ matchId = await resolveMatchOrPrompt(args.match, itemsResult.data);
4305
+ } else if (args.match && round == null) {
4306
+ throw new Error("--match requires --round (-r) to identify which round to search.");
4307
+ }
3956
4308
  const result = await withSpinner(
3957
4309
  "Fetching player stats\u2026",
3958
4310
  () => fetchPlayerStats({ source, season, round, matchId, competition })
@@ -3960,7 +4312,14 @@ var init_stats = __esm({
3960
4312
  if (!result.success) {
3961
4313
  throw result.error;
3962
4314
  }
3963
- const data = result.data;
4315
+ let data = result.data;
4316
+ if (args.player) {
4317
+ const playerMatches = fuzzySearch(args.player, data, (p) => p.displayName, {
4318
+ maxResults: 50,
4319
+ threshold: 0.4
4320
+ });
4321
+ data = playerMatches.map((m) => m.item);
4322
+ }
3964
4323
  showSummary(
3965
4324
  `Loaded ${data.length} player stat lines for ${season}${round ? ` round ${round}` : ""}`
3966
4325
  );
@@ -3988,6 +4347,7 @@ var init_fixture2 = __esm({
3988
4347
  "src/cli/commands/fixture.ts"() {
3989
4348
  "use strict";
3990
4349
  init_index();
4350
+ init_flags();
3991
4351
  init_formatters();
3992
4352
  init_ui();
3993
4353
  init_validation2();
@@ -4004,18 +4364,11 @@ var init_fixture2 = __esm({
4004
4364
  description: "Fetch fixture/schedule for a season"
4005
4365
  },
4006
4366
  args: {
4007
- season: { type: "string", description: "Season year (e.g. 2025)", required: true },
4008
- round: { type: "string", description: "Round number" },
4009
- source: { type: "string", description: "Data source", default: "afl-api" },
4010
- competition: {
4011
- type: "string",
4012
- description: "Competition code (AFLM or AFLW)",
4013
- default: "AFLM"
4014
- },
4015
- json: { type: "boolean", description: "Output as JSON" },
4016
- csv: { type: "boolean", description: "Output as CSV" },
4017
- format: { type: "string", description: "Output format: table, json, csv" },
4018
- full: { type: "boolean", description: "Show all columns in table output" }
4367
+ ...SEASON_FLAG,
4368
+ ...ROUND_FLAG,
4369
+ ...SOURCE_FLAG,
4370
+ ...COMPETITION_FLAG,
4371
+ ...OUTPUT_FLAGS
4019
4372
  },
4020
4373
  async run({ args }) {
4021
4374
  const season = validateSeason(args.season);
@@ -4056,6 +4409,7 @@ var init_ladder3 = __esm({
4056
4409
  "src/cli/commands/ladder.ts"() {
4057
4410
  "use strict";
4058
4411
  init_index();
4412
+ init_flags();
4059
4413
  init_formatters();
4060
4414
  init_ui();
4061
4415
  init_validation2();
@@ -4074,18 +4428,11 @@ var init_ladder3 = __esm({
4074
4428
  description: "Fetch ladder standings for a season"
4075
4429
  },
4076
4430
  args: {
4077
- season: { type: "string", description: "Season year (e.g. 2025)", required: true },
4078
- round: { type: "string", description: "Round number" },
4079
- source: { type: "string", description: "Data source", default: "afl-api" },
4080
- competition: {
4081
- type: "string",
4082
- description: "Competition code (AFLM or AFLW)",
4083
- default: "AFLM"
4084
- },
4085
- json: { type: "boolean", description: "Output as JSON" },
4086
- csv: { type: "boolean", description: "Output as CSV" },
4087
- format: { type: "string", description: "Output format: table, json, csv" },
4088
- full: { type: "boolean", description: "Show all columns in table output" }
4431
+ ...SEASON_FLAG,
4432
+ ...ROUND_FLAG,
4433
+ ...SOURCE_FLAG,
4434
+ ...COMPETITION_FLAG,
4435
+ ...OUTPUT_FLAGS
4089
4436
  },
4090
4437
  async run({ args }) {
4091
4438
  const season = validateSeason(args.season);
@@ -4150,7 +4497,10 @@ var init_lineup3 = __esm({
4150
4497
  "src/cli/commands/lineup.ts"() {
4151
4498
  "use strict";
4152
4499
  init_index();
4500
+ init_afl_api();
4501
+ init_flags();
4153
4502
  init_formatters();
4503
+ init_resolvers();
4154
4504
  init_ui();
4155
4505
  init_validation2();
4156
4506
  DEFAULT_COLUMNS5 = [
@@ -4166,27 +4516,29 @@ var init_lineup3 = __esm({
4166
4516
  description: "Fetch match lineups for a round"
4167
4517
  },
4168
4518
  args: {
4169
- season: { type: "string", description: "Season year (e.g. 2025)", required: true },
4170
- round: { type: "string", description: "Round number", required: true },
4171
- "match-id": { type: "string", description: "Specific match ID" },
4172
- source: { type: "string", description: "Data source", default: "afl-api" },
4173
- competition: {
4174
- type: "string",
4175
- description: "Competition code (AFLM or AFLW)",
4176
- default: "AFLM"
4177
- },
4178
- json: { type: "boolean", description: "Output as JSON" },
4179
- csv: { type: "boolean", description: "Output as CSV" },
4180
- format: { type: "string", description: "Output format: table, json, csv" },
4181
- full: { type: "boolean", description: "Show all columns in table output" }
4519
+ ...SEASON_FLAG,
4520
+ ...REQUIRED_ROUND_FLAG,
4521
+ match: { type: "string", description: "Filter by team name to find a specific match" },
4522
+ "match-id": { type: "string", description: "Specific match provider ID (advanced)" },
4523
+ ...SOURCE_FLAG,
4524
+ ...COMPETITION_FLAG,
4525
+ ...OUTPUT_FLAGS
4182
4526
  },
4183
4527
  async run({ args }) {
4184
4528
  const season = validateSeason(args.season);
4185
4529
  const round = validateRound(args.round);
4186
- const matchId = args["match-id"];
4187
4530
  const source = validateSource(args.source);
4188
4531
  const competition = validateCompetition(args.competition);
4189
4532
  const format = validateFormat(args.format);
4533
+ let matchId = args["match-id"];
4534
+ if (!matchId && args.match) {
4535
+ const client = new AflApiClient();
4536
+ const seasonResult = await client.resolveCompSeason(competition, season);
4537
+ if (!seasonResult.success) throw seasonResult.error;
4538
+ const itemsResult = await client.fetchRoundMatchItemsByNumber(seasonResult.data, round);
4539
+ if (!itemsResult.success) throw itemsResult.error;
4540
+ matchId = await resolveMatchOrPrompt(args.match, itemsResult.data);
4541
+ }
4190
4542
  const result = await withSpinner(
4191
4543
  "Fetching lineups\u2026",
4192
4544
  () => fetchLineup({ source, season, round, matchId, competition })
@@ -4225,7 +4577,9 @@ var init_squad = __esm({
4225
4577
  "src/cli/commands/squad.ts"() {
4226
4578
  "use strict";
4227
4579
  init_index();
4580
+ init_flags();
4228
4581
  init_formatters();
4582
+ init_resolvers();
4229
4583
  init_ui();
4230
4584
  init_validation2();
4231
4585
  DEFAULT_COLUMNS6 = [
@@ -4241,34 +4595,22 @@ var init_squad = __esm({
4241
4595
  description: "Fetch team squad for a season"
4242
4596
  },
4243
4597
  args: {
4244
- "team-id": {
4245
- type: "string",
4246
- description: "Team ID, abbreviation, or name (e.g. 5, CARL, Carlton)",
4247
- required: true
4248
- },
4249
- season: { type: "string", description: "Season year (e.g. 2025)", required: true },
4250
- competition: {
4251
- type: "string",
4252
- description: "Competition code (AFLM or AFLW)",
4253
- default: "AFLM"
4254
- },
4255
- json: { type: "boolean", description: "Output as JSON" },
4256
- csv: { type: "boolean", description: "Output as CSV" },
4257
- format: { type: "string", description: "Output format: table, json, csv" },
4258
- full: { type: "boolean", description: "Show all columns in table output" }
4598
+ ...REQUIRED_TEAM_FLAG,
4599
+ ...SEASON_FLAG,
4600
+ ...COMPETITION_FLAG,
4601
+ ...OUTPUT_FLAGS
4259
4602
  },
4260
4603
  async run({ args }) {
4261
4604
  const season = validateSeason(args.season);
4262
4605
  const competition = validateCompetition(args.competition);
4263
4606
  const format = validateFormat(args.format);
4264
- let teamId = args["team-id"].trim();
4265
- const isNumeric = /^\d+$/.test(teamId);
4266
- if (!isNumeric) {
4607
+ let teamId = args.team.trim();
4608
+ if (!/^\d+$/.test(teamId)) {
4267
4609
  const teamsResult = await withSpinner("Resolving team\u2026", () => fetchTeams({ competition }));
4268
4610
  if (!teamsResult.success) {
4269
4611
  throw teamsResult.error;
4270
4612
  }
4271
- teamId = resolveTeamIdentifier(args["team-id"], teamsResult.data);
4613
+ teamId = await resolveTeamOrPrompt(args.team, teamsResult.data);
4272
4614
  }
4273
4615
  const result = await withSpinner(
4274
4616
  "Fetching squad\u2026",
@@ -4303,6 +4645,7 @@ var init_teams2 = __esm({
4303
4645
  "src/cli/commands/teams.ts"() {
4304
4646
  "use strict";
4305
4647
  init_index();
4648
+ init_flags();
4306
4649
  init_formatters();
4307
4650
  init_ui();
4308
4651
  init_validation2();
@@ -4318,12 +4661,9 @@ var init_teams2 = __esm({
4318
4661
  description: "Fetch team list"
4319
4662
  },
4320
4663
  args: {
4321
- competition: { type: "string", description: "Competition code (AFLM or AFLW)" },
4664
+ ...OPTIONAL_COMPETITION_FLAG,
4322
4665
  "team-type": { type: "string", description: "Team type filter" },
4323
- json: { type: "boolean", description: "Output as JSON" },
4324
- csv: { type: "boolean", description: "Output as CSV" },
4325
- format: { type: "string", description: "Output format: table, json, csv" },
4326
- full: { type: "boolean", description: "Show all columns in table output" }
4666
+ ...OUTPUT_FLAGS
4327
4667
  },
4328
4668
  async run({ args }) {
4329
4669
  const competition = validateOptionalCompetition(args.competition);
@@ -4372,6 +4712,7 @@ var init_team_stats2 = __esm({
4372
4712
  "src/cli/commands/team-stats.ts"() {
4373
4713
  "use strict";
4374
4714
  init_index();
4715
+ init_flags();
4375
4716
  init_formatters();
4376
4717
  init_ui();
4377
4718
  init_validation2();
@@ -4393,17 +4734,14 @@ var init_team_stats2 = __esm({
4393
4734
  description: "Fetch team aggregate statistics for a season"
4394
4735
  },
4395
4736
  args: {
4396
- season: { type: "string", description: "Season year (e.g. 2024)", required: true },
4737
+ ...SEASON_FLAG,
4397
4738
  source: {
4398
4739
  type: "string",
4399
4740
  description: "Data source (footywire, afl-tables)",
4400
4741
  default: "footywire"
4401
4742
  },
4402
4743
  summary: { type: "string", description: "Summary type: totals or averages", default: "totals" },
4403
- json: { type: "boolean", description: "Output as JSON" },
4404
- csv: { type: "boolean", description: "Output as CSV" },
4405
- format: { type: "string", description: "Output format: table, json, csv" },
4406
- full: { type: "boolean", description: "Show all columns in table output" }
4744
+ ...OUTPUT_FLAGS
4407
4745
  },
4408
4746
  async run({ args }) {
4409
4747
  const season = validateSeason(args.season);
@@ -4444,7 +4782,9 @@ var init_player_details2 = __esm({
4444
4782
  "src/cli/commands/player-details.ts"() {
4445
4783
  "use strict";
4446
4784
  init_index();
4785
+ init_flags();
4447
4786
  init_formatters();
4787
+ init_resolvers();
4448
4788
  init_ui();
4449
4789
  init_validation2();
4450
4790
  DEFAULT_COLUMNS9 = [
@@ -4462,37 +4802,31 @@ var init_player_details2 = __esm({
4462
4802
  description: "Fetch player biographical details for a team"
4463
4803
  },
4464
4804
  args: {
4465
- team: { type: "positional", description: "Team name (e.g. Carlton, Hawthorn)", required: true },
4805
+ ...REQUIRED_TEAM_FLAG,
4466
4806
  source: {
4467
4807
  type: "string",
4468
4808
  description: "Data source: afl-api, footywire, afl-tables",
4469
4809
  default: "afl-api"
4470
4810
  },
4471
- season: { type: "string", description: "Season year (for AFL API source, e.g. 2025)" },
4472
- competition: {
4473
- type: "string",
4474
- description: "Competition code (AFLM or AFLW)",
4475
- default: "AFLM"
4476
- },
4477
- json: { type: "boolean", description: "Output as JSON" },
4478
- csv: { type: "boolean", description: "Output as CSV" },
4479
- format: { type: "string", description: "Output format: table, json, csv" },
4480
- full: { type: "boolean", description: "Show all columns in table output" }
4811
+ ...OPTIONAL_SEASON_FLAG,
4812
+ ...COMPETITION_FLAG,
4813
+ ...OUTPUT_FLAGS
4481
4814
  },
4482
4815
  async run({ args }) {
4483
4816
  const source = validateSource(args.source);
4484
4817
  const competition = validateCompetition(args.competition);
4485
4818
  const format = validateFormat(args.format);
4486
4819
  const season = validateOptionalSeason(args.season) ?? resolveDefaultSeason(competition);
4820
+ const team = await resolveTeamNameOrPrompt(args.team);
4487
4821
  const result = await withSpinner(
4488
4822
  "Fetching player details\u2026",
4489
- () => fetchPlayerDetails({ source, team: args.team, season, competition })
4823
+ () => fetchPlayerDetails({ source, team, season, competition })
4490
4824
  );
4491
4825
  if (!result.success) {
4492
4826
  throw result.error;
4493
4827
  }
4494
4828
  const data = result.data;
4495
- showSummary(`Loaded ${data.length} players for ${args.team} (${source})`);
4829
+ showSummary(`Loaded ${data.length} players for ${team} (${source})`);
4496
4830
  const formatOptions = {
4497
4831
  json: args.json,
4498
4832
  csv: args.csv,
@@ -4517,7 +4851,9 @@ var init_coaches_votes2 = __esm({
4517
4851
  "src/cli/commands/coaches-votes.ts"() {
4518
4852
  "use strict";
4519
4853
  init_index();
4854
+ init_flags();
4520
4855
  init_formatters();
4856
+ init_resolvers();
4521
4857
  init_ui();
4522
4858
  init_validation2();
4523
4859
  DEFAULT_COLUMNS10 = [
@@ -4534,33 +4870,27 @@ var init_coaches_votes2 = __esm({
4534
4870
  description: "Fetch AFLCA coaches votes for a season"
4535
4871
  },
4536
4872
  args: {
4537
- season: { type: "string", description: "Season year (e.g. 2024)", required: true },
4538
- round: { type: "string", description: "Round number" },
4539
- competition: {
4540
- type: "string",
4541
- description: "Competition code (AFLM or AFLW)",
4542
- default: "AFLM"
4543
- },
4544
- team: { type: "string", description: "Filter by team name" },
4545
- json: { type: "boolean", description: "Output as JSON" },
4546
- csv: { type: "boolean", description: "Output as CSV" },
4547
- format: { type: "string", description: "Output format: table, json, csv" },
4548
- full: { type: "boolean", description: "Show all columns in table output" }
4873
+ ...SEASON_FLAG,
4874
+ ...ROUND_FLAG,
4875
+ ...COMPETITION_FLAG,
4876
+ ...TEAM_FLAG,
4877
+ ...OUTPUT_FLAGS
4549
4878
  },
4550
4879
  async run({ args }) {
4551
4880
  const season = validateSeason(args.season);
4552
4881
  const round = args.round ? validateRound(args.round) : void 0;
4553
4882
  const competition = validateCompetition(args.competition);
4554
4883
  const format = validateFormat(args.format);
4884
+ const team = args.team ? await resolveTeamNameOrPrompt(args.team) : void 0;
4555
4885
  const result = await withSpinner(
4556
4886
  "Fetching coaches votes\u2026",
4557
- () => fetchCoachesVotes({ season, round, competition, team: args.team })
4887
+ () => fetchCoachesVotes({ season, round, competition, team })
4558
4888
  );
4559
4889
  if (!result.success) {
4560
4890
  throw result.error;
4561
4891
  }
4562
4892
  const data = result.data;
4563
- const teamSuffix = args.team ? ` for ${args.team}` : "";
4893
+ const teamSuffix = team ? ` for ${team}` : "";
4564
4894
  const roundSuffix = round ? ` round ${round}` : "";
4565
4895
  showSummary(`Loaded ${data.length} vote records for ${season}${roundSuffix}${teamSuffix}`);
4566
4896
  const formatOptions = {
@@ -4609,7 +4939,7 @@ ${issueLines.join("\n")}`;
4609
4939
  var main = defineCommand11({
4610
4940
  meta: {
4611
4941
  name: "fitzroy",
4612
- version: "1.1.1",
4942
+ version: "1.2.0",
4613
4943
  description: "CLI for fetching AFL data \u2014 match results, player stats, fixtures, ladders, and more"
4614
4944
  },
4615
4945
  subCommands: {
package/dist/index.d.ts CHANGED
@@ -1777,13 +1777,18 @@ declare class AflApiClient {
1777
1777
  private readonly fetchFn;
1778
1778
  private readonly tokenUrl;
1779
1779
  private cachedToken;
1780
+ private pendingAuth;
1780
1781
  constructor(options?: AflApiClientOptions);
1781
1782
  /**
1782
1783
  * Authenticate with the WMCTok token endpoint and cache the token.
1783
1784
  *
1785
+ * Concurrent callers share the same in-flight request to avoid
1786
+ * redundant token fetches (thundering herd prevention).
1787
+ *
1784
1788
  * @returns The access token on success, or an error Result.
1785
1789
  */
1786
1790
  authenticate(): Promise<Result<string, AflApiError>>;
1791
+ private doAuthenticate;
1787
1792
  /**
1788
1793
  * Whether the cached token is still valid (not expired).
1789
1794
  */
package/dist/index.js CHANGED
@@ -1407,6 +1407,22 @@ async function fetchCoachesVotes(query) {
1407
1407
  return ok(votes);
1408
1408
  }
1409
1409
 
1410
+ // src/lib/concurrency.ts
1411
+ async function batchedMap(items, fn, options) {
1412
+ const batchSize = options?.batchSize ?? 5;
1413
+ const delayMs = options?.delayMs ?? 0;
1414
+ const results = [];
1415
+ for (let i = 0; i < items.length; i += batchSize) {
1416
+ const batch = items.slice(i, i + batchSize);
1417
+ const batchResults = await Promise.all(batch.map(fn));
1418
+ results.push(...batchResults);
1419
+ if (delayMs > 0 && i + batchSize < items.length) {
1420
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1421
+ }
1422
+ }
1423
+ return results;
1424
+ }
1425
+
1410
1426
  // src/lib/validation.ts
1411
1427
  import { z } from "zod/v4";
1412
1428
  var AflApiTokenSchema = z.object({
@@ -1692,6 +1708,7 @@ var AflApiClient = class {
1692
1708
  fetchFn;
1693
1709
  tokenUrl;
1694
1710
  cachedToken = null;
1711
+ pendingAuth = null;
1695
1712
  constructor(options) {
1696
1713
  this.fetchFn = options?.fetchFn ?? globalThis.fetch;
1697
1714
  this.tokenUrl = options?.tokenUrl ?? TOKEN_URL;
@@ -1699,9 +1716,21 @@ var AflApiClient = class {
1699
1716
  /**
1700
1717
  * Authenticate with the WMCTok token endpoint and cache the token.
1701
1718
  *
1719
+ * Concurrent callers share the same in-flight request to avoid
1720
+ * redundant token fetches (thundering herd prevention).
1721
+ *
1702
1722
  * @returns The access token on success, or an error Result.
1703
1723
  */
1704
1724
  async authenticate() {
1725
+ if (this.pendingAuth) {
1726
+ return this.pendingAuth;
1727
+ }
1728
+ this.pendingAuth = this.doAuthenticate().finally(() => {
1729
+ this.pendingAuth = null;
1730
+ });
1731
+ return this.pendingAuth;
1732
+ }
1733
+ async doAuthenticate() {
1705
1734
  try {
1706
1735
  const response = await this.fetchFn(this.tokenUrl, {
1707
1736
  method: "POST",
@@ -1956,7 +1985,7 @@ var AflApiClient = class {
1956
1985
  return roundsResult;
1957
1986
  }
1958
1987
  const providerIds = roundsResult.data.flatMap((r) => r.providerId ? [r.providerId] : []);
1959
- const results = await Promise.all(providerIds.map((id) => this.fetchRoundMatchItems(id)));
1988
+ const results = await batchedMap(providerIds, (id) => this.fetchRoundMatchItems(id));
1960
1989
  const allItems = [];
1961
1990
  for (const result of results) {
1962
1991
  if (!result.success) {
@@ -2290,8 +2319,9 @@ async function fetchFixture(query) {
2290
2319
  const roundProviderIds = roundsResult.data.flatMap(
2291
2320
  (r) => r.providerId ? [{ providerId: r.providerId, roundNumber: r.roundNumber }] : []
2292
2321
  );
2293
- const roundResults = await Promise.all(
2294
- roundProviderIds.map((r) => client.fetchRoundMatchItems(r.providerId))
2322
+ const roundResults = await batchedMap(
2323
+ roundProviderIds,
2324
+ (r) => client.fetchRoundMatchItems(r.providerId)
2295
2325
  );
2296
2326
  const fixtures = [];
2297
2327
  for (let i = 0; i < roundResults.length; i++) {
@@ -3042,8 +3072,9 @@ async function fetchLineup(query) {
3042
3072
  if (matchItems.data.length === 0) {
3043
3073
  return err(new AflApiError(`No matches found for round ${query.round}`));
3044
3074
  }
3045
- const rosterResults = await Promise.all(
3046
- matchItems.data.map((item) => client.fetchMatchRoster(item.match.matchId))
3075
+ const rosterResults = await batchedMap(
3076
+ matchItems.data,
3077
+ (item) => client.fetchMatchRoster(item.match.matchId)
3047
3078
  );
3048
3079
  const lineups = [];
3049
3080
  for (const rosterResult of rosterResults) {
@@ -3334,8 +3365,9 @@ async function fetchPlayerStats(query) {
3334
3365
  teamIdMap.set(item.match.homeTeamId, item.match.homeTeam.name);
3335
3366
  teamIdMap.set(item.match.awayTeamId, item.match.awayTeam.name);
3336
3367
  }
3337
- const statsResults = await Promise.all(
3338
- matchItemsResult.data.map((item) => client.fetchPlayerStats(item.match.matchId))
3368
+ const statsResults = await batchedMap(
3369
+ matchItemsResult.data,
3370
+ (item) => client.fetchPlayerStats(item.match.matchId)
3339
3371
  );
3340
3372
  const allStats = [];
3341
3373
  for (let i = 0; i < statsResults.length; i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fitzroy",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "TypeScript library and CLI for AFL data — match results, player stats, fixtures, ladders, and more",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",