Skip to content

Commit b71d0bf

Browse files
committed
update
1 parent 3de181e commit b71d0bf

7 files changed

Lines changed: 218 additions & 124 deletions

File tree

‎apps/codebattle/frontend/src/features/tournament/panels/AnalyticsPanels.tsx‎

Lines changed: 77 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import { LEVEL_COLORS, levelLabel } from '@/features/lobby/format';
77
import type { GameLevel } from '@/features/lobby/types';
88
import { formatSeconds } from '@/features/tasks/shared';
99
import { LangIcon } from '@/shared/LangIcon';
10+
import { useSharedProps } from '@/shared/useSharedProps';
1011
import { Markdown } from '@/shared/Markdown';
1112

13+
import { useTournamentStore } from '../store';
1214
import type { Tournament } from '../types';
1315
import type { TournamentApi } from '../useTournament';
1416

@@ -315,6 +317,8 @@ interface TaskInfo {
315317
name: string;
316318
description_en: string | null;
317319
description_ru: string | null;
320+
time_to_solve_sec: number | null;
321+
base_score: number | null;
318322
}
319323

320324
interface TopTaskUser {
@@ -335,6 +339,56 @@ interface DurationBucket {
335339
wins_count: number;
336340
}
337341

342+
/**
343+
* Winners' solve times on a fixed 0 … time-to-solve scale (the server's buckets), with
344+
* quarter ticks and the viewer's own solve marked.
345+
*/
346+
function DurationChart({ buckets, myDuration }: { buckets: DurationBucket[]; myDuration: number | null }) {
347+
const { t } = useTranslation();
348+
const scale = buckets[buckets.length - 1].end || 1;
349+
const maxWins = Math.max(1, ...buckets.map((bucket) => bucket.wins_count));
350+
const mine = myDuration != null ? Math.min(100, (myDuration / scale) * 100) : null;
351+
const inBucket = (bucket: DurationBucket, index: number) =>
352+
myDuration != null && myDuration >= bucket.start && (myDuration < bucket.end || index === buckets.length - 1);
353+
354+
return (
355+
<div className={classes.durationChart}>
356+
<div className={classes.histogram}>
357+
{buckets.map((bucket, index) => (
358+
<Tooltip
359+
key={bucket.start}
360+
label={t('%{from}–%{to}: %{count} solved', {
361+
from: formatSeconds(bucket.start),
362+
to: formatSeconds(bucket.end),
363+
count: bucket.wins_count,
364+
})}
365+
>
366+
<div className={classes.histogramCol}>
367+
<div
368+
className={classes.histogramBar}
369+
data-mine={inBucket(bucket, index) || undefined}
370+
style={{ height: bucket.wins_count ? `${(bucket.wins_count / maxWins) * 100}%` : undefined }}
371+
/>
372+
</div>
373+
</Tooltip>
374+
))}
375+
{mine != null && (
376+
<div className={classes.myMarker} style={{ left: `${mine}%` }}>
377+
<span className={classes.myLabel}>{t('You %{time}', { time: formatSeconds(myDuration!) })}</span>
378+
</div>
379+
)}
380+
</div>
381+
<div className={classes.axis}>
382+
{[0, 0.25, 0.5, 0.75, 1].map((share) => (
383+
<span key={share} style={{ left: `${share * 100}%` }} data-edge={share === 0 ? 'start' : share === 1 ? 'end' : undefined}>
384+
{formatSeconds(Math.round(scale * share))}
385+
</span>
386+
))}
387+
</div>
388+
</div>
389+
);
390+
}
391+
338392
/** One task: how fast winners solved it and who was fastest (legacy TaskRankingAdvancedPanel). */
339393
export function TaskAdvancedPanel({
340394
tournament,
@@ -350,6 +404,8 @@ export function TaskAdvancedPanel({
350404
onSelectUser?: (id: number) => void;
351405
}) {
352406
const { t } = useTranslation();
407+
const { current_user: me } = useSharedProps();
408+
const matches = useTournamentStore((state) => state.matches);
353409
const [task, setTask] = useState<TaskInfo | null>(null);
354410
const [showDescription, setShowDescription] = useState(false);
355411
const top = usePanelResults<TopTaskUser>(api, tournament.state, { type: 'top_user_by_task_ranking', task_id: taskId });
@@ -364,7 +420,16 @@ export function TaskAdvancedPanel({
364420
}, [api, taskId]);
365421

366422
const buckets = distribution.rows ?? [];
367-
const maxWins = Math.max(1, ...buckets.map((bucket) => bucket.wins_count));
423+
// The viewer's own winning solve on this task, if any (top list, else their matches).
424+
const myDuration =
425+
top.rows?.find((row) => row.user_id === me.id)?.duration_sec ??
426+
Object.values(matches).find(
427+
(match) =>
428+
match.task_id === taskId &&
429+
match.duration_sec != null &&
430+
(match.player_results?.[me.id]?.result_percent ?? 0) >= 100,
431+
)?.duration_sec ??
432+
null;
368433

369434
return (
370435
<Stack gap="md">
@@ -379,6 +444,16 @@ export function TaskAdvancedPanel({
379444
{levelLabel(task.level)}
380445
</Badge>
381446
)}
447+
{task?.base_score != null && (
448+
<Badge size="xs" variant="default" tt="none">
449+
{t('Base score %{score}', { score: task.base_score })}
450+
</Badge>
451+
)}
452+
{task?.time_to_solve_sec != null && (
453+
<Badge size="xs" variant="default" tt="none">
454+
{t('Time limit %{time}', { time: formatSeconds(task.time_to_solve_sec) })}
455+
</Badge>
456+
)}
382457
</Group>
383458
<Switch size="xs" label={t('Show task description')} checked={showDescription} onChange={(event) => setShowDescription(event.currentTarget.checked)} />
384459
</Group>
@@ -392,25 +467,7 @@ export function TaskAdvancedPanel({
392467
) : buckets.length === 0 ? (
393468
<PanelMessage>{t('No results yet')}</PanelMessage>
394469
) : (
395-
<>
396-
<div className={classes.histogram}>
397-
{buckets.map((bucket) => (
398-
<Tooltip key={bucket.start} label={`${formatSeconds(bucket.start)}–${formatSeconds(bucket.end)}: ${bucket.wins_count}`}>
399-
<div className={classes.histogramCol}>
400-
<div className={classes.histogramBar} style={{ height: `${(bucket.wins_count / maxWins) * 100}%` }} />
401-
</div>
402-
</Tooltip>
403-
))}
404-
</div>
405-
<Group justify="space-between" mt={4}>
406-
<Text size="xs" c="dimmed" ff="monospace">
407-
{formatSeconds(buckets[0].start)}
408-
</Text>
409-
<Text size="xs" c="dimmed" ff="monospace">
410-
{formatSeconds(buckets[buckets.length - 1].end)}
411-
</Text>
412-
</Group>
413-
</>
470+
<DurationChart buckets={buckets} myDuration={myDuration} />
414471
)}
415472
</Paper>
416473

‎apps/codebattle/frontend/src/features/tournament/panels/Panels.module.css‎

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,32 +40,88 @@
4040
font-weight: 600;
4141
}
4242

43-
/* Winners' solve-time histogram (16 buckets). */
43+
/* Winners' solve-time histogram on a 0 … time-to-solve scale. */
44+
.durationChart {
45+
padding-top: 18px;
46+
}
47+
4448
.histogram {
49+
position: relative;
4550
display: flex;
4651
align-items: flex-end;
47-
gap: 3px;
52+
gap: 2px;
4853
height: 140px;
4954
border-bottom: 1px solid var(--cb-border);
5055
}
5156

57+
/* The viewer's solve: a line across the chart with its time on top. */
58+
.myMarker {
59+
position: absolute;
60+
top: 0;
61+
bottom: 0;
62+
width: 0;
63+
border-left: 2px dashed var(--cb-success);
64+
pointer-events: none;
65+
}
66+
67+
.myLabel {
68+
position: absolute;
69+
bottom: 100%;
70+
left: 0;
71+
transform: translate(-50%, -4px);
72+
padding: 0 6px;
73+
border-radius: 999px;
74+
white-space: nowrap;
75+
font-family: var(--mantine-font-family-monospace);
76+
font-size: 10px;
77+
font-weight: 700;
78+
color: #fff;
79+
background: var(--cb-success);
80+
}
81+
82+
.axis {
83+
position: relative;
84+
height: 16px;
85+
margin-top: 4px;
86+
font-family: var(--mantine-font-family-monospace);
87+
font-size: 10px;
88+
color: var(--cb-text-muted);
89+
90+
& > span {
91+
position: absolute;
92+
transform: translateX(-50%);
93+
94+
&[data-edge='start'] {
95+
transform: none;
96+
}
97+
98+
&[data-edge='end'] {
99+
transform: translateX(-100%);
100+
}
101+
}
102+
}
103+
52104
.histogramCol {
53105
flex: 1;
54106
height: 100%;
55107
display: flex;
56108
align-items: flex-end;
57109

58110
&:hover .histogramBar {
59-
background: var(--cb-success);
111+
background: var(--mantine-color-blue-5);
60112
}
61113
}
62114

63115
.histogramBar {
64116
width: 100%;
65-
min-height: 2px;
117+
min-height: 0;
66118
border-radius: 3px 3px 0 0;
67119
background: color-mix(in srgb, var(--mantine-color-blue-4) 70%, transparent);
68120
transition: background 120ms ease;
121+
122+
&[data-mine] {
123+
background: var(--cb-success);
124+
}
69125
}
70126

71127
/* Panel tabs: a single scrolling row. */

‎apps/codebattle/lib/codebattle/tournament/tournament_result.ex‎

Lines changed: 38 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -803,55 +803,53 @@ defmodule Codebattle.Tournament.TournamentResult do
803803
|> map_repo_result()
804804
end
805805

806+
@doc """
807+
How long the winners took on a task: their solve times (seconds) in equal-width buckets
808+
on a fixed scale from 0 to the task's time to solve (stretched if a solve took longer),
809+
so charts of different tasks and tournaments read the same. At most 20 buckets, never
810+
narrower than a second; the scale's end belongs to the last bucket. No solves, no rows.
811+
"""
806812
def get_task_duration_distribution(tournament, task_id) do
807813
"""
808-
WITH DurationStats AS (
809-
SELECT
810-
MIN(duration_sec) AS min_duration,
811-
MAX(duration_sec) AS max_duration
812-
FROM
813-
tournament_results
814-
WHERE
815-
tournament_id = $1
814+
WITH solves AS (
815+
SELECT duration_sec
816+
FROM tournament_results
817+
WHERE tournament_id = $1
816818
AND task_id = $2
817819
AND result_percent = 100.0
818820
AND score > 0
821+
AND duration_sec IS NOT NULL
819822
),
820-
IntervalParams AS (
821-
SELECT
822-
min_duration,
823-
max_duration,
824-
15 AS num_intervals,
825-
(max_duration - min_duration) / 15.0 AS interval_step
826-
FROM
827-
DurationStats
823+
params AS (
824+
SELECT scale_max, LEAST(20, scale_max) AS buckets
825+
FROM (
826+
SELECT GREATEST(
827+
COALESCE((SELECT time_to_solve_sec FROM tasks WHERE id = $2), 0),
828+
COALESCE(MAX(duration_sec), 0),
829+
1
830+
) AS scale_max
831+
FROM solves
832+
) scale
833+
WHERE EXISTS (SELECT 1 FROM solves)
828834
),
829-
Intervals AS (
830-
SELECT
831-
min_duration + (interval_step * generate_series) AS interval_start,
832-
min_duration + (interval_step * (generate_series + 1)) AS interval_end
833-
FROM
834-
IntervalParams,
835-
generate_series(0, 15)
835+
intervals AS (
836+
SELECT
837+
idx,
838+
buckets,
839+
scale_max * idx::numeric / buckets AS interval_start,
840+
scale_max * (idx + 1)::numeric / buckets AS interval_end
841+
FROM params, generate_series(0, buckets - 1) AS idx
836842
)
837843
SELECT
838-
CEIL(interval_start)::int AS start,
839-
CEIL(interval_end)::int AS end,
840-
COUNT(tr.duration_sec) AS wins_count
841-
FROM
842-
Intervals
843-
LEFT JOIN
844-
tournament_results tr
845-
ON
846-
tr.duration_sec >= interval_start
847-
AND tr.duration_sec < interval_end
848-
AND tr.task_id = $2
849-
AND tr.result_percent = 100.0
850-
AND tr.tournament_id = $1
851-
GROUP BY
852-
interval_start, interval_end
853-
ORDER BY
854-
interval_start
844+
ROUND(interval_start)::int AS start,
845+
ROUND(interval_end)::int AS end,
846+
COUNT(solves.duration_sec)::int AS wins_count
847+
FROM intervals
848+
LEFT JOIN solves
849+
ON solves.duration_sec >= interval_start
850+
AND (solves.duration_sec < interval_end OR (idx = buckets - 1 AND solves.duration_sec <= interval_end))
851+
GROUP BY idx, interval_start, interval_end
852+
ORDER BY idx
855853
"""
856854
|> Repo.query!([tournament.id, task_id])
857855
|> map_repo_result()

‎apps/codebattle/lib/codebattle_web/channels/tournament_channel.ex‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ defmodule CodebattleWeb.TournamentChannel do
191191
task =
192192
task_id
193193
|> Codebattle.Task.get()
194-
|> Map.take([:id, :level, :name, :description_ru, :description_en, :examples])
194+
|> Map.take([:id, :level, :name, :description_ru, :description_en, :examples, :time_to_solve_sec, :base_score])
195195

196196
{:reply, {:ok, task}, socket}
197197
end

‎apps/codebattle/priv/gettext/en/LC_MESSAGES/default.po‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4023,3 +4023,15 @@ msgstr "of %{count}"
40234023

40244024
msgid "LIVE"
40254025
msgstr "LIVE"
4026+
4027+
msgid "Base score %{score}"
4028+
msgstr "Base score %{score}"
4029+
4030+
msgid "Time limit %{time}"
4031+
msgstr "Time limit %{time}"
4032+
4033+
msgid "%{from}–%{to}: %{count} solved"
4034+
msgstr "%{from}–%{to}: %{count} solved"
4035+
4036+
msgid "You %{time}"
4037+
msgstr "You %{time}"

‎apps/codebattle/priv/gettext/ru/LC_MESSAGES/default.po‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5240,3 +5240,15 @@ msgstr "из %{count}"
52405240

52415241
msgid "LIVE"
52425242
msgstr "LIVE"
5243+
5244+
msgid "Base score %{score}"
5245+
msgstr "Базовые очки %{score}"
5246+
5247+
msgid "Time limit %{time}"
5248+
msgstr "Лимит %{time}"
5249+
5250+
msgid "%{from}–%{to}: %{count} solved"
5251+
msgstr "%{from}–%{to}: решили %{count}"
5252+
5253+
msgid "You %{time}"
5254+
msgstr "Вы %{time}"

0 commit comments

Comments
 (0)