wc_data/domain.rs
1//! Normalized, provider-agnostic domain model for the World Cup.
2//!
3//! Every backend maps its upstream representation into these types so the TUI
4//! and the rest of the app depend only on this module, never on a specific data
5//! source. Times are always stored in UTC ([`OffsetDateTime`]); the UI converts
6//! to the user's local zone for display.
7
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10
11/// A stage of the tournament. The 2026 format is a 48-team group stage (12
12/// groups of 4) followed by a 32-team knockout.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub enum Stage {
15 /// Group stage (groups A–L).
16 GroupStage,
17 /// Round of 32 (first knockout round in the 2026 format).
18 RoundOf32,
19 /// Round of 16.
20 RoundOf16,
21 /// Quarter-final.
22 QuarterFinal,
23 /// Semi-final.
24 SemiFinal,
25 /// Third-place play-off.
26 ThirdPlace,
27 /// Final.
28 Final,
29}
30
31impl Stage {
32 /// All knockout rounds in bracket order (excludes the group stage).
33 #[must_use]
34 pub fn knockout_order() -> [Stage; 6] {
35 [
36 Stage::RoundOf32,
37 Stage::RoundOf16,
38 Stage::QuarterFinal,
39 Stage::SemiFinal,
40 Stage::ThirdPlace,
41 Stage::Final,
42 ]
43 }
44
45 /// Whether this stage is part of the knockout bracket.
46 #[must_use]
47 pub fn is_knockout(self) -> bool {
48 !matches!(self, Stage::GroupStage)
49 }
50
51 /// A short human-readable label.
52 #[must_use]
53 pub fn label(self) -> &'static str {
54 match self {
55 Stage::GroupStage => "Group Stage",
56 Stage::RoundOf32 => "Round of 32",
57 Stage::RoundOf16 => "Round of 16",
58 Stage::QuarterFinal => "Quarter-final",
59 Stage::SemiFinal => "Semi-final",
60 Stage::ThirdPlace => "Third place",
61 Stage::Final => "Final",
62 }
63 }
64}
65
66/// A national team.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct Team {
69 /// Provider-specific identifier (opaque to the UI).
70 pub id: String,
71 /// Display name, e.g. "Canada".
72 pub name: String,
73 /// Short code, e.g. "CAN".
74 pub abbreviation: String,
75 /// ISO-ish country code where available.
76 pub country_code: Option<String>,
77 /// URL to a crest/flag image, where available.
78 pub crest_url: Option<String>,
79}
80
81impl Team {
82 /// A placeholder team for not-yet-decided bracket slots.
83 #[must_use]
84 pub fn placeholder(label: impl Into<String>) -> Self {
85 let name = label.into();
86 Self {
87 id: String::new(),
88 abbreviation: name.chars().take(3).collect::<String>().to_uppercase(),
89 name,
90 country_code: None,
91 crest_url: None,
92 }
93 }
94
95 /// Whether this is a placeholder (unknown) team.
96 #[must_use]
97 pub fn is_placeholder(&self) -> bool {
98 self.id.is_empty()
99 }
100}
101
102/// The live/finished state of a match.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub enum MatchStatus {
105 /// Not started yet.
106 Scheduled,
107 /// In play. `minute` is the displayed clock minute when known.
108 Live {
109 /// Current match minute, when reported.
110 minute: Option<u16>,
111 /// Optional period detail, e.g. "1st Half", "ET".
112 detail: Option<String>,
113 },
114 /// Half-time interval.
115 HalfTime,
116 /// Finished in regulation.
117 FullTime,
118 /// Finished after extra time.
119 AfterExtraTime,
120 /// Decided on penalties.
121 Penalties,
122 /// Postponed.
123 Postponed,
124 /// Cancelled.
125 Canceled,
126 /// Unknown / unmapped status.
127 Unknown,
128}
129
130impl MatchStatus {
131 /// Whether the match is currently being played (including half-time).
132 #[must_use]
133 pub fn is_live(&self) -> bool {
134 matches!(self, MatchStatus::Live { .. } | MatchStatus::HalfTime)
135 }
136
137 /// Whether the match has finished by any means.
138 #[must_use]
139 pub fn is_finished(&self) -> bool {
140 matches!(
141 self,
142 MatchStatus::FullTime | MatchStatus::AfterExtraTime | MatchStatus::Penalties
143 )
144 }
145}
146
147/// The score of a match, including a penalty-shootout tally when applicable.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
149pub struct Score {
150 /// Home goals.
151 pub home: u8,
152 /// Away goals.
153 pub away: u8,
154 /// Home penalty-shootout goals, when the match went to penalties.
155 pub home_pens: Option<u8>,
156 /// Away penalty-shootout goals, when the match went to penalties.
157 pub away_pens: Option<u8>,
158}
159
160/// A single fixture.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct Match {
163 /// Provider-specific identifier used to fetch detail.
164 pub id: String,
165 /// Tournament stage.
166 pub stage: Stage,
167 /// Group letter ("A".."L") for group-stage matches.
168 pub group: Option<String>,
169 /// Home team.
170 pub home: Team,
171 /// Away team.
172 pub away: Team,
173 /// Current score, if the match has started.
174 pub score: Option<Score>,
175 /// Match status.
176 pub status: MatchStatus,
177 /// Kickoff time in UTC.
178 #[serde(with = "time::serde::rfc3339")]
179 pub kickoff: OffsetDateTime,
180 /// Venue name, where available.
181 pub venue: Option<String>,
182}
183
184/// One team's row in a group table.
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186pub struct GroupStanding {
187 /// The team this row describes.
188 pub team: Team,
189 /// 1-based rank within the group.
190 pub rank: u8,
191 /// Matches played.
192 pub played: u8,
193 /// Wins.
194 pub won: u8,
195 /// Draws.
196 pub drawn: u8,
197 /// Losses.
198 pub lost: u8,
199 /// Goals scored.
200 pub goals_for: u16,
201 /// Goals conceded.
202 pub goals_against: u16,
203 /// Goal difference (`goals_for - goals_against`).
204 pub goal_diff: i16,
205 /// Points.
206 pub points: u16,
207}
208
209/// A group table.
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct Group {
212 /// Group name/letter, e.g. "A".
213 pub name: String,
214 /// Standings, already sorted by rank.
215 pub standings: Vec<GroupStanding>,
216}
217
218/// The kind of in-match event on a timeline.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220pub enum MatchEventKind {
221 /// A goal from open play.
222 Goal,
223 /// An own goal.
224 OwnGoal,
225 /// A goal from a penalty.
226 PenaltyGoal,
227 /// A missed/saved penalty.
228 PenaltyMiss,
229 /// A yellow card.
230 YellowCard,
231 /// A second yellow (booking leading to a red).
232 SecondYellow,
233 /// A straight red card.
234 RedCard,
235 /// A substitution.
236 Substitution,
237 /// A VAR decision.
238 Var,
239 /// Anything else.
240 Other,
241}
242
243/// A single timeline event in a match.
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct MatchEvent {
246 /// Match minute, when known.
247 pub minute: Option<u16>,
248 /// Added/stoppage-time minutes beyond `minute`, when known.
249 pub stoppage: Option<u16>,
250 /// What happened.
251 pub kind: MatchEventKind,
252 /// The team this event belongs to (provider team id), when known.
253 pub team_id: Option<String>,
254 /// Primary player involved (scorer, booked player, player coming on).
255 pub player: Option<String>,
256 /// Free-form detail (assist, reason, player going off, etc.).
257 pub detail: Option<String>,
258}
259
260/// A player in a lineup.
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct Player {
263 /// Player name.
264 pub name: String,
265 /// Shirt number, when known.
266 pub number: Option<u8>,
267 /// Position abbreviation, when known.
268 pub position: Option<String>,
269}
270
271/// A team's lineup for a match.
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273pub struct Lineup {
274 /// Provider team id this lineup belongs to.
275 pub team_id: String,
276 /// Formation string, e.g. "4-3-3", when known.
277 pub formation: Option<String>,
278 /// Starting XI.
279 pub starters: Vec<Player>,
280 /// Substitutes.
281 pub substitutes: Vec<Player>,
282}
283
284/// A single comparable team statistic (e.g. possession, shots).
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct TeamStat {
287 /// Stat label, e.g. "Possession".
288 pub label: String,
289 /// Home value, formatted for display (e.g. "57%").
290 pub home: String,
291 /// Away value, formatted for display.
292 pub away: String,
293}
294
295/// Full detail for a single match: the fixture plus timeline, lineups, stats.
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297pub struct MatchDetail {
298 /// The fixture summary (teams, score, status).
299 pub summary: Match,
300 /// Timeline events, ordered chronologically.
301 pub events: Vec<MatchEvent>,
302 /// Lineups, typically one per team when available.
303 pub lineups: Vec<Lineup>,
304 /// Comparable team statistics.
305 pub stats: Vec<TeamStat>,
306}
307
308/// One round of the knockout bracket.
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310pub struct BracketRound {
311 /// The stage this round represents.
312 pub stage: Stage,
313 /// Matches in this round, in bracket order.
314 pub matches: Vec<Match>,
315}
316
317/// The knockout bracket as an ordered list of rounds.
318#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
319pub struct Bracket {
320 /// Rounds from [`Stage::RoundOf32`] through [`Stage::Final`].
321 pub rounds: Vec<BracketRound>,
322}
323
324/// A scheduling window for a stage, from the competition calendar.
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
326pub struct StageWindow {
327 /// The stage.
328 pub stage: Stage,
329 /// Provider label, e.g. "Round of 32".
330 pub label: String,
331 /// Window start (UTC).
332 #[serde(with = "time::serde::rfc3339")]
333 pub start: OffsetDateTime,
334 /// Window end (UTC).
335 #[serde(with = "time::serde::rfc3339")]
336 pub end: OffsetDateTime,
337}
338
339/// The competition calendar: the set of stage windows.
340#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
341pub struct Calendar {
342 /// Stage windows, in chronological order.
343 pub stages: Vec<StageWindow>,
344}