Skip to content

Commit 004a572

Browse files
authored
[arenabuddy] Add match statistics page with detailed win rate tracking (#299)
# Add Match Statistics Page This PR adds a new statistics page to the application that provides users with detailed insights into their match performance. The statistics include: - Overall match and game records with win rates - Performance breakdown when on the play vs. on the draw - Mulligan statistics showing win rates based on cards kept - Top opponents with match records Key changes: - Created a new `stats.rs` module with components for displaying statistics - Added a Stats route to the navigation menu - Implemented backend service methods to retrieve match statistics - Created data models for representing match statistics - Added database queries to calculate win rates and other metrics - Fixed a bug in the match replay logic related to game number tracking The UI is responsive with a grid layout that adjusts based on screen size and includes cards for different stat categories with clean formatting of win/loss records and percentages.
1 parent 80b2838 commit 004a572

File tree

10 files changed

+459
-78
lines changed

10 files changed

+459
-78
lines changed

arenabuddy/arenabuddy/assets/tailwind.css

Lines changed: 23 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,9 @@
308308
.mr-2 {
309309
margin-right: calc(var(--spacing) * 2);
310310
}
311+
.mr-4 {
312+
margin-right: calc(var(--spacing) * 4);
313+
}
311314
.mb-1 {
312315
margin-bottom: calc(var(--spacing) * 1);
313316
}
@@ -452,6 +455,9 @@
452455
.cursor-pointer {
453456
cursor: pointer;
454457
}
458+
.grid-cols-1 {
459+
grid-template-columns: repeat(1, minmax(0, 1fr));
460+
}
455461
.grid-cols-2 {
456462
grid-template-columns: repeat(2, minmax(0, 1fr));
457463
}
@@ -627,6 +633,9 @@
627633
.border-blue-600 {
628634
border-color: var(--color-blue-600);
629635
}
636+
.border-gray-100 {
637+
border-color: var(--color-gray-100);
638+
}
630639
.border-gray-200 {
631640
border-color: var(--color-gray-200);
632641
}
@@ -793,6 +802,9 @@
793802
.py-8 {
794803
padding-block: calc(var(--spacing) * 8);
795804
}
805+
.pt-2 {
806+
padding-top: calc(var(--spacing) * 2);
807+
}
796808
.pt-4 {
797809
padding-top: calc(var(--spacing) * 4);
798810
}
@@ -956,9 +968,6 @@
956968
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
957969
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
958970
}
959-
.filter {
960-
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
961-
}
962971
.transition-all {
963972
transition-property: all;
964973
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -1003,6 +1012,12 @@
10031012
}
10041013
}
10051014
}
1015+
.last\:border-0 {
1016+
&:last-child {
1017+
border-style: var(--tw-border-style);
1018+
border-width: 0px;
1019+
}
1020+
}
10061021
.last\:border-b-0 {
10071022
&:last-child {
10081023
border-bottom-style: var(--tw-border-style);
@@ -1144,6 +1159,11 @@
11441159
padding: calc(var(--spacing) * 6);
11451160
}
11461161
}
1162+
.md\:grid-cols-2 {
1163+
@media (width >= 48rem) {
1164+
grid-template-columns: repeat(2, minmax(0, 1fr));
1165+
}
1166+
}
11471167
.md\:grid-cols-4 {
11481168
@media (width >= 48rem) {
11491169
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -1320,59 +1340,6 @@
13201340
inherits: false;
13211341
initial-value: 0 0 #0000;
13221342
}
1323-
@property --tw-blur {
1324-
syntax: "*";
1325-
inherits: false;
1326-
}
1327-
@property --tw-brightness {
1328-
syntax: "*";
1329-
inherits: false;
1330-
}
1331-
@property --tw-contrast {
1332-
syntax: "*";
1333-
inherits: false;
1334-
}
1335-
@property --tw-grayscale {
1336-
syntax: "*";
1337-
inherits: false;
1338-
}
1339-
@property --tw-hue-rotate {
1340-
syntax: "*";
1341-
inherits: false;
1342-
}
1343-
@property --tw-invert {
1344-
syntax: "*";
1345-
inherits: false;
1346-
}
1347-
@property --tw-opacity {
1348-
syntax: "*";
1349-
inherits: false;
1350-
}
1351-
@property --tw-saturate {
1352-
syntax: "*";
1353-
inherits: false;
1354-
}
1355-
@property --tw-sepia {
1356-
syntax: "*";
1357-
inherits: false;
1358-
}
1359-
@property --tw-drop-shadow {
1360-
syntax: "*";
1361-
inherits: false;
1362-
}
1363-
@property --tw-drop-shadow-color {
1364-
syntax: "*";
1365-
inherits: false;
1366-
}
1367-
@property --tw-drop-shadow-alpha {
1368-
syntax: "<percentage>";
1369-
inherits: false;
1370-
initial-value: 100%;
1371-
}
1372-
@property --tw-drop-shadow-size {
1373-
syntax: "*";
1374-
inherits: false;
1375-
}
13761343
@property --tw-duration {
13771344
syntax: "*";
13781345
inherits: false;
@@ -1424,19 +1391,6 @@
14241391
--tw-ring-offset-width: 0px;
14251392
--tw-ring-offset-color: #fff;
14261393
--tw-ring-offset-shadow: 0 0 #0000;
1427-
--tw-blur: initial;
1428-
--tw-brightness: initial;
1429-
--tw-contrast: initial;
1430-
--tw-grayscale: initial;
1431-
--tw-hue-rotate: initial;
1432-
--tw-invert: initial;
1433-
--tw-opacity: initial;
1434-
--tw-saturate: initial;
1435-
--tw-sepia: initial;
1436-
--tw-drop-shadow: initial;
1437-
--tw-drop-shadow-color: initial;
1438-
--tw-drop-shadow-alpha: 100%;
1439-
--tw-drop-shadow-size: initial;
14401394
--tw-duration: initial;
14411395
}
14421396
}

arenabuddy/arenabuddy/src/app/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mod error_logs;
66
mod match_details;
77
mod matches;
88
mod pages;
9+
mod stats;
910
use dioxus::prelude::*;
1011
use pages::Route;
1112
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");

arenabuddy/arenabuddy/src/app/pages.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use dioxus_router::{Link, Outlet, Routable};
44
use crate::{
55
app::{
66
debug_logs::DebugLogs, draft_details::DraftDetails, drafts::Drafts, error_logs::ErrorLogs,
7-
match_details::MatchDetails, matches::Matches,
7+
match_details::MatchDetails, matches::Matches, stats::Stats,
88
},
99
backend::{BackgroundRuntime, SharedAuthState},
1010
};
@@ -35,6 +35,8 @@ pub enum Route {
3535
DraftDetails { id: String },
3636
#[route("/debug")]
3737
DebugLogs {},
38+
#[route("/stats")]
39+
Stats {},
3840
#[end_layout]
3941
#[route("/:..route")]
4042
PageNotFound { route: Vec<String> },
@@ -195,6 +197,13 @@ fn Layout() -> Element {
195197
"Drafts"
196198
}
197199
}
200+
li {
201+
Link {
202+
to: Route::Stats {},
203+
class: "hover:text-blue-400 transition-colors duration-200",
204+
"Stats"
205+
}
206+
}
198207
li {
199208
Link {
200209
to: Route::ErrorLogs {},
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
use arenabuddy_core::display::stats::MatchStats;
2+
use dioxus::prelude::*;
3+
4+
use crate::backend::Service;
5+
6+
fn format_rate(rate: Option<f64>) -> String {
7+
rate.map_or("N/A".to_string(), |r| format!("{r:.1}%"))
8+
}
9+
10+
#[component]
11+
fn StatCard(title: &'static str, children: Element) -> Element {
12+
rsx! {
13+
div { class: "bg-white rounded-lg shadow-md p-6",
14+
h2 { class: "text-lg font-semibold text-gray-700 mb-4", "{title}" }
15+
{children}
16+
}
17+
}
18+
}
19+
20+
#[component]
21+
fn RecordLine(label: &'static str, wins: i64, losses: i64, rate: Option<f64>) -> Element {
22+
rsx! {
23+
div { class: "flex justify-between items-center py-2 border-b border-gray-100 last:border-0",
24+
span { class: "text-gray-600", "{label}" }
25+
div { class: "flex items-center space-x-3",
26+
span { class: "text-green-600 font-medium", "{wins}W" }
27+
span { class: "text-gray-400", "-" }
28+
span { class: "text-red-600 font-medium", "{losses}L" }
29+
span { class: "text-gray-500 text-sm ml-2", "({format_rate(rate)})" }
30+
}
31+
}
32+
}
33+
}
34+
35+
#[component]
36+
fn StatsDisplay(stats: MatchStats) -> Element {
37+
rsx! {
38+
div { class: "grid grid-cols-1 md:grid-cols-2 gap-6",
39+
StatCard { title: "Match Record",
40+
RecordLine {
41+
label: "Overall",
42+
wins: stats.match_wins,
43+
losses: stats.match_losses,
44+
rate: stats.match_win_rate(),
45+
}
46+
div { class: "pt-2 text-sm text-gray-500",
47+
"{stats.total_matches} matches played"
48+
}
49+
}
50+
51+
StatCard { title: "Game Record",
52+
RecordLine {
53+
label: "Overall",
54+
wins: stats.game_wins,
55+
losses: stats.game_losses,
56+
rate: stats.game_win_rate(),
57+
}
58+
div { class: "pt-2 text-sm text-gray-500",
59+
"{stats.total_games} games played"
60+
}
61+
}
62+
63+
StatCard { title: "Play / Draw Game Win Rate",
64+
RecordLine {
65+
label: "On the Play",
66+
wins: stats.play_wins,
67+
losses: stats.play_losses,
68+
rate: stats.play_win_rate(),
69+
}
70+
RecordLine {
71+
label: "On the Draw",
72+
wins: stats.draw_wins,
73+
losses: stats.draw_losses,
74+
rate: stats.draw_win_rate(),
75+
}
76+
}
77+
78+
StatCard { title: "Mulligans",
79+
if stats.mulligan_stats.is_empty() {
80+
p { class: "text-gray-500 text-sm", "No mulligan data available" }
81+
} else {
82+
for bucket in stats.mulligan_stats.iter() {
83+
RecordLine {
84+
label: match bucket.cards_kept {
85+
7 => "Kept 7",
86+
6 => "Kept 6",
87+
5 => "Kept 5",
88+
4 => "Kept 4",
89+
_ => "Other",
90+
},
91+
wins: bucket.wins,
92+
losses: bucket.losses,
93+
rate: bucket.win_rate(),
94+
}
95+
}
96+
}
97+
}
98+
99+
if !stats.opponents.is_empty() {
100+
StatCard { title: "Top Opponents",
101+
for opp in stats.opponents.iter() {
102+
div { class: "flex justify-between items-center py-2 border-b border-gray-100 last:border-0",
103+
span { class: "text-gray-600 truncate mr-4", "{opp.name}" }
104+
div { class: "flex items-center space-x-3 flex-shrink-0",
105+
span { class: "text-green-600 font-medium", "{opp.wins}W" }
106+
span { class: "text-gray-400", "-" }
107+
span { class: "text-red-600 font-medium", "{opp.losses}L" }
108+
span { class: "text-gray-500 text-sm ml-2",
109+
"({opp.matches} matches)"
110+
}
111+
}
112+
}
113+
}
114+
}
115+
}
116+
}
117+
}
118+
}
119+
120+
#[component]
121+
pub(crate) fn Stats() -> Element {
122+
let service = use_context::<Service>();
123+
let mut stats_resource = use_resource(move || {
124+
let service = service.clone();
125+
async move { service.get_stats().await }
126+
});
127+
128+
let refresh = move |_| {
129+
stats_resource.restart();
130+
};
131+
132+
let resource_value = stats_resource.value();
133+
let data = resource_value.read();
134+
135+
rsx! {
136+
div { class: "container mx-auto px-4 py-8 max-w-5xl",
137+
div { class: "flex justify-between items-center mb-6",
138+
h1 { class: "text-2xl font-bold text-gray-800", "Match Statistics" }
139+
button {
140+
onclick: refresh,
141+
class: "bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded shadow transition-colors duration-150",
142+
disabled: data.is_none(),
143+
if data.is_none() {
144+
"Loading..."
145+
} else {
146+
"Refresh"
147+
}
148+
}
149+
}
150+
151+
match &*data {
152+
None => rsx! {
153+
div { class: "bg-white rounded-lg shadow-md p-12 text-center text-gray-500",
154+
div { class: "animate-pulse", "Loading statistics..." }
155+
}
156+
},
157+
158+
Some(Err(err)) => rsx! {
159+
div { class: "bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded",
160+
p { "Failed to load statistics: {err}" }
161+
}
162+
},
163+
164+
Some(Ok(stats)) => {
165+
if stats.total_matches == 0 {
166+
rsx! {
167+
div { class: "bg-white rounded-lg shadow-md p-12 text-center text-gray-500",
168+
"No match data available. Play some games in MTG Arena!"
169+
}
170+
}
171+
} else {
172+
rsx! { StatsDisplay { stats: stats.clone() } }
173+
}
174+
}
175+
}
176+
}
177+
}
178+
}

arenabuddy/arenabuddy/src/backend/service.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use arenabuddy_core::{
88
game::GameResultDisplay,
99
match_details::MatchDetails,
1010
mulligan::Mulligan,
11+
stats::MatchStats,
1112
},
1213
models::{Draft, MTGAMatch},
1314
};
@@ -150,6 +151,10 @@ where
150151
Ok(DraftDetailsDisplay::new(draft, &self.cards))
151152
}
152153

154+
pub async fn get_stats(&self) -> Result<MatchStats> {
155+
Ok(self.db.get_match_stats(None).await?)
156+
}
157+
153158
pub async fn get_error_logs(&self) -> Result<Vec<String>> {
154159
let logs = self.log_collector.lock().await;
155160
Ok(logs.clone())

arenabuddy/core/src/display/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ pub mod event_log;
55
pub mod game;
66
pub mod match_details;
77
pub mod mulligan;
8+
pub mod stats;

0 commit comments

Comments
 (0)