Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 46 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,51 @@
# Changelog

## Unreleased

- Added `getTeamEvents(team, {year})` for a team's full Statbotics history
across events (part of issue #9). It reuses the `/team_events` endpoint with
a `team` filter instead of `event`, and optionally a `year`, returning every
team-event row for that team sorted newest season first, then by event key.
List endpoints return an empty list on 404, so an unknown team or a team
with no recorded events answers with an empty list rather than throwing.
## 0.4.0

Breaking. The live v3 API does not send the shape this client's EPA and
team-event models were written against, so `getEventTeams` threw a
`TypeError` on every real response. Statbotics answered HTTP 500 on every
data endpoint from 2026-06-15 to around 2026-09-02, which is why nothing
caught it sooner: the models could not be exercised against a live body, and
the tests asserted against hand-written maps that agreed with the models
rather than with the API.

- `StatboticsEpa` now decodes the shape the API sends. `epa.total_points` is
a bare number, not a `{mean, sd}` object, and the per-phase measures live
under `epa.breakdown`. Renamed to match: `totalPointsMean` ->
`totalPoints`, `autoPointsMean` -> `autoPoints`, `teleopPointsMean` ->
`teleopPoints`, `endgamePointsMean` -> `endgamePoints`. Added `unitless`
and `norm`, the cross-season comparable scales. Removed `totalPointsSd`,
which the API no longer reports anywhere.
- `StatboticsTeamEvent` reads its record from the nested `record` object:
`wins`/`losses`/`ties` from `record.total`, and `rank`/`numTeams` from
`record.qual`. They were previously read as top-level fields, which the
API has no such thing as, so every one decoded to 0 or null.
- Added `getTeamYears(team)` and `StatboticsTeamYear` for season-over-season
history, completing issue #9. One row per season with that season's EPA,
record, and worldwide EPA rank. Note `/team_years` reports its record flat,
unlike `/team_events`.
- No cache migration, deliberately. An on-device last-good cache (#512)
cannot hold team-event data written by an earlier version: the cache writer
requires a successful decode, decoding threw on every live response, and
every release of this package (0.1.0 on 2026-08-05 onward) shipped inside
the outage. A consumer that reads an old record gets a decode miss its own
cache layer already treats as no cache.
- Model tests now assert against captured live response bodies in
`test/fixtures/`, so an API shape change fails a test.
- Corrected two doc comments: an unknown team number answers 200 with `[]`,
not 404. A team number of 100000 or more is rejected with HTTP 422.
- Includes `getTeamEvents(team, {year})`, merged but never released under
0.3.2: a team's history across events, from `/team_events` with a `team`
filter, sorted newest season first then by event key.

Migrating: rename the four EPA field reads, and drop any use of
`totalPointsSd`. That is the whole of it. Nothing else that already existed
moved or changed signature, so code reading `record`, `rank` or `numTeams` off
a `StatboticsTeamEvent` needs no edit, though it was reading zeros before
this. The rest of the release is additive: `getTeamYears`,
`StatboticsTeamYear`, `getTeamEvents`, and the `unitless` and `norm` fields
are all new surface no existing caller has to adopt.

## 0.3.1

Expand Down
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,39 @@ call `close()` when you are done so the underlying HTTP client is released.
| `getEvents(year)` | `GET /events?year={year}` | `List<StatboticsEvent>` |
| `getEventTeams(eventKey)` | `GET /team_events?event={eventKey}` | `List<StatboticsTeamEvent>` |
| `getTeamEvents(team, {year})` | `GET /team_events?team={team}[&year={year}]` | `List<StatboticsTeamEvent>` |
| `getTeamYears(team)` | `GET /team_years?team={team}` | `List<StatboticsTeamYear>` |
| `getEventTeamsBasic(eventKey)` | `GET /teams?event={eventKey}` | `List<StatboticsTeamBasic>` |
| `getEventMatches(eventKey)` | `GET /matches?event={eventKey}` | `List<StatboticsMatch>` |

- `getEventTeams` sorts results by rank ascending.
- `getTeamEvents` sorts results newest season first, then by event key, so a
team's most recent results lead. Pass `year` to narrow to one season.
- `getTeamYears` sorts results newest season first, one row per season the
team competed in. Compare seasons on `epa.unitless` or `epa.norm`, not
`epa.totalPoints`: point values belong to a season's game, so a 2002 total of
16.6 and a 2025 total of 92.77 say nothing about which robot was better.
- `getEvents` sorts results by week then name.
- `getEventMatches` sorts results by comp level (`qm`, `ef`, `qf`, `sf`, `f`)
then match number.
- `getEventTeamsBasic` returns basic team info (number + nickname) and returns
an empty list if the endpoint is unavailable.

The API is public and read-only, so models only decode the fields each endpoint
exposes (EPA means and standard deviations on `StatboticsTeamEvent`, alliance
teams on `StatboticsMatch`, dates and location on `StatboticsEvent`). Each model
also round-trips through `toJson` for caching.
exposes (EPA and the per-phase breakdown on `StatboticsTeamEvent` and
`StatboticsTeamYear`, alliance teams on `StatboticsMatch`, dates and location
on `StatboticsEvent`). Each model also round-trips through `toJson` for
caching.

`StatboticsEpa.totalPoints` is the point estimate the API reports at
`epa.total_points`, not an average over matches. The per-phase measures
(`autoPoints`, `teleopPoints`, `endgamePoints`) come from `epa.breakdown` and
are null for seasons predating that game's scoring split, so treat every EPA
field as nullable.

Model tests assert against captured live response bodies in `test/fixtures/`,
not only hand-written maps, so a change to the API's shape fails a test rather
than reaching a consumer. Refresh a fixture by re-running the `curl` in the
comment above `_fixture` in the test file.

## Retries and backoff

Expand Down
39 changes: 37 additions & 2 deletions lib/src/statbotics_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,9 @@ class StatboticsClient {
/// just drops the `event` filter and adds `team` (and optionally `year`),
/// answering with every row that mentions the team instead of every row for
/// one event. Results are sorted by year descending, then event key
/// ascending. Returns an empty list on 404 (an unknown team number) or if
/// the team has no recorded events.
/// ascending. For a team Statbotics has no events for, the endpoint answers
/// 200 with `[]`, so an unknown team number comes back as an empty list
/// rather than an error.
///
/// The 1000-row cap is fixed rather than a parameter, the way the other
/// list methods fix theirs: a team plays a handful of events a season, so
Expand Down Expand Up @@ -105,6 +106,40 @@ class StatboticsClient {
return results;
}

/// `GET /v3/team_years?team={team}&limit=100` — returns one row per season
/// the team competed in, newest season first.
///
/// The season-over-season view [getTeamEvents] cannot give: that one answers
/// per event within a season, this one answers per season. For a team
/// Statbotics has no seasons for, the endpoint answers 200 with `[]`, so an
/// unknown team number comes back as an empty list rather than an error. A
/// [team] of 100000 or more is rejected with HTTP 422 and throws
/// [StatboticsApiException]: that is a malformed request, not an unknown
/// team.
///
/// The 100-row cap is fixed the way the other list methods fix theirs. FRC
/// has run since 1992, so no team has more seasons than that.
Future<List<StatboticsTeamYear>> getTeamYears(int team) async {
final body = await _get(
'/team_years',
queryParameters: <String, String>{
'team': team.toString(),
'limit': '100',
},
);
if (body == null) return const <StatboticsTeamYear>[];
final list = jsonDecode(body) as List<dynamic>;
final results = list
.map(
(json) => StatboticsTeamYear.fromJson(
(json as Map).cast<String, dynamic>(),
),
)
.toList(growable: true);
results.sort((a, b) => b.year.compareTo(a.year));
return results;
}

/// `GET /v3/events?year={year}` — returns all events for the given year,
/// sorted by week then name.
Future<List<StatboticsEvent>> getEvents(int year) async {
Expand Down
Loading