Skip to main content

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}