fitzroy 1.1.1 → 1.3.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/index.js CHANGED
@@ -20,6 +20,12 @@ var UnsupportedSourceError = class extends Error {
20
20
  }
21
21
  name = "UnsupportedSourceError";
22
22
  };
23
+ function aflwUnsupportedError(source) {
24
+ return new UnsupportedSourceError(
25
+ `AFLW data is not available from ${source}. Use --source afl-api for AFLW data.`,
26
+ source
27
+ );
28
+ }
23
29
  var ValidationError = class extends Error {
24
30
  constructor(message, issues) {
25
31
  super(message);
@@ -119,6 +125,10 @@ function toAestString(date) {
119
125
  });
120
126
  return formatter.format(date);
121
127
  }
128
+ function resolveDefaultSeason(competition = "AFLM") {
129
+ const year = (/* @__PURE__ */ new Date()).getFullYear();
130
+ return competition === "AFLW" ? year - 1 : year;
131
+ }
122
132
  var MONTH_ABBREV_TO_INDEX = /* @__PURE__ */ new Map([
123
133
  ["jan", 0],
124
134
  ["feb", 1],
@@ -233,6 +243,26 @@ function normaliseTeamName(raw) {
233
243
  const trimmed = raw.trim();
234
244
  return ALIAS_MAP.get(trimmed.toLowerCase()) ?? trimmed;
235
245
  }
246
+ var AFL_API_TEAM_IDS = /* @__PURE__ */ new Map([
247
+ ["CD_T10", "Adelaide Crows"],
248
+ ["CD_T20", "Brisbane Lions"],
249
+ ["CD_T30", "Carlton"],
250
+ ["CD_T40", "Collingwood"],
251
+ ["CD_T50", "Essendon"],
252
+ ["CD_T60", "Fremantle"],
253
+ ["CD_T70", "Geelong Cats"],
254
+ ["CD_T1000", "Gold Coast Suns"],
255
+ ["CD_T1010", "GWS Giants"],
256
+ ["CD_T80", "Hawthorn"],
257
+ ["CD_T90", "Melbourne"],
258
+ ["CD_T100", "North Melbourne"],
259
+ ["CD_T110", "Port Adelaide"],
260
+ ["CD_T120", "Richmond"],
261
+ ["CD_T130", "St Kilda"],
262
+ ["CD_T160", "Sydney Swans"],
263
+ ["CD_T150", "West Coast Eagles"],
264
+ ["CD_T140", "Western Bulldogs"]
265
+ ]);
236
266
 
237
267
  // src/transforms/footywire-player-stats.ts
238
268
  import * as cheerio from "cheerio";
@@ -487,6 +517,14 @@ var FINALS_PATTERN = /final|elimination|qualifying|preliminary|semi|grand/i;
487
517
  function inferRoundType(roundName) {
488
518
  return FINALS_PATTERN.test(roundName) ? "Finals" : "HomeAndAway";
489
519
  }
520
+ function finalsRoundNumber(headerText, lastHARound) {
521
+ const lower = headerText.toLowerCase();
522
+ if (lower.includes("qualifying") || lower.includes("elimination")) return lastHARound + 1;
523
+ if (lower.includes("semi")) return lastHARound + 2;
524
+ if (lower.includes("preliminary")) return lastHARound + 3;
525
+ if (lower.includes("grand")) return lastHARound + 4;
526
+ return lastHARound + 1;
527
+ }
490
528
  function toMatchStatus(raw) {
491
529
  switch (raw) {
492
530
  case "CONCLUDED":
@@ -783,6 +821,7 @@ function parseMatchList(html, year) {
783
821
  const $ = cheerio2.load(html);
784
822
  const results = [];
785
823
  let currentRound = 0;
824
+ let lastHARound = 0;
786
825
  let currentRoundType = "HomeAndAway";
787
826
  $("tr").each((_i, row) => {
788
827
  const roundHeader = $(row).find("td[colspan='7']");
@@ -792,6 +831,11 @@ function parseMatchList(html, year) {
792
831
  const roundMatch = /Round\s+(\d+)/i.exec(text);
793
832
  if (roundMatch?.[1]) {
794
833
  currentRound = Number.parseInt(roundMatch[1], 10);
834
+ if (currentRoundType === "HomeAndAway") {
835
+ lastHARound = currentRound;
836
+ }
837
+ } else if (currentRoundType === "Finals") {
838
+ currentRound = finalsRoundNumber(text, lastHARound);
795
839
  }
796
840
  return;
797
841
  }
@@ -862,6 +906,7 @@ function parseFixtureList(html, year) {
862
906
  const $ = cheerio2.load(html);
863
907
  const fixtures = [];
864
908
  let currentRound = 0;
909
+ let lastHARound = 0;
865
910
  let currentRoundType = "HomeAndAway";
866
911
  let gameNumber = 0;
867
912
  $("tr").each((_i, row) => {
@@ -872,6 +917,11 @@ function parseFixtureList(html, year) {
872
917
  const roundMatch = /Round\s+(\d+)/i.exec(text);
873
918
  if (roundMatch?.[1]) {
874
919
  currentRound = Number.parseInt(roundMatch[1], 10);
920
+ if (currentRoundType === "HomeAndAway") {
921
+ lastHARound = currentRound;
922
+ }
923
+ } else if (currentRoundType === "Finals") {
924
+ currentRound = finalsRoundNumber(text, lastHARound);
875
925
  }
876
926
  return;
877
927
  }
@@ -1407,6 +1457,22 @@ async function fetchCoachesVotes(query) {
1407
1457
  return ok(votes);
1408
1458
  }
1409
1459
 
1460
+ // src/lib/concurrency.ts
1461
+ async function batchedMap(items, fn, options) {
1462
+ const batchSize = options?.batchSize ?? 5;
1463
+ const delayMs = options?.delayMs ?? 0;
1464
+ const results = [];
1465
+ for (let i = 0; i < items.length; i += batchSize) {
1466
+ const batch = items.slice(i, i + batchSize);
1467
+ const batchResults = await Promise.all(batch.map(fn));
1468
+ results.push(...batchResults);
1469
+ if (delayMs > 0 && i + batchSize < items.length) {
1470
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1471
+ }
1472
+ }
1473
+ return results;
1474
+ }
1475
+
1410
1476
  // src/lib/validation.ts
1411
1477
  import { z } from "zod/v4";
1412
1478
  var AflApiTokenSchema = z.object({
@@ -1512,7 +1578,14 @@ var CfsPlayerInnerSchema = z.object({
1512
1578
  captain: z.boolean().optional(),
1513
1579
  playerJumperNumber: z.number().optional()
1514
1580
  }).passthrough();
1515
- var statNum = z.number().nullable().optional();
1581
+ var statNum = z.union([
1582
+ z.number(),
1583
+ z.string().transform((s) => {
1584
+ if (s === "" || s === "-") return null;
1585
+ const n = Number(s);
1586
+ return Number.isNaN(n) ? null : n;
1587
+ })
1588
+ ]).nullable().optional();
1516
1589
  var PlayerGameStatsSchema = z.object({
1517
1590
  goals: statNum,
1518
1591
  behinds: statNum,
@@ -1590,8 +1663,8 @@ var PlayerStatsItemSchema = z.object({
1590
1663
  teamId: z.string(),
1591
1664
  playerStats: z.object({
1592
1665
  stats: PlayerGameStatsSchema,
1593
- timeOnGroundPercentage: z.number().nullable().optional()
1594
- }).passthrough()
1666
+ timeOnGroundPercentage: statNum
1667
+ }).passthrough().nullable().optional()
1595
1668
  }).passthrough();
1596
1669
  var PlayerStatsListSchema = z.object({
1597
1670
  homeTeamPlayerStats: z.array(PlayerStatsItemSchema),
@@ -1692,6 +1765,7 @@ var AflApiClient = class {
1692
1765
  fetchFn;
1693
1766
  tokenUrl;
1694
1767
  cachedToken = null;
1768
+ pendingAuth = null;
1695
1769
  constructor(options) {
1696
1770
  this.fetchFn = options?.fetchFn ?? globalThis.fetch;
1697
1771
  this.tokenUrl = options?.tokenUrl ?? TOKEN_URL;
@@ -1699,9 +1773,21 @@ var AflApiClient = class {
1699
1773
  /**
1700
1774
  * Authenticate with the WMCTok token endpoint and cache the token.
1701
1775
  *
1776
+ * Concurrent callers share the same in-flight request to avoid
1777
+ * redundant token fetches (thundering herd prevention).
1778
+ *
1702
1779
  * @returns The access token on success, or an error Result.
1703
1780
  */
1704
1781
  async authenticate() {
1782
+ if (this.pendingAuth) {
1783
+ return this.pendingAuth;
1784
+ }
1785
+ this.pendingAuth = this.doAuthenticate().finally(() => {
1786
+ this.pendingAuth = null;
1787
+ });
1788
+ return this.pendingAuth;
1789
+ }
1790
+ async doAuthenticate() {
1705
1791
  try {
1706
1792
  const response = await this.fetchFn(this.tokenUrl, {
1707
1793
  method: "POST",
@@ -1956,7 +2042,7 @@ var AflApiClient = class {
1956
2042
  return roundsResult;
1957
2043
  }
1958
2044
  const providerIds = roundsResult.data.flatMap((r) => r.providerId ? [r.providerId] : []);
1959
- const results = await Promise.all(providerIds.map((id) => this.fetchRoundMatchItems(id)));
2045
+ const results = await batchedMap(providerIds, (id) => this.fetchRoundMatchItems(id));
1960
2046
  const allItems = [];
1961
2047
  for (const result of results) {
1962
2048
  if (!result.success) {
@@ -2255,12 +2341,14 @@ function toFixture(item, season, fallbackRoundNumber, competition) {
2255
2341
  async function fetchFixture(query) {
2256
2342
  const competition = query.competition ?? "AFLM";
2257
2343
  if (query.source === "squiggle") {
2344
+ if (competition === "AFLW") return err(aflwUnsupportedError("squiggle"));
2258
2345
  const client2 = new SquiggleClient();
2259
2346
  const result = await client2.fetchGames(query.season, query.round ?? void 0);
2260
2347
  if (!result.success) return result;
2261
2348
  return ok(transformSquiggleGamesToFixture(result.data.games, query.season));
2262
2349
  }
2263
2350
  if (query.source === "footywire") {
2351
+ if (competition === "AFLW") return err(aflwUnsupportedError("footywire"));
2264
2352
  const fwClient = new FootyWireClient();
2265
2353
  const result = await fwClient.fetchSeasonFixture(query.season);
2266
2354
  if (!result.success) return result;
@@ -2290,8 +2378,9 @@ async function fetchFixture(query) {
2290
2378
  const roundProviderIds = roundsResult.data.flatMap(
2291
2379
  (r) => r.providerId ? [{ providerId: r.providerId, roundNumber: r.roundNumber }] : []
2292
2380
  );
2293
- const roundResults = await Promise.all(
2294
- roundProviderIds.map((r) => client.fetchRoundMatchItems(r.providerId))
2381
+ const roundResults = await batchedMap(
2382
+ roundProviderIds,
2383
+ (r) => client.fetchRoundMatchItems(r.providerId)
2295
2384
  );
2296
2385
  const fixtures = [];
2297
2386
  for (let i = 0; i < roundResults.length; i++) {
@@ -2603,6 +2692,7 @@ function parseSeasonPage(html, year) {
2603
2692
  const results = [];
2604
2693
  let currentRound = 0;
2605
2694
  let currentRoundType = "HomeAndAway";
2695
+ let lastHARound = 0;
2606
2696
  let matchCounter = 0;
2607
2697
  $("table").each((_i, table) => {
2608
2698
  const $table = $(table);
@@ -2612,10 +2702,14 @@ function parseSeasonPage(html, year) {
2612
2702
  if (roundMatch?.[1] && border !== "1") {
2613
2703
  currentRound = Number.parseInt(roundMatch[1], 10);
2614
2704
  currentRoundType = inferRoundType(text);
2705
+ if (currentRoundType === "HomeAndAway") {
2706
+ lastHARound = currentRound;
2707
+ }
2615
2708
  return;
2616
2709
  }
2617
2710
  if (border !== "1" && inferRoundType(text) === "Finals") {
2618
2711
  currentRoundType = "Finals";
2712
+ currentRound = finalsRoundNumber(text, lastHARound);
2619
2713
  return;
2620
2714
  }
2621
2715
  if (border !== "1") return;
@@ -2930,6 +3024,7 @@ function transformLadderEntries(entries) {
2930
3024
  async function fetchLadder(query) {
2931
3025
  const competition = query.competition ?? "AFLM";
2932
3026
  if (query.source === "squiggle") {
3027
+ if (competition === "AFLW") return err(aflwUnsupportedError("squiggle"));
2933
3028
  const client2 = new SquiggleClient();
2934
3029
  const result = await client2.fetchStandings(query.season, query.round ?? void 0);
2935
3030
  if (!result.success) return result;
@@ -2941,6 +3036,7 @@ async function fetchLadder(query) {
2941
3036
  });
2942
3037
  }
2943
3038
  if (query.source === "afl-tables") {
3039
+ if (competition === "AFLW") return err(aflwUnsupportedError("afl-tables"));
2944
3040
  const atClient = new AflTablesClient();
2945
3041
  const resultsResult = await atClient.fetchSeasonResults(query.season);
2946
3042
  if (!resultsResult.success) return resultsResult;
@@ -3042,8 +3138,9 @@ async function fetchLineup(query) {
3042
3138
  if (matchItems.data.length === 0) {
3043
3139
  return err(new AflApiError(`No matches found for round ${query.round}`));
3044
3140
  }
3045
- const rosterResults = await Promise.all(
3046
- matchItems.data.map((item) => client.fetchMatchRoster(item.match.matchId))
3141
+ const rosterResults = await batchedMap(
3142
+ matchItems.data,
3143
+ (item) => client.fetchMatchRoster(item.match.matchId)
3047
3144
  );
3048
3145
  const lineups = [];
3049
3146
  for (const rosterResult of rosterResults) {
@@ -3074,6 +3171,7 @@ async function fetchMatchResults(query) {
3074
3171
  return ok(transformMatchItems(itemsResult.data, query.season, competition));
3075
3172
  }
3076
3173
  case "footywire": {
3174
+ if (competition === "AFLW") return err(aflwUnsupportedError("footywire"));
3077
3175
  const client = new FootyWireClient();
3078
3176
  const result = await client.fetchSeasonResults(query.season);
3079
3177
  if (!result.success) return result;
@@ -3083,6 +3181,7 @@ async function fetchMatchResults(query) {
3083
3181
  return result;
3084
3182
  }
3085
3183
  case "afl-tables": {
3184
+ if (competition === "AFLW") return err(aflwUnsupportedError("afl-tables"));
3086
3185
  const client = new AflTablesClient();
3087
3186
  const result = await client.fetchSeasonResults(query.season);
3088
3187
  if (!result.success) return result;
@@ -3092,6 +3191,7 @@ async function fetchMatchResults(query) {
3092
3191
  return result;
3093
3192
  }
3094
3193
  case "squiggle": {
3194
+ if (competition === "AFLW") return err(aflwUnsupportedError("squiggle"));
3095
3195
  const client = new SquiggleClient();
3096
3196
  const result = await client.fetchGames(query.season, query.round ?? void 0, 100);
3097
3197
  if (!result.success) return result;
@@ -3117,7 +3217,7 @@ async function resolveTeamId(client, teamName, competition) {
3117
3217
  async function fetchFromAflApi(query) {
3118
3218
  const client = new AflApiClient();
3119
3219
  const competition = query.competition ?? "AFLM";
3120
- const season = query.season ?? (/* @__PURE__ */ new Date()).getFullYear();
3220
+ const season = query.season ?? resolveDefaultSeason(competition);
3121
3221
  const [teamIdResult, seasonResult] = await Promise.all([
3122
3222
  resolveTeamId(client, query.team, competition),
3123
3223
  client.resolveCompSeason(competition, season)
@@ -3140,8 +3240,8 @@ async function fetchFromAflApi(query) {
3140
3240
  jumperNumber: p.jumperNumber ?? null,
3141
3241
  position: p.position ?? null,
3142
3242
  dateOfBirth: p.player.dateOfBirth ?? null,
3143
- heightCm: p.player.heightInCm ?? null,
3144
- weightKg: p.player.weightInKg ?? null,
3243
+ heightCm: p.player.heightInCm || null,
3244
+ weightKg: p.player.weightInKg || null,
3145
3245
  gamesPlayed: null,
3146
3246
  goals: null,
3147
3247
  draftYear: p.player.draftYear ? Number.parseInt(p.player.draftYear, 10) || null : null,
@@ -3155,8 +3255,9 @@ async function fetchFromAflApi(query) {
3155
3255
  return ok(players);
3156
3256
  }
3157
3257
  async function fetchFromFootyWire(query) {
3158
- const client = new FootyWireClient();
3159
3258
  const competition = query.competition ?? "AFLM";
3259
+ if (competition === "AFLW") return err(aflwUnsupportedError("footywire"));
3260
+ const client = new FootyWireClient();
3160
3261
  const teamName = normaliseTeamName(query.team);
3161
3262
  const result = await client.fetchPlayerList(teamName);
3162
3263
  if (!result.success) return result;
@@ -3168,8 +3269,9 @@ async function fetchFromFootyWire(query) {
3168
3269
  return ok(players);
3169
3270
  }
3170
3271
  async function fetchFromAflTables(query) {
3171
- const client = new AflTablesClient();
3172
3272
  const competition = query.competition ?? "AFLM";
3273
+ if (competition === "AFLW") return err(aflwUnsupportedError("afl-tables"));
3274
+ const client = new AflTablesClient();
3173
3275
  const teamName = normaliseTeamName(query.team);
3174
3276
  const result = await client.fetchPlayerList(teamName);
3175
3277
  if (!result.success) return result;
@@ -3204,80 +3306,82 @@ function toNullable(value) {
3204
3306
  }
3205
3307
  function transformOne(item, matchId, season, roundNumber, competition, source, teamIdMap) {
3206
3308
  const inner = item.player.player.player;
3207
- const stats = item.playerStats.stats;
3208
- const clearances = stats.clearances;
3309
+ const stats = item.playerStats?.stats;
3310
+ const clearances = stats?.clearances;
3209
3311
  return {
3210
3312
  matchId,
3211
3313
  season,
3212
3314
  roundNumber,
3213
- team: normaliseTeamName(teamIdMap?.get(item.teamId) ?? item.teamId),
3315
+ team: normaliseTeamName(
3316
+ teamIdMap?.get(item.teamId) ?? AFL_API_TEAM_IDS.get(item.teamId) ?? item.teamId
3317
+ ),
3214
3318
  competition,
3215
3319
  playerId: inner.playerId,
3216
3320
  givenName: inner.playerName.givenName,
3217
3321
  surname: inner.playerName.surname,
3218
3322
  displayName: `${inner.playerName.givenName} ${inner.playerName.surname}`,
3219
3323
  jumperNumber: item.player.jumperNumber ?? null,
3220
- kicks: toNullable(stats.kicks),
3221
- handballs: toNullable(stats.handballs),
3222
- disposals: toNullable(stats.disposals),
3223
- marks: toNullable(stats.marks),
3224
- goals: toNullable(stats.goals),
3225
- behinds: toNullable(stats.behinds),
3226
- tackles: toNullable(stats.tackles),
3227
- hitouts: toNullable(stats.hitouts),
3228
- freesFor: toNullable(stats.freesFor),
3229
- freesAgainst: toNullable(stats.freesAgainst),
3230
- contestedPossessions: toNullable(stats.contestedPossessions),
3231
- uncontestedPossessions: toNullable(stats.uncontestedPossessions),
3232
- contestedMarks: toNullable(stats.contestedMarks),
3233
- intercepts: toNullable(stats.intercepts),
3324
+ kicks: toNullable(stats?.kicks),
3325
+ handballs: toNullable(stats?.handballs),
3326
+ disposals: toNullable(stats?.disposals),
3327
+ marks: toNullable(stats?.marks),
3328
+ goals: toNullable(stats?.goals),
3329
+ behinds: toNullable(stats?.behinds),
3330
+ tackles: toNullable(stats?.tackles),
3331
+ hitouts: toNullable(stats?.hitouts),
3332
+ freesFor: toNullable(stats?.freesFor),
3333
+ freesAgainst: toNullable(stats?.freesAgainst),
3334
+ contestedPossessions: toNullable(stats?.contestedPossessions),
3335
+ uncontestedPossessions: toNullable(stats?.uncontestedPossessions),
3336
+ contestedMarks: toNullable(stats?.contestedMarks),
3337
+ intercepts: toNullable(stats?.intercepts),
3234
3338
  centreClearances: toNullable(clearances?.centreClearances),
3235
3339
  stoppageClearances: toNullable(clearances?.stoppageClearances),
3236
3340
  totalClearances: toNullable(clearances?.totalClearances),
3237
- inside50s: toNullable(stats.inside50s),
3238
- rebound50s: toNullable(stats.rebound50s),
3239
- clangers: toNullable(stats.clangers),
3240
- turnovers: toNullable(stats.turnovers),
3241
- onePercenters: toNullable(stats.onePercenters),
3242
- bounces: toNullable(stats.bounces),
3243
- goalAssists: toNullable(stats.goalAssists),
3244
- disposalEfficiency: toNullable(stats.disposalEfficiency),
3245
- metresGained: toNullable(stats.metresGained),
3246
- goalAccuracy: toNullable(stats.goalAccuracy),
3247
- marksInside50: toNullable(stats.marksInside50),
3248
- tacklesInside50: toNullable(stats.tacklesInside50),
3249
- shotsAtGoal: toNullable(stats.shotsAtGoal),
3250
- scoreInvolvements: toNullable(stats.scoreInvolvements),
3251
- totalPossessions: toNullable(stats.totalPossessions),
3252
- timeOnGroundPercentage: toNullable(item.playerStats.timeOnGroundPercentage),
3253
- ratingPoints: toNullable(stats.ratingPoints),
3254
- dreamTeamPoints: toNullable(stats.dreamTeamPoints),
3255
- effectiveDisposals: toNullable(stats.extendedStats?.effectiveDisposals),
3256
- effectiveKicks: toNullable(stats.extendedStats?.effectiveKicks),
3257
- kickEfficiency: toNullable(stats.extendedStats?.kickEfficiency),
3258
- kickToHandballRatio: toNullable(stats.extendedStats?.kickToHandballRatio),
3259
- pressureActs: toNullable(stats.extendedStats?.pressureActs),
3260
- defHalfPressureActs: toNullable(stats.extendedStats?.defHalfPressureActs),
3261
- spoils: toNullable(stats.extendedStats?.spoils),
3262
- hitoutsToAdvantage: toNullable(stats.extendedStats?.hitoutsToAdvantage),
3263
- hitoutWinPercentage: toNullable(stats.extendedStats?.hitoutWinPercentage),
3264
- hitoutToAdvantageRate: toNullable(stats.extendedStats?.hitoutToAdvantageRate),
3265
- groundBallGets: toNullable(stats.extendedStats?.groundBallGets),
3266
- f50GroundBallGets: toNullable(stats.extendedStats?.f50GroundBallGets),
3267
- interceptMarks: toNullable(stats.extendedStats?.interceptMarks),
3268
- marksOnLead: toNullable(stats.extendedStats?.marksOnLead),
3269
- contestedPossessionRate: toNullable(stats.extendedStats?.contestedPossessionRate),
3270
- contestOffOneOnOnes: toNullable(stats.extendedStats?.contestOffOneOnOnes),
3271
- contestOffWins: toNullable(stats.extendedStats?.contestOffWins),
3272
- contestOffWinsPercentage: toNullable(stats.extendedStats?.contestOffWinsPercentage),
3273
- contestDefOneOnOnes: toNullable(stats.extendedStats?.contestDefOneOnOnes),
3274
- contestDefLosses: toNullable(stats.extendedStats?.contestDefLosses),
3275
- contestDefLossPercentage: toNullable(stats.extendedStats?.contestDefLossPercentage),
3276
- centreBounceAttendances: toNullable(stats.extendedStats?.centreBounceAttendances),
3277
- kickins: toNullable(stats.extendedStats?.kickins),
3278
- kickinsPlayon: toNullable(stats.extendedStats?.kickinsPlayon),
3279
- ruckContests: toNullable(stats.extendedStats?.ruckContests),
3280
- scoreLaunches: toNullable(stats.extendedStats?.scoreLaunches),
3341
+ inside50s: toNullable(stats?.inside50s),
3342
+ rebound50s: toNullable(stats?.rebound50s),
3343
+ clangers: toNullable(stats?.clangers),
3344
+ turnovers: toNullable(stats?.turnovers),
3345
+ onePercenters: toNullable(stats?.onePercenters),
3346
+ bounces: toNullable(stats?.bounces),
3347
+ goalAssists: toNullable(stats?.goalAssists),
3348
+ disposalEfficiency: toNullable(stats?.disposalEfficiency),
3349
+ metresGained: toNullable(stats?.metresGained),
3350
+ goalAccuracy: toNullable(stats?.goalAccuracy),
3351
+ marksInside50: toNullable(stats?.marksInside50),
3352
+ tacklesInside50: toNullable(stats?.tacklesInside50),
3353
+ shotsAtGoal: toNullable(stats?.shotsAtGoal),
3354
+ scoreInvolvements: toNullable(stats?.scoreInvolvements),
3355
+ totalPossessions: toNullable(stats?.totalPossessions),
3356
+ timeOnGroundPercentage: toNullable(item.playerStats?.timeOnGroundPercentage),
3357
+ ratingPoints: toNullable(stats?.ratingPoints),
3358
+ dreamTeamPoints: toNullable(stats?.dreamTeamPoints),
3359
+ effectiveDisposals: toNullable(stats?.extendedStats?.effectiveDisposals),
3360
+ effectiveKicks: toNullable(stats?.extendedStats?.effectiveKicks),
3361
+ kickEfficiency: toNullable(stats?.extendedStats?.kickEfficiency),
3362
+ kickToHandballRatio: toNullable(stats?.extendedStats?.kickToHandballRatio),
3363
+ pressureActs: toNullable(stats?.extendedStats?.pressureActs),
3364
+ defHalfPressureActs: toNullable(stats?.extendedStats?.defHalfPressureActs),
3365
+ spoils: toNullable(stats?.extendedStats?.spoils),
3366
+ hitoutsToAdvantage: toNullable(stats?.extendedStats?.hitoutsToAdvantage),
3367
+ hitoutWinPercentage: toNullable(stats?.extendedStats?.hitoutWinPercentage),
3368
+ hitoutToAdvantageRate: toNullable(stats?.extendedStats?.hitoutToAdvantageRate),
3369
+ groundBallGets: toNullable(stats?.extendedStats?.groundBallGets),
3370
+ f50GroundBallGets: toNullable(stats?.extendedStats?.f50GroundBallGets),
3371
+ interceptMarks: toNullable(stats?.extendedStats?.interceptMarks),
3372
+ marksOnLead: toNullable(stats?.extendedStats?.marksOnLead),
3373
+ contestedPossessionRate: toNullable(stats?.extendedStats?.contestedPossessionRate),
3374
+ contestOffOneOnOnes: toNullable(stats?.extendedStats?.contestOffOneOnOnes),
3375
+ contestOffWins: toNullable(stats?.extendedStats?.contestOffWins),
3376
+ contestOffWinsPercentage: toNullable(stats?.extendedStats?.contestOffWinsPercentage),
3377
+ contestDefOneOnOnes: toNullable(stats?.extendedStats?.contestDefOneOnOnes),
3378
+ contestDefLosses: toNullable(stats?.extendedStats?.contestDefLosses),
3379
+ contestDefLossPercentage: toNullable(stats?.extendedStats?.contestDefLossPercentage),
3380
+ centreBounceAttendances: toNullable(stats?.extendedStats?.centreBounceAttendances),
3381
+ kickins: toNullable(stats?.extendedStats?.kickins),
3382
+ kickinsPlayon: toNullable(stats?.extendedStats?.kickinsPlayon),
3383
+ ruckContests: toNullable(stats?.extendedStats?.ruckContests),
3384
+ scoreLaunches: toNullable(stats?.extendedStats?.scoreLaunches),
3281
3385
  source
3282
3386
  };
3283
3387
  }
@@ -3334,8 +3438,9 @@ async function fetchPlayerStats(query) {
3334
3438
  teamIdMap.set(item.match.homeTeamId, item.match.homeTeam.name);
3335
3439
  teamIdMap.set(item.match.awayTeamId, item.match.awayTeam.name);
3336
3440
  }
3337
- const statsResults = await Promise.all(
3338
- matchItemsResult.data.map((item) => client.fetchPlayerStats(item.match.matchId))
3441
+ const statsResults = await batchedMap(
3442
+ matchItemsResult.data,
3443
+ (item) => client.fetchPlayerStats(item.match.matchId)
3339
3444
  );
3340
3445
  const allStats = [];
3341
3446
  for (let i = 0; i < statsResults.length; i++) {
@@ -3359,6 +3464,7 @@ async function fetchPlayerStats(query) {
3359
3464
  return ok(allStats);
3360
3465
  }
3361
3466
  case "footywire": {
3467
+ if (competition === "AFLW") return err(aflwUnsupportedError("footywire"));
3362
3468
  const fwClient = new FootyWireClient();
3363
3469
  const idsResult = await fwClient.fetchSeasonMatchIds(query.season);
3364
3470
  if (!idsResult.success) return idsResult;
@@ -3388,6 +3494,7 @@ async function fetchPlayerStats(query) {
3388
3494
  return ok(allStats);
3389
3495
  }
3390
3496
  case "afl-tables": {
3497
+ if (competition === "AFLW") return err(aflwUnsupportedError("afl-tables"));
3391
3498
  const atClient = new AflTablesClient();
3392
3499
  const atResult = await atClient.fetchSeasonPlayerStats(query.season);
3393
3500
  if (!atResult.success) return atResult;
@@ -3411,7 +3518,37 @@ async function fetchTeamStats(query) {
3411
3518
  }
3412
3519
  case "afl-tables": {
3413
3520
  const client = new AflTablesClient();
3414
- return client.fetchTeamStats(query.season);
3521
+ const statsResult = await client.fetchTeamStats(query.season);
3522
+ if (!statsResult.success) return statsResult;
3523
+ const needsGp = statsResult.data.some((e) => e.gamesPlayed === 0);
3524
+ const gpMap = /* @__PURE__ */ new Map();
3525
+ if (needsGp) {
3526
+ const resultsResult = await client.fetchSeasonResults(query.season);
3527
+ if (resultsResult.success) {
3528
+ for (const m of resultsResult.data) {
3529
+ gpMap.set(m.homeTeam, (gpMap.get(m.homeTeam) ?? 0) + 1);
3530
+ gpMap.set(m.awayTeam, (gpMap.get(m.awayTeam) ?? 0) + 1);
3531
+ }
3532
+ }
3533
+ }
3534
+ const enriched = statsResult.data.map((entry) => ({
3535
+ ...entry,
3536
+ gamesPlayed: gpMap.get(entry.team) ?? entry.gamesPlayed
3537
+ }));
3538
+ if (summaryType === "averages") {
3539
+ return ok(
3540
+ enriched.map((entry) => ({
3541
+ ...entry,
3542
+ stats: Object.fromEntries(
3543
+ Object.entries(entry.stats).map(([k, v]) => [
3544
+ k,
3545
+ entry.gamesPlayed > 0 ? +(v / entry.gamesPlayed).toFixed(1) : 0
3546
+ ])
3547
+ )
3548
+ }))
3549
+ );
3550
+ }
3551
+ return ok(enriched);
3415
3552
  }
3416
3553
  case "afl-api":
3417
3554
  case "squiggle":
@@ -3430,19 +3567,30 @@ async function fetchTeamStats(query) {
3430
3567
  function teamTypeForComp(comp) {
3431
3568
  return comp === "AFLW" ? "WOMEN" : "MEN";
3432
3569
  }
3433
- async function fetchTeams(query) {
3434
- const client = new AflApiClient();
3435
- const teamType = query?.teamType ?? teamTypeForComp(query?.competition ?? "AFLM");
3436
- const result = await client.fetchTeams(teamType);
3437
- if (!result.success) return result;
3438
- const competition = query?.competition ?? "AFLM";
3439
- const teams = result.data.map((t) => ({
3570
+ function toTeams(data, competition) {
3571
+ return data.map((t) => ({
3440
3572
  teamId: String(t.id),
3441
3573
  name: normaliseTeamName(t.name),
3442
3574
  abbreviation: t.abbreviation ?? "",
3443
3575
  competition
3444
3576
  })).filter((t) => AFL_SENIOR_TEAMS.has(t.name));
3445
- return ok(teams);
3577
+ }
3578
+ async function fetchTeams(query) {
3579
+ const client = new AflApiClient();
3580
+ if (!query?.competition && !query?.teamType) {
3581
+ const [menResult, womenResult] = await Promise.all([
3582
+ client.fetchTeams("MEN"),
3583
+ client.fetchTeams("WOMEN")
3584
+ ]);
3585
+ if (!menResult.success) return menResult;
3586
+ if (!womenResult.success) return womenResult;
3587
+ return ok([...toTeams(menResult.data, "AFLM"), ...toTeams(womenResult.data, "AFLW")]);
3588
+ }
3589
+ const competition = query?.competition ?? "AFLM";
3590
+ const teamType = query?.teamType ?? teamTypeForComp(competition);
3591
+ const result = await client.fetchTeams(teamType);
3592
+ if (!result.success) return result;
3593
+ return ok(toTeams(result.data, competition));
3446
3594
  }
3447
3595
  async function fetchSquad(query) {
3448
3596
  const client = new AflApiClient();
@@ -3463,8 +3611,8 @@ async function fetchSquad(query) {
3463
3611
  jumperNumber: p.jumperNumber ?? null,
3464
3612
  position: p.position ?? null,
3465
3613
  dateOfBirth: p.player.dateOfBirth ? new Date(p.player.dateOfBirth) : null,
3466
- heightCm: p.player.heightInCm ?? null,
3467
- weightKg: p.player.weightInKg ?? null,
3614
+ heightCm: p.player.heightInCm || null,
3615
+ weightKg: p.player.weightInKg || null,
3468
3616
  draftYear: p.player.draftYear ? Number.parseInt(p.player.draftYear, 10) || null : null,
3469
3617
  draftPosition: p.player.draftPosition ? Number.parseInt(p.player.draftPosition, 10) || null : null,
3470
3618
  draftType: p.player.draftType ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fitzroy",
3
- "version": "1.1.1",
3
+ "version": "1.3.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",