Skip to content

Commit c3c536f

Browse files
chore: repair analytics module
improve queries and adjust dashboards
1 parent 20e93a9 commit c3c536f

11 files changed

Lines changed: 391 additions & 143 deletions

app/controllers/api/v1/admin/players_controller.rb

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ module Admin
1616
#
1717
class PlayersController < Api::V1::BaseController
1818
before_action :require_admin_access
19-
before_action :set_player, only: %i[soft_delete restore enable_access disable_access transfer]
19+
before_action :set_player, only: %i[soft_delete restore enable_access disable_access transfer change_status]
2020

2121
# GET /api/v1/admin/players
2222
# Lists all players including soft-deleted ones
@@ -187,6 +187,55 @@ def disable_access
187187
end
188188
end
189189

190+
# POST /api/v1/admin/players/:id/change_status
191+
# Changes the status of a non-deleted player (active / inactive / benched / trial).
192+
# Use soft_delete to archive a player and restore to un-archive them.
193+
def change_status
194+
new_status = params[:status].to_s.strip
195+
196+
# Disallow setting 'removed' via this endpoint — that is handled by soft_delete
197+
allowed = Constants::Player::STATUSES - ['removed']
198+
unless allowed.include?(new_status)
199+
return render_error(
200+
message: "Invalid status '#{new_status}'. Allowed: #{allowed.join(', ')}",
201+
code: 'VALIDATION_ERROR',
202+
status: :unprocessable_entity
203+
)
204+
end
205+
206+
if @player.deleted_at.present?
207+
return render_error(
208+
message: 'Cannot change status of an archived player. Use restore instead.',
209+
code: 'PLAYER_ARCHIVED',
210+
status: :unprocessable_entity
211+
)
212+
end
213+
214+
old_status = @player.status
215+
216+
if @player.update(status: new_status)
217+
log_user_action(
218+
action: 'change_status',
219+
entity_type: 'Player',
220+
entity_id: @player.id,
221+
old_values: { status: old_status },
222+
new_values: { status: new_status }
223+
)
224+
225+
render_success({
226+
message: "Player status changed to #{new_status}",
227+
player: PlayerSerializer.render_as_hash(@player)
228+
})
229+
else
230+
render_error(
231+
message: 'Failed to update player status',
232+
code: 'CHANGE_STATUS_ERROR',
233+
status: :unprocessable_entity,
234+
details: @player.errors.as_json
235+
)
236+
end
237+
end
238+
190239
# POST /api/v1/admin/players/:id/transfer
191240
# Transfers a player to another organization
192241
def transfer

app/controllers/api/v1/analytics/laning_controller.rb

Lines changed: 45 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,77 +5,77 @@ module V1
55
module Analytics
66
# Laning Phase Analytics Controller
77
#
8-
# Provides early game performance metrics focusing on CS (creep score) and gold acquisition.
9-
# Tracks farming efficiency with CS per minute calculations and gold earnings.
8+
# Returns CS, gold, and early-game metrics for a given player.
9+
# Timeline data (gold_diff@10/@15) is not available from the data source,
10+
# so those fields are omitted (nil) and the frontend falls back gracefully.
1011
#
11-
# @example GET /api/v1/analytics/laning/:player_id
12-
# {
13-
# cs_performance: { avg_cs_total: 185.5, avg_cs_per_min: 7.4, best_cs_game: 245 },
14-
# gold_performance: { avg_gold: 12500, best_gold_game: 15000 }
15-
# }
16-
#
17-
# Main endpoints:
18-
# - GET show: Returns laning statistics for the last 20 matches with CS and gold metrics
1912
class LaningController < Api::V1::BaseController
2013
def show
2114
player = organization_scoped(Player).find(params[:player_id])
2215

2316
stats = PlayerMatchStat.joins(:match)
17+
.includes(:match)
2418
.where(player: player, match: { organization: current_organization })
2519
.order('matches.game_start DESC')
2620
.limit(20)
2721

28-
laning_data = {
29-
player: PlayerSerializer.render_as_hash(player),
30-
cs_performance: {
31-
avg_cs_total: stats.average('minions_killed + jungle_minions_killed')&.round(1),
32-
avg_cs_per_min: calculate_avg_cs_per_min(stats),
33-
best_cs_game: stats.maximum('minions_killed + jungle_minions_killed'),
34-
worst_cs_game: stats.minimum('minions_killed + jungle_minions_killed')
35-
},
36-
gold_performance: {
37-
avg_gold: stats.average(:gold_earned)&.round(0),
38-
best_gold_game: stats.maximum(:gold_earned),
39-
worst_gold_game: stats.minimum(:gold_earned)
40-
},
41-
cs_by_match: stats.map do |stat|
42-
match_duration_mins = stat.match.game_duration ? stat.match.game_duration / 60.0 : 25
43-
cs_total = (stat.minions_killed || 0) + (stat.jungle_minions_killed || 0)
44-
cs_per_min = cs_total / match_duration_mins
22+
games = stats.count
23+
wins = stats.joins(:match).where(matches: { victory: true }).count
4524

46-
{
47-
match_id: stat.match.id,
48-
date: stat.match.game_start,
49-
cs_total: cs_total,
50-
cs_per_min: cs_per_min.round(1),
51-
gold: stat.gold_earned,
52-
champion: stat.champion,
53-
victory: stat.match.victory
54-
}
55-
end
25+
laning_data = {
26+
player: PlayerSerializer.render_as_hash(player),
27+
avg_cs_per_min: stats.average(:cs_per_min)&.round(1) || calculate_avg_cs_per_min(stats),
28+
avg_cs_total: stats.average(:cs)&.round(1) || 0,
29+
lane_win_rate: games.zero? ? nil : ((wins.to_f / games) * 100).round(1),
30+
first_blood_rate: games.zero? ? nil : ((stats.where(first_blood: true).count.to_f / games) * 100).round(1),
31+
first_tower_rate: games.zero? ? nil : ((stats.where(first_tower: true).count.to_f / games) * 100).round(1),
32+
avg_gold: stats.average(:gold_earned)&.round(0) || 0,
33+
# Timeline fields not available from data source
34+
gold_diff_10: nil,
35+
gold_diff_15: nil,
36+
cs_diff_10: nil,
37+
cs_diff_15: nil,
38+
solo_kills: nil,
39+
laning_trend: build_laning_trend(stats)
5640
}
5741

5842
render_success(laning_data)
5943
end
6044

6145
private
6246

47+
def build_laning_trend(stats)
48+
stats.map do |stat|
49+
next unless stat.match.game_start
50+
51+
duration_mins = stat.match.game_duration ? stat.match.game_duration / 60.0 : 25
52+
cs = stat.cs || 0
53+
cs_pm = duration_mins > 0 ? (cs / duration_mins).round(1) : 0
54+
55+
{
56+
date: stat.match.game_start.strftime('%Y-%m-%d'),
57+
cs_total: cs,
58+
cs_per_min: cs_pm,
59+
gold: stat.gold_earned || 0,
60+
gold_diff: 0, # not available
61+
champion: stat.champion,
62+
victory: stat.match.victory
63+
}
64+
end.compact.sort_by { |d| d[:date] }
65+
end
66+
6367
def calculate_avg_cs_per_min(stats)
64-
total_cs = 0
68+
total_cs = 0
6569
total_minutes = 0
6670

6771
stats.each do |stat|
6872
next unless stat.match.game_duration
6973

70-
cs = (stat.minions_killed || 0) + (stat.jungle_minions_killed || 0)
71-
minutes = stat.match.game_duration / 60.0
72-
total_cs += cs
73-
total_minutes += minutes
74+
total_cs += stat.cs || 0
75+
total_minutes += stat.match.game_duration / 60.0
7476
end
7577

76-
return 0 if total_minutes.zero?
77-
78-
(total_cs / total_minutes).round(1)
78+
total_minutes.zero? ? 0 : (total_cs / total_minutes).round(1)
7979
end
8080
end
8181
end

app/controllers/api/v1/analytics/performance_controller.rb

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,24 @@ class PerformanceController < Api::V1::BaseController
4545
# @return [JSON] Performance analytics data
4646
def index
4747
matches = apply_date_filters(organization_scoped(Match))
48-
players = organization_scoped(Player).active
4948

50-
# Only process player_id if it's present and valid
49+
# Use active players for team-wide stats (best performers, role breakdown, etc.)
50+
# but validate player_id against ALL org players so that bench/trial/inactive
51+
# players can still have their individual stats viewed.
52+
active_players = organization_scoped(Player).active
53+
all_org_players = organization_scoped(Player)
54+
5155
player_id = params[:player_id].presence
52-
if player_id.present? && !players.exists?(id: player_id)
56+
if player_id.present? && !all_org_players.exists?(id: player_id)
5357
return render_error(
5458
message: 'Player not found',
5559
code: 'PLAYER_NOT_FOUND',
5660
status: :not_found
5761
)
5862
end
5963

60-
service = ::Analytics::Services::PerformanceAnalyticsService.new(matches, players)
61-
performance_data = service.calculate_performance_data(player_id: player_id)
64+
service = ::Analytics::Services::PerformanceAnalyticsService.new(matches, active_players)
65+
performance_data = service.calculate_performance_data(player_id: player_id, all_players: all_org_players)
6266

6367
render_success(performance_data)
6468
rescue StandardError => e

app/controllers/api/v1/analytics/team_comparison_controller.rb

Lines changed: 82 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def index
1919
private
2020

2121
def fetch_active_players
22-
organization_scoped(Player).active.includes(:player_match_stats)
22+
organization_scoped(Player).active
2323
end
2424

2525
def build_matches_query
@@ -60,27 +60,58 @@ def build_comparison_data(players, matches)
6060
}
6161
end
6262

63+
# Single GROUP BY query replaces one query per player (N+1 → 1)
6364
def build_player_comparisons(players, matches)
64-
player_stats = players.map { |player| build_player_stats(player, matches) }
65-
sorted_stats = player_stats.compact
66-
sorted_stats.sort_by { |p| -p[:avg_performance_score] }
67-
end
68-
69-
def build_player_stats(player, matches)
70-
stats = PlayerMatchStat.where(player: player, match: matches)
71-
return nil if stats.empty?
72-
73-
{
74-
player: PlayerSerializer.render_as_hash(player),
75-
games_played: stats.count,
76-
kda: calculate_kda(stats),
77-
avg_damage: calculate_average(stats, :damage_dealt_total, 0),
78-
avg_gold: calculate_average(stats, :gold_earned, 0),
79-
avg_cs: calculate_average(stats, :cs, 1),
80-
avg_vision_score: calculate_average(stats, :vision_score, 1),
81-
avg_performance_score: calculate_average(stats, :performance_score, 1),
82-
multikills: build_multikills(stats)
83-
}
65+
player_ids = players.pluck(:id)
66+
match_ids = matches.pluck(:id)
67+
return [] if player_ids.empty? || match_ids.empty?
68+
69+
agg_rows = PlayerMatchStat
70+
.where(player_id: player_ids, match_id: match_ids)
71+
.group(:player_id)
72+
.select(
73+
'player_id',
74+
'COUNT(*) AS games_played',
75+
'SUM(kills) AS total_kills',
76+
'SUM(deaths) AS total_deaths',
77+
'SUM(assists) AS total_assists',
78+
'AVG(damage_dealt_total) AS avg_damage',
79+
'AVG(gold_earned) AS avg_gold',
80+
'AVG(cs) AS avg_cs',
81+
'AVG(vision_score) AS avg_vision_score',
82+
'AVG(performance_score) AS avg_performance_score',
83+
'SUM(double_kills) AS double_kills',
84+
'SUM(triple_kills) AS triple_kills',
85+
'SUM(quadra_kills) AS quadra_kills',
86+
'SUM(penta_kills) AS penta_kills'
87+
)
88+
89+
players_by_id = players.index_by(&:id)
90+
91+
agg_rows.filter_map do |agg|
92+
player = players_by_id[agg.player_id]
93+
next unless player
94+
95+
deaths = agg.total_deaths.to_i.zero? ? 1 : agg.total_deaths.to_i
96+
kda = ((agg.total_kills.to_i + agg.total_assists.to_i).to_f / deaths).round(2)
97+
98+
{
99+
player: PlayerSerializer.render_as_hash(player),
100+
games_played: agg.games_played.to_i,
101+
kda: kda,
102+
avg_damage: agg.avg_damage.to_f.round(0),
103+
avg_gold: agg.avg_gold.to_f.round(0),
104+
avg_cs: agg.avg_cs.to_f.round(1),
105+
avg_vision_score: agg.avg_vision_score.to_f.round(1),
106+
avg_performance_score: agg.avg_performance_score.to_f.round(1),
107+
multikills: {
108+
double: agg.double_kills.to_i,
109+
triple: agg.triple_kills.to_i,
110+
quadra: agg.quadra_kills.to_i,
111+
penta: agg.penta_kills.to_i
112+
}
113+
}
114+
end.sort_by { |p| -p[:avg_performance_score] }
84115
end
85116

86117
def calculate_average(stats, column, precision)
@@ -117,33 +148,39 @@ def calculate_team_averages(matches)
117148
}
118149
end
119150

151+
# Single GROUP BY across all roles — replaces 3N per-player queries
120152
def calculate_role_rankings(players, matches)
121-
rankings = {}
122-
123-
%w[top jungle mid adc support].each do |role|
124-
rankings[role] = calculate_role_ranking(players, matches, role)
153+
player_ids = players.pluck(:id)
154+
match_ids = matches.pluck(:id)
155+
156+
rankings = { 'top' => [], 'jungle' => [], 'mid' => [], 'adc' => [], 'support' => [] }
157+
return rankings if player_ids.empty? || match_ids.empty?
158+
159+
agg_rows = PlayerMatchStat
160+
.joins(:player)
161+
.where(player_id: player_ids, match_id: match_ids)
162+
.group('player_id, players.role, players.summoner_name')
163+
.select(
164+
'player_id',
165+
'players.role AS role',
166+
'players.summoner_name AS summoner_name',
167+
'COUNT(*) AS games',
168+
'AVG(performance_score) AS avg_performance'
169+
)
170+
171+
agg_rows.each do |agg|
172+
role = agg.role
173+
next unless rankings.key?(role)
174+
175+
rankings[role] << {
176+
player_id: agg.player_id,
177+
summoner_name: agg.summoner_name,
178+
avg_performance: agg.avg_performance.to_f.round(1),
179+
games: agg.games.to_i
180+
}
125181
end
126182

127-
rankings
128-
end
129-
130-
def calculate_role_ranking(players, matches, role)
131-
role_players = players.where(role: role)
132-
role_data = role_players.map { |player| build_role_player_data(player, matches) }
133-
sorted_data = role_data.compact
134-
sorted_data.sort_by { |p| -p[:avg_performance] }
135-
end
136-
137-
def build_role_player_data(player, matches)
138-
stats = PlayerMatchStat.where(player: player, match: matches)
139-
return nil if stats.empty?
140-
141-
{
142-
player_id: player.id,
143-
summoner_name: player.summoner_name,
144-
avg_performance: stats.average(:performance_score)&.round(1) || 0,
145-
games: stats.count
146-
}
183+
rankings.transform_values { |list| list.sort_by { |p| -p[:avg_performance] } }
147184
end
148185
end
149186
end

app/controllers/api/v1/analytics/teamfights_controller.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def show
2121
player = organization_scoped(Player).find(params[:player_id])
2222

2323
stats = PlayerMatchStat.joins(:match)
24+
.includes(:match)
2425
.where(player: player, match: { organization: current_organization })
2526
.order('matches.game_start DESC')
2627
.limit(20)

0 commit comments

Comments
 (0)