Skip to content

Commit 9a46630

Browse files
Introduce forecast view toggle for final rounds (#252)
Co-authored-by: Kevin Hays <kevinhays@google.com>
1 parent 1782550 commit 9a46630

File tree

12 files changed

+452
-20
lines changed

12 files changed

+452
-20
lines changed

client/src/components/ResultStat/ResultStat.jsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from "../../lib/attempt-result";
88
import { shouldComputeAverage } from "../../lib/result";
99

10-
function ResultStat({ result, field, eventId, format }) {
10+
function ResultStat({ result, field, eventId, format, forecastView }) {
1111
if (
1212
field === "average" &&
1313
result.average === 0 &&
@@ -18,6 +18,16 @@ function ResultStat({ result, field, eventId, format }) {
1818
if (format.numberOfAttempts === 5 && result.attempts.length === 4) {
1919
return (
2020
<Box component="span" sx={{ opacity: 0.5 }}>
21+
{forecastView && (
22+
<>
23+
<Tooltip title="Projected average">
24+
<span>
25+
{formatAttemptResult(result.projectedAverage, eventId)}
26+
</span>
27+
</Tooltip>
28+
{" ("}
29+
</>
30+
)}
2131
<Tooltip title="Best possible average">
2232
<span>
2333
{formatAttemptResult(
@@ -35,6 +45,17 @@ function ResultStat({ result, field, eventId, format }) {
3545
)}
3646
</span>
3747
</Tooltip>
48+
{forecastView && <>{")"}</>}
49+
</Box>
50+
);
51+
}
52+
53+
if (forecastView) {
54+
return (
55+
<Box component="span" sx={{ opacity: 0.5 }}>
56+
<Tooltip title="Projected average">
57+
<span>{formatAttemptResult(result.projectedAverage, eventId)}</span>
58+
</Tooltip>
3859
</Box>
3960
);
4061
}

client/src/components/ResultsProjector/ResultsProjector.jsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ import CloseIcon from "@mui/icons-material/Close";
2121
import FlagIcon from "../FlagIcon/FlagIcon";
2222
import { times } from "../../lib/utils";
2323
import { formatAttemptResult } from "../../lib/attempt-result";
24-
import { orderedResultStats, paddedAttemptResults } from "../../lib/result";
24+
import {
25+
resultsForView,
26+
orderedResultStats,
27+
paddedAttemptResults,
28+
} from "../../lib/result";
2529
import RecordTagBadge from "../RecordTagBadge/RecordTagBadge";
2630
import ResultStat from "../ResultStat/ResultStat";
2731

@@ -72,13 +76,19 @@ function getNumberOfRows() {
7276
return Math.floor((window.innerHeight - 64 - 56) / 67);
7377
}
7478

75-
function ResultsProjector({ results, format, eventId, title, exitUrl }) {
79+
function ResultsProjector({
80+
results,
81+
format,
82+
eventId,
83+
title,
84+
exitUrl,
85+
forecastView,
86+
}) {
7687
const [status, setStatus] = useState(STATUS.SHOWING);
7788
const [topResultIndex, setTopResultIndex] = useState(0);
7889

7990
const stats = orderedResultStats(eventId, format);
80-
81-
const nonemptyResults = results.filter(
91+
const nonemptyResults = resultsForView(results, format, forecastView).filter(
8292
(result) => result.attempts.length > 0
8393
);
8494

@@ -237,6 +247,7 @@ function ResultsProjector({ results, format, eventId, title, exitUrl }) {
237247
field={field}
238248
eventId={eventId}
239249
format={format}
250+
forecastView={forecastView}
240251
/>
241252
</RecordTagBadge>
242253
</TableCell>

client/src/components/Round/Round.jsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ const ROUND_QUERY = gql`
5050
numberOfAttempts
5151
sortBy
5252
}
53+
advancementCondition {
54+
level
55+
type
56+
}
5357
results {
5458
id
5559
...roundResult
@@ -85,11 +89,17 @@ function Round() {
8589
});
8690

8791
const [previousData, setPreviousData] = useState(null);
92+
const [forecastView, setForecastView] = useState(false);
8893

8994
useEffect(() => {
9095
if (newData) setPreviousData(newData);
9196
}, [newData]);
9297

98+
useEffect(() => {
99+
// Reset to default on round change
100+
setForecastView(false);
101+
}, [roundId]);
102+
93103
// When the round changes, show the old data until the new is loaded.
94104
const data = newData || previousData;
95105

@@ -115,7 +125,12 @@ function Round() {
115125
{loading && <Loading />}
116126
<Grid container direction="column" spacing={1}>
117127
<Grid item>
118-
<RoundToolbar round={round} competitionId={competitionId} />
128+
<RoundToolbar
129+
round={round}
130+
competitionId={competitionId}
131+
forecastView={forecastView}
132+
setForecastView={setForecastView}
133+
/>
119134
</Grid>
120135
<Grid item>
121136
<Routes>
@@ -128,6 +143,7 @@ function Round() {
128143
eventId={round.competitionEvent.event.id}
129144
title={`${round.competitionEvent.event.name} - ${round.name}`}
130145
exitUrl={`/competitions/${competitionId}/rounds/${roundId}`}
146+
forecastView={forecastView}
131147
/>
132148
}
133149
/>
@@ -141,6 +157,7 @@ function Round() {
141157
format={round.format}
142158
eventId={round.competitionEvent.event.id}
143159
competitionId={competitionId}
160+
forecastView={forecastView}
144161
/>
145162
}
146163
/>

client/src/components/Round/RoundToolbar.jsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ import {
88
} from "@mui/material";
99
import TvIcon from "@mui/icons-material/Tv";
1010
import PrintIcon from "@mui/icons-material/Print";
11+
import InsightsIcon from "@mui/icons-material/Insights";
12+
import TimelineIcon from "@mui/icons-material/Timeline";
1113
import { appUrl } from "../../lib/urls";
14+
import { forecastViewSupported } from "../../lib/result";
1215

13-
function RoundToolbar({ round, competitionId }) {
16+
function RoundToolbar({ round, competitionId, forecastView, setForecastView }) {
1417
const mdScreen = useMediaQuery((theme) => theme.breakpoints.up("md"));
1518

1619
return (
@@ -23,6 +26,31 @@ function RoundToolbar({ round, competitionId }) {
2326
<Grid item style={{ flexGrow: 1 }} />
2427
{mdScreen && (
2528
<Grid item>
29+
{forecastView ? (
30+
<Tooltip title="Default view" placement="top">
31+
<IconButton onClick={() => setForecastView(false)} size="large">
32+
<TimelineIcon />
33+
</IconButton>
34+
</Tooltip>
35+
) : (
36+
<Tooltip
37+
title={
38+
<div>
39+
Forecast view:
40+
<div>- shows projected average for incomplete results</div>
41+
</div>
42+
}
43+
placement="top"
44+
>
45+
<IconButton
46+
onClick={() => setForecastView(true)}
47+
size="large"
48+
disabled={!forecastViewSupported(round)}
49+
>
50+
<InsightsIcon />
51+
</IconButton>
52+
</Tooltip>
53+
)}
2654
<Tooltip title="PDF" placement="top">
2755
<IconButton
2856
component="a"

client/src/components/RoundResults/RoundResults.jsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import RoundResultDialog from "./RoundResultDialog";
55

66
const DEFAULT_VISIBLE_RESULTS = 100;
77

8-
function RoundResults({ results, format, eventId, competitionId }) {
8+
function RoundResults({
9+
results,
10+
format,
11+
eventId,
12+
competitionId,
13+
forecastView,
14+
}) {
915
const smScreen = useMediaQuery((theme) => theme.breakpoints.up("sm"));
1016

1117
const [selectedResult, setSelectedResult] = useState(null);
@@ -35,6 +41,7 @@ function RoundResults({ results, format, eventId, competitionId }) {
3541
eventId={eventId}
3642
competitionId={competitionId}
3743
onResultClick={handleResultClick}
44+
forecastView={forecastView}
3845
/>
3946
</Grid>
4047
{!showAll && (

client/src/components/RoundResults/RoundResultsTable.jsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import { green } from "@mui/material/colors";
1414
import { alpha } from "@mui/material/styles";
1515
import { times } from "../../lib/utils";
1616
import { formatAttemptResult } from "../../lib/attempt-result";
17-
import { orderedResultStats, paddedAttemptResults } from "../../lib/result";
17+
import {
18+
resultsForView,
19+
orderedResultStats,
20+
paddedAttemptResults,
21+
} from "../../lib/result";
1822
import RecordTagBadge from "../RecordTagBadge/RecordTagBadge";
1923
import ResultStat from "../ResultStat/ResultStat";
2024

@@ -47,12 +51,20 @@ const styles = {
4751
};
4852

4953
const RoundResultsTable = memo(
50-
({ results, format, eventId, competitionId, onResultClick }) => {
54+
({
55+
results,
56+
format,
57+
eventId,
58+
competitionId,
59+
onResultClick,
60+
forecastView,
61+
}) => {
5162
const smScreen = useMediaQuery((theme) => theme.breakpoints.up("sm"));
5263
const mdScreen = useMediaQuery((theme) => theme.breakpoints.up("md"));
5364

5465
const stats = orderedResultStats(eventId, format);
5566

67+
const viewResults = resultsForView(results, format, forecastView);
5668
return (
5769
<Paper>
5870
<Table size="small">
@@ -80,7 +92,7 @@ const RoundResultsTable = memo(
8092
</TableRow>
8193
</TableHead>
8294
<TableBody>
83-
{results.map((result) => (
95+
{viewResults.map((result) => (
8496
<TableRow
8597
key={result.id}
8698
hover
@@ -144,6 +156,7 @@ const RoundResultsTable = memo(
144156
field={field}
145157
eventId={eventId}
146158
format={format}
159+
forecastView={forecastView}
147160
/>
148161
</RecordTagBadge>
149162
</TableCell>

client/src/components/admin/AdminRound/AdminResultsTable.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ const AdminResultsTable = memo(
167167
field={field}
168168
eventId={eventId}
169169
format={format}
170+
forecastView={false}
170171
/>
171172
</RecordTagBadge>
172173
</TableCell>

client/src/lib/attempt-result.js

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@ export const SKIPPED_VALUE = 0;
55
export const DNF_VALUE = -1;
66
export const DNS_VALUE = -2;
77

8-
function isComplete(attemptResult) {
8+
export function isComplete(attemptResult) {
99
return attemptResult > 0;
1010
}
1111

12-
function isSkipped(attemptResult) {
12+
export function isSkipped(attemptResult) {
1313
return attemptResult === SKIPPED_VALUE;
1414
}
1515

16+
export function toMonotonic(attemptResult) {
17+
return isComplete(attemptResult) ? attemptResult : Infinity;
18+
}
19+
1620
function compareAttemptResults(attemptResult1, attemptResult2) {
1721
if (!isComplete(attemptResult1) && !isComplete(attemptResult2)) return 0;
1822
if (!isComplete(attemptResult1) && isComplete(attemptResult2)) return 1;
@@ -80,7 +84,7 @@ export function average(attemptResults, eventId) {
8084
const scaled = attemptResults.map((attemptResult) => attemptResult * 100);
8185
switch (attemptResults.length) {
8286
case 3:
83-
return meanOf3(scaled);
87+
return meanOfX(scaled);
8488
case 5:
8589
return averageOf5(scaled);
8690
default:
@@ -92,7 +96,7 @@ export function average(attemptResults, eventId) {
9296

9397
switch (attemptResults.length) {
9498
case 3:
95-
return truncateOver10Mins(meanOf3(attemptResults));
99+
return truncateOver10Mins(meanOfX(attemptResults));
96100
case 5:
97101
return truncateOver10Mins(averageOf5(attemptResults));
98102
default:
@@ -111,10 +115,10 @@ function truncateOver10Mins(value) {
111115

112116
function averageOf5(attemptResults) {
113117
const [, x, y, z] = attemptResults.slice().sort(compareAttemptResults);
114-
return meanOf3([x, y, z]);
118+
return meanOfX([x, y, z]);
115119
}
116120

117-
function meanOf3(attemptResults) {
121+
function meanOfX(attemptResults) {
118122
if (!attemptResults.every(isComplete)) return DNF_VALUE;
119123
return mean(attemptResults);
120124
}
@@ -124,6 +128,48 @@ function mean(values) {
124128
return Math.round(sum / values.length);
125129
}
126130

131+
/**
132+
* Returns projected average.
133+
*
134+
* Note that contrarily to other functions in this module, this
135+
* function expects a non-padded and incomplete list of attempt
136+
* results (without trailing skipped values).
137+
*
138+
* Projections are defined as follows:
139+
*
140+
* - mo3 events: mean of current solves
141+
* - ao5 events:
142+
* - 1-2 solves: mean of current solves
143+
* - 3-4 solves: median of current solves
144+
*
145+
* When all result attempts are present, the return value is the same
146+
* as the usual average.
147+
*/
148+
export function projectedAverage(attemptResults, format) {
149+
if (attemptResults.length === 0) return SKIPPED_VALUE;
150+
151+
if (format.numberOfAttempts === 3) {
152+
return meanOfX(attemptResults);
153+
}
154+
155+
if (format.numberOfAttempts === 5) {
156+
if (attemptResults.length < 3) {
157+
return meanOfX(attemptResults);
158+
}
159+
if (attemptResults.length === 3) {
160+
const [, x] = attemptResults.slice().sort(compareAttemptResults);
161+
return x;
162+
}
163+
if (attemptResults.length === 4) {
164+
const [, x, y] = attemptResults.slice().sort(compareAttemptResults);
165+
return meanOfX([x, y]);
166+
}
167+
return averageOf5(attemptResults);
168+
}
169+
170+
throw new Error("Unexpected format");
171+
}
172+
127173
/**
128174
* Calculates the best possible average of 5 for the given attempts.
129175
*
@@ -142,7 +188,7 @@ export function bestPossibleAverage(attemptResults) {
142188
}
143189

144190
const [x, y, z] = attemptResults.slice().sort(compareAttemptResults);
145-
const mean = meanOf3([x, y, z]);
191+
const mean = meanOfX([x, y, z]);
146192
return truncateOver10Mins(mean);
147193
}
148194

@@ -164,7 +210,7 @@ export function worstPossibleAverage(attemptResults) {
164210
}
165211

166212
const [, x, y, z] = attemptResults.slice().sort(compareAttemptResults);
167-
const mean = meanOf3([x, y, z]);
213+
const mean = meanOfX([x, y, z]);
168214
return truncateOver10Mins(mean);
169215
}
170216

0 commit comments

Comments
 (0)