Skip to content

Commit 13f00e2

Browse files
committed
Minor changes
1 parent ab2f4b7 commit 13f00e2

File tree

10 files changed

+298
-36
lines changed

10 files changed

+298
-36
lines changed

apps/codebattle/assets/js/widgets/pages/profile/UserProfile.jsx

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -29,31 +29,6 @@ const getSeasonPlaceColor = (place) => {
2929
return seasonPlaceColors.platinum;
3030
};
3131

32-
function HolopinTags({ name }) {
33-
return (
34-
name && (
35-
<div className="row mt-5 mb-md-3 mb-lg-4 mt-lg-0">
36-
<div className="position-relative col-lg-10 col-md-11 mx-auto">
37-
<div className="card cb-card">
38-
<div className="card-header py-1 cb-bg-highlight-panel font-weight-bold text-center">
39-
Holopins
40-
</div>
41-
<div className="card-body p-0">
42-
<a href={`https://holopin.io/@${name}`}>
43-
<img
44-
src={`https://holopin.me/@${name}`}
45-
alt={`@${name}'s Holopin board`}
46-
className="w-100"
47-
/>
48-
</a>
49-
</div>
50-
</div>
51-
</div>
52-
</div>
53-
)
54-
);
55-
}
56-
5732
function UserProfile() {
5833
const [userData, setUserData] = useState(null);
5934
const [topRivals, setTopRivals] = useState([]);
@@ -365,7 +340,6 @@ function UserProfile() {
365340
<Heatmap />
366341
</div>
367342
</div>
368-
<HolopinTags name={user?.githubName} />
369343
</div>
370344
<div
371345
className="tab-pane fade min-h-100"

apps/codebattle/lib/codebattle/game/engine.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ defmodule Codebattle.Game.Engine do
420420
defp update_user!(player, %Game{task_type: "sql"}) do
421421
User
422422
|> Repo.get!(player.id)
423-
|> User.changeset(%{
423+
|> User.rating_changeset(%{
424424
rating: player.rating,
425425
db_type: player.editor_lang
426426
})
@@ -430,7 +430,7 @@ defmodule Codebattle.Game.Engine do
430430
defp update_user!(player, %Game{task_type: "css"}) do
431431
User
432432
|> Repo.get!(player.id)
433-
|> User.changeset(%{
433+
|> User.rating_changeset(%{
434434
rating: player.rating,
435435
style_lang: player.editor_lang
436436
})
@@ -440,7 +440,7 @@ defmodule Codebattle.Game.Engine do
440440
defp update_user!(player, _game) do
441441
User
442442
|> Repo.get!(player.id)
443-
|> User.changeset(%{
443+
|> User.rating_changeset(%{
444444
rating: player.rating,
445445
lang: player.editor_lang
446446
})
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
defmodule Codebattle.Maintenance.Tasks.RecoverUserRatings do
2+
@moduledoc """
3+
Rebuilds suspiciously low user ratings from persisted `user_games` history.
4+
5+
The task recalculates ratings from the first suspicious collapse onward:
6+
- it only considers users whose current rating is below a configurable threshold
7+
- it finds the first transition from a sane rating to a suspiciously low rating
8+
- it uses the last sane rating as the baseline
9+
- it replays `rating_diff` values from the corrupted row onward
10+
- it does nothing unless `recover/1` is called explicitly
11+
"""
12+
13+
import Ecto.Query
14+
15+
alias Codebattle.Repo
16+
alias Codebattle.User
17+
alias Codebattle.UserGame
18+
19+
@default_threshold 100
20+
@type recovery_plan_item :: %{
21+
user_id: pos_integer(),
22+
current_rating: integer(),
23+
recovered_rating: integer(),
24+
baseline_rating: integer(),
25+
source_game_id: pos_integer(),
26+
source_inserted_at: NaiveDateTime.t(),
27+
games_count: non_neg_integer(),
28+
total_rating_diff: integer()
29+
}
30+
31+
@spec plan(keyword()) :: [recovery_plan_item()]
32+
def plan(opts \\ []) do
33+
threshold = Keyword.get(opts, :threshold, @default_threshold)
34+
user_ids = Keyword.get(opts, :user_ids)
35+
36+
threshold
37+
|> suspicious_users_query(user_ids)
38+
|> Repo.all()
39+
|> Enum.map(&build_recovery_plan(&1, threshold))
40+
|> Enum.reject(&is_nil/1)
41+
end
42+
43+
@spec recover(keyword()) :: [recovery_plan_item()]
44+
def recover(opts \\ []) do
45+
plans = plan(opts)
46+
47+
Repo.transaction(fn ->
48+
Enum.each(plans, fn %{user_id: user_id, recovered_rating: recovered_rating} ->
49+
user_id
50+
|> User.get!()
51+
|> User.rating_changeset(%{rating: recovered_rating})
52+
|> Repo.update!()
53+
end)
54+
end)
55+
56+
plans
57+
end
58+
59+
@spec build_recovery_plan(User.t(), integer()) :: recovery_plan_item() | nil
60+
def build_recovery_plan(%User{id: user_id, rating: current_rating}, threshold) do
61+
history = rating_history(user_id)
62+
63+
with %{baseline_rating: baseline_rating, history_from_drop: history_from_drop} = drop <-
64+
find_suspicious_drop(history, threshold) do
65+
total_rating_diff = Enum.sum_by(history_from_drop, & &1.rating_diff)
66+
recovered_rating = baseline_rating + total_rating_diff
67+
68+
if recovered_rating != current_rating do
69+
%{
70+
user_id: user_id,
71+
current_rating: current_rating,
72+
recovered_rating: recovered_rating,
73+
baseline_rating: baseline_rating,
74+
source_game_id: drop.source_game_id,
75+
source_inserted_at: drop.source_inserted_at,
76+
games_count: length(history_from_drop),
77+
total_rating_diff: total_rating_diff
78+
}
79+
end
80+
end
81+
end
82+
83+
defp suspicious_users_query(threshold, nil) do
84+
from(u in User,
85+
where: u.is_bot == false and not is_nil(u.rating) and u.rating < ^threshold
86+
)
87+
end
88+
89+
defp suspicious_users_query(threshold, user_ids) do
90+
from(u in User,
91+
where: u.is_bot == false and not is_nil(u.rating) and u.rating < ^threshold and u.id in ^user_ids
92+
)
93+
end
94+
95+
defp rating_history(user_id) do
96+
Repo.all(
97+
from(ug in UserGame,
98+
where: ug.user_id == ^user_id,
99+
order_by: [asc: ug.inserted_at, asc: ug.id],
100+
select: %{
101+
game_id: ug.game_id,
102+
inserted_at: ug.inserted_at,
103+
rating: ug.rating,
104+
rating_diff: type(coalesce(ug.rating_diff, 0), :integer)
105+
}
106+
)
107+
)
108+
end
109+
110+
defp find_suspicious_drop(history, threshold) do
111+
case history
112+
|> Enum.with_index()
113+
|> Enum.reduce_while(nil, fn {row, idx}, previous_row ->
114+
reduce_suspicious_drop(history, threshold, row, idx, previous_row)
115+
end) do
116+
%{baseline_rating: _} = drop -> drop
117+
_ -> nil
118+
end
119+
end
120+
121+
defp reduce_suspicious_drop(history, threshold, row, idx, %{rating: previous_rating})
122+
when previous_rating >= threshold and row.rating < threshold and previous_rating > row.rating do
123+
{:halt,
124+
%{
125+
baseline_rating: previous_rating,
126+
source_game_id: row.game_id,
127+
source_inserted_at: row.inserted_at,
128+
history_from_drop: Enum.drop(history, idx)
129+
}}
130+
end
131+
132+
defp reduce_suspicious_drop(_history, _threshold, row, _idx, _previous_row), do: {:cont, row}
133+
end

apps/codebattle/lib/codebattle/user.ex

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,6 @@ defmodule Codebattle.User do
117117
:lang,
118118
:locale,
119119
:name,
120-
:rating,
121120
:style_lang,
122121
:subscription_type
123122
])
@@ -129,6 +128,13 @@ defmodule Codebattle.User do
129128
|> assign_clan(params, 1)
130129
end
131130

131+
def rating_changeset(user, params \\ %{}) do
132+
user
133+
|> cast(params, [:rating, :lang, :style_lang, :db_type])
134+
|> validate_required([:rating])
135+
|> validate_number(:rating, greater_than_or_equal_to: 0)
136+
end
137+
132138
def settings_changeset(user, params \\ %{}) do
133139
user
134140
|> cast(params, [:name, :lang, :locale])

apps/codebattle/lib/codebattle/user/scope.ex

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,7 @@ defmodule Codebattle.User.Scope do
4141
defp filter_by_date(query, %{"date_from" => date_from}) when date_from !== "" do
4242
starts_at = date_from |> Date.from_iso8601!() |> NaiveDateTime.new!(~T[00:00:00])
4343

44-
query
45-
|> where([ug: ug], ug.inserted_at >= type(^starts_at, :naive_datetime))
46-
|> select_merge([ug: ug], %{rating: sum(ug.rating_diff)})
44+
where(query, [ug: ug], ug.inserted_at >= type(^starts_at, :naive_datetime))
4745
end
4846

4947
defp filter_by_date(query, _params), do: query
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
defmodule Codebattle.Repo.Migrations.HardenUserRatings do
2+
@moduledoc false
3+
use Ecto.Migration
4+
5+
def up do
6+
execute("UPDATE users SET rating = 1200 WHERE rating IS NULL")
7+
8+
execute("ALTER TABLE users ALTER COLUMN rating SET DEFAULT 1200")
9+
execute("ALTER TABLE users ALTER COLUMN rating SET NOT NULL")
10+
11+
create(constraint(:users, :users_rating_non_negative, check: "rating >= 0"))
12+
end
13+
14+
def down do
15+
drop(constraint(:users, :users_rating_non_negative))
16+
17+
execute("ALTER TABLE users ALTER COLUMN rating DROP NOT NULL")
18+
execute("ALTER TABLE users ALTER COLUMN rating DROP DEFAULT")
19+
end
20+
end
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
defmodule Codebattle.Maintenance.Tasks.RecoverUserRatingsTest do
2+
use CodebattleWeb.ConnCase, async: false
3+
4+
alias Codebattle.Maintenance.Tasks.RecoverUserRatings
5+
alias Codebattle.Repo
6+
alias Codebattle.User
7+
8+
test "builds a recovery plan from the first suspicious drop onward" do
9+
user = insert(:user, rating: 1)
10+
user_id = user.id
11+
12+
insert(:user_game,
13+
user: user,
14+
game: insert(:game),
15+
rating: 1172,
16+
rating_diff: 0,
17+
inserted_at: ~N[2026-03-10 23:34:44]
18+
)
19+
20+
insert(:user_game,
21+
user: user,
22+
game: insert(:game),
23+
rating: 0,
24+
rating_diff: -1172,
25+
inserted_at: ~N[2026-03-11 00:14:01]
26+
)
27+
28+
insert(:user_game,
29+
user: user,
30+
game: insert(:game),
31+
rating: 2,
32+
rating_diff: 2,
33+
inserted_at: ~N[2026-03-12 00:14:01]
34+
)
35+
36+
assert [
37+
%{
38+
user_id: ^user_id,
39+
current_rating: 1,
40+
recovered_rating: 2,
41+
baseline_rating: 1172,
42+
source_game_id: _,
43+
games_count: 2,
44+
total_rating_diff: -1170
45+
}
46+
] = RecoverUserRatings.plan()
47+
end
48+
49+
test "does not build a plan for users without a suspicious drop" do
50+
user = insert(:user, rating: 42)
51+
52+
insert(:user_game,
53+
user: user,
54+
game: insert(:game),
55+
rating: 42,
56+
rating_diff: -1158,
57+
inserted_at: ~N[2026-03-11 00:14:01]
58+
)
59+
60+
assert [] = RecoverUserRatings.plan(user_ids: [user.id])
61+
end
62+
63+
test "recovers the rating using recomputed value" do
64+
user = insert(:user, rating: 0)
65+
user_id = user.id
66+
67+
insert(:user_game,
68+
user: user,
69+
game: insert(:game),
70+
rating: 1172,
71+
rating_diff: 0,
72+
inserted_at: ~N[2026-03-10 23:34:44]
73+
)
74+
75+
insert(:user_game,
76+
user: user,
77+
game: insert(:game),
78+
rating: 0,
79+
rating_diff: -1172,
80+
inserted_at: ~N[2026-03-11 00:14:01]
81+
)
82+
83+
insert(:user_game,
84+
user: user,
85+
game: insert(:game),
86+
rating: 2,
87+
rating_diff: 2,
88+
inserted_at: ~N[2026-03-12 00:14:01]
89+
)
90+
91+
assert [%{user_id: ^user_id, recovered_rating: 2, baseline_rating: 1172}] =
92+
RecoverUserRatings.recover(user_ids: [user_id])
93+
94+
assert %{rating: 2} = Repo.get!(User, user_id)
95+
end
96+
end

apps/codebattle/test/codebattle/user/rank_update_test.exs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,15 @@ defmodule Codebattle.User.RankUpdateTest do
4444
# rating has been updated
4545

4646
user1
47-
|> User.changeset(%{rating: 10_000})
47+
|> User.rating_changeset(%{rating: 10_000})
4848
|> Repo.update!()
4949

5050
user5
51-
|> User.changeset(%{rating: 100_100})
51+
|> User.rating_changeset(%{rating: 100_100})
5252
|> Repo.update!()
5353

5454
user6
55-
|> User.changeset(%{rating: 100_200})
55+
|> User.rating_changeset(%{rating: 100_200})
5656
|> Repo.update!()
5757

5858
User.RankUpdate.call()

apps/codebattle/test/codebattle/user/scope_test.exs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,20 @@ defmodule Codebattle.User.ScopeTest do
5353
assert result_1.id == user1.id
5454
assert result_2.id == user2.id
5555
end
56+
57+
test "keeps persisted rating for filtered periods" do
58+
user =
59+
insert(:user, %{name: "first", email: "test1@test.test", github_id: 1, rating: 2400})
60+
61+
game = insert(:game, starts_at: ~N[2026-03-22 10:00:00], state: "game_over")
62+
insert(:user_game, user: user, game: game, inserted_at: ~N[2026-03-22 10:00:00], rating_diff: nil)
63+
64+
params = %{"date_from" => "2026-03-21"}
65+
query = Scope.list_users(params)
66+
67+
[result] = Repo.all(query)
68+
assert result.id == user.id
69+
assert result.rating == 2400
70+
end
5671
end
5772
end

0 commit comments

Comments
 (0)