display.rs (9276B)
1 use crate::models::{Match, StandingsResponse}; 2 use chrono::Local; 3 use prettytable::{Cell, Row, Table, format}; 4 5 const STATUS_ICONS: &[(&str, &str)] = &[("FINISHED", "✓"), ("SCHEDULED", "⏰"), ("IN_PLAY", "▶")]; 6 7 const DEFAULT_STATUS_ICON: &str = "•"; 8 9 pub fn filter_matches( 10 matches: Vec<Match>, 11 team_filter: Option<&str>, 12 show_all: bool, 13 ) -> Vec<Match> { 14 let today_start = Local::now() 15 .date_naive() 16 .and_hms_opt(0, 0, 0) 17 .unwrap() 18 .and_local_timezone(Local) 19 .unwrap(); 20 21 matches 22 .into_iter() 23 .filter(|m| { 24 if !show_all { 25 if let Some(mt) = m.utc_date { 26 if mt < today_start { 27 return false; 28 } 29 } else { 30 return false; 31 } 32 } 33 34 if let Some(team) = team_filter { 35 let team_lower = team.to_lowercase(); 36 let home_match = m 37 .home_team 38 .name 39 .as_ref() 40 .is_some_and(|n| n.to_lowercase().contains(&team_lower)) 41 || m.home_team 42 .tla 43 .as_ref() 44 .is_some_and(|t| t.to_lowercase().contains(&team_lower)); 45 let away_match = m 46 .away_team 47 .name 48 .as_ref() 49 .is_some_and(|n| n.to_lowercase().contains(&team_lower)) 50 || m.away_team 51 .tla 52 .as_ref() 53 .is_some_and(|t| t.to_lowercase().contains(&team_lower)); 54 55 home_match || away_match 56 } else { 57 true 58 } 59 }) 60 .collect() 61 } 62 63 pub fn display_table(matches: &[Match], show_competition: bool) { 64 if matches.is_empty() { 65 println!("No matches found."); 66 return; 67 } 68 69 let mut table = Table::new(); 70 table.set_format(*format::consts::FORMAT_BOX_CHARS); 71 72 let mut header = vec![ 73 Cell::new("MD").style_spec("Fb"), 74 Cell::new("Date & Time").style_spec("Fb"), 75 ]; 76 if show_competition { 77 header.push(Cell::new("Competition").style_spec("Fb")); 78 } 79 header.extend([ 80 Cell::new("Home Team").style_spec("Fb"), 81 Cell::new("Score").style_spec("Fb"), 82 Cell::new("Away Team").style_spec("Fb"), 83 Cell::new("Status").style_spec("Fb"), 84 ]); 85 table.add_row(Row::new(header)); 86 87 for m in matches { 88 let matchday = m 89 .matchday 90 .map_or_else(|| "-".to_string(), |md| md.to_string()); 91 92 let date = m.utc_date.map_or_else( 93 || "TBD".to_string(), 94 |dt| { 95 dt.with_timezone(&Local) 96 .format("%b %d, %Y %H:%M %:z") 97 .to_string() 98 }, 99 ); 100 101 let score = match (m.score.full_time.home, m.score.full_time.away) { 102 (Some(h), Some(a)) => format!("{h} - {a}"), 103 _ => "vs".to_string(), 104 }; 105 106 let status = m.status.as_deref().unwrap_or("UNKNOWN"); 107 let status_icon = STATUS_ICONS 108 .iter() 109 .find(|(s, _)| *s == status) 110 .map_or(DEFAULT_STATUS_ICON, |(_, icon)| *icon); 111 112 let home_team = m.home_team.name.as_deref().unwrap_or("Unknown"); 113 let away_team = m.away_team.name.as_deref().unwrap_or("Unknown"); 114 115 let mut cells = vec![Cell::new(&matchday), Cell::new(&date)]; 116 if show_competition { 117 let competition = m 118 .competition 119 .as_ref() 120 .and_then(|c| c.name.as_deref()) 121 .unwrap_or("-"); 122 cells.push(Cell::new(competition)); 123 } 124 cells.extend([ 125 Cell::new(home_team).style_spec("r"), 126 Cell::new(&score).style_spec("Fyc"), 127 Cell::new(away_team).style_spec("l"), 128 Cell::new(&format!("{status_icon} {status}")), 129 ]); 130 table.add_row(Row::new(cells)); 131 } 132 133 table.printstd(); 134 } 135 136 pub fn display_standings(standings_response: StandingsResponse) { 137 let mut printed = false; 138 for standing in standings_response.standings { 139 if standing.table.is_empty() || printed { 140 continue; 141 } 142 143 let mut table = Table::new(); 144 table.set_format(*format::consts::FORMAT_BOX_CHARS); 145 146 table.add_row(Row::new(vec![ 147 Cell::new("Pos").style_spec("Fb"), 148 Cell::new("Team").style_spec("Fb"), 149 Cell::new("P").style_spec("Fb"), 150 Cell::new("W").style_spec("Fb"), 151 Cell::new("D").style_spec("Fb"), 152 Cell::new("L").style_spec("Fb"), 153 Cell::new("GF").style_spec("Fb"), 154 Cell::new("GA").style_spec("Fb"), 155 Cell::new("GD").style_spec("Fb"), 156 Cell::new("Pts").style_spec("Fb"), 157 ])); 158 159 for entry in &standing.table { 160 let gd_str = if entry.goal_difference >= 0 { 161 format!("+{}", entry.goal_difference) 162 } else { 163 entry.goal_difference.to_string() 164 }; 165 166 table.add_row(Row::new(vec![ 167 Cell::new(&entry.position.to_string()).style_spec("c"), 168 Cell::new(&entry.team.name), 169 Cell::new(&entry.played_games.to_string()).style_spec("c"), 170 Cell::new(&entry.won.to_string()).style_spec("c"), 171 Cell::new(&entry.draw.to_string()).style_spec("c"), 172 Cell::new(&entry.lost.to_string()).style_spec("c"), 173 Cell::new(&entry.goals_for.to_string()).style_spec("c"), 174 Cell::new(&entry.goals_against.to_string()).style_spec("c"), 175 Cell::new(&gd_str).style_spec("c"), 176 Cell::new(&entry.points.to_string()).style_spec("Fbc"), 177 ])); 178 } 179 180 table.printstd(); 181 printed = true; 182 } 183 } 184 185 #[cfg(test)] 186 mod tests { 187 use super::*; 188 use crate::models::{Score, ScoreDetail, Team}; 189 use chrono::{TimeZone, Utc}; 190 191 fn create_match(date: Option<chrono::DateTime<Utc>>, home: &str, away: &str) -> Match { 192 Match { 193 utc_date: date, 194 status: Some("SCHEDULED".to_string()), 195 matchday: Some(1), 196 home_team: Team { 197 name: Some(home.to_string()), 198 tla: Some(home[..3].to_string()), 199 }, 200 away_team: Team { 201 name: Some(away.to_string()), 202 tla: Some(away[..3].to_string()), 203 }, 204 score: Score { 205 full_time: ScoreDetail::default(), 206 }, 207 competition: None, 208 } 209 } 210 211 #[test] 212 fn test_filter_matches_empty() { 213 let matches = vec![]; 214 let result = filter_matches(matches, None, false); 215 assert!(result.is_empty()); 216 } 217 218 #[test] 219 fn test_filter_matches_no_filter_show_all() { 220 let past = Utc.with_ymd_and_hms(2020, 1, 1, 12, 0, 0).unwrap(); 221 let future = Utc.with_ymd_and_hms(2030, 1, 1, 12, 0, 0).unwrap(); 222 223 let matches = vec![ 224 create_match(Some(past), "Arsenal", "Chelsea"), 225 create_match(Some(future), "Liverpool", "Man City"), 226 ]; 227 228 let result = filter_matches(matches, None, true); 229 assert_eq!(result.len(), 2); 230 } 231 232 #[test] 233 fn test_filter_matches_no_filter_show_future_only() { 234 let past = Utc.with_ymd_and_hms(2020, 1, 1, 12, 0, 0).unwrap(); 235 let future = Utc.with_ymd_and_hms(2030, 1, 1, 12, 0, 0).unwrap(); 236 237 let matches = vec![ 238 create_match(Some(past), "Arsenal", "Chelsea"), 239 create_match(Some(future), "Liverpool", "Man City"), 240 ]; 241 242 let result = filter_matches(matches, None, false); 243 assert_eq!(result.len(), 1); 244 assert_eq!(result[0].home_team.name, Some("Liverpool".to_string())); 245 } 246 247 #[test] 248 fn test_filter_matches_with_team_filter() { 249 let future = Utc.with_ymd_and_hms(2030, 1, 1, 12, 0, 0).unwrap(); 250 251 let matches = vec![ 252 create_match(Some(future), "Arsenal", "Chelsea"), 253 create_match(Some(future), "Liverpool", "Arsenal"), 254 create_match(Some(future), "Man City", "Chelsea"), 255 ]; 256 257 let result = filter_matches(matches, Some("Arsenal"), true); 258 assert_eq!(result.len(), 2); 259 } 260 261 #[test] 262 fn test_filter_matches_team_filter_case_insensitive() { 263 let future = Utc.with_ymd_and_hms(2030, 1, 1, 12, 0, 0).unwrap(); 264 265 let matches = vec![create_match(Some(future), "Arsenal", "Chelsea")]; 266 267 let result = filter_matches(matches.clone(), Some("arsenal"), true); 268 assert_eq!(result.len(), 1); 269 270 let result = filter_matches(matches, Some("ARSENAL"), true); 271 assert_eq!(result.len(), 1); 272 } 273 274 #[test] 275 fn test_filter_matches_team_filter_by_tla() { 276 let future = Utc.with_ymd_and_hms(2030, 1, 1, 12, 0, 0).unwrap(); 277 278 let matches = vec![create_match(Some(future), "Arsenal", "Chelsea")]; 279 280 let result = filter_matches(matches, Some("ARS"), true); 281 assert_eq!(result.len(), 1); 282 } 283 }