diff --git a/front_end/messages/en.json b/front_end/messages/en.json
index 3b4b48b1fd..44734b7890 100644
--- a/front_end/messages/en.json
+++ b/front_end/messages/en.json
@@ -540,7 +540,10 @@
"average": "Average",
"score": "Score",
"coverage": "Coverage",
+ "coverageMax": "Coverage (max)",
"totalCoverage": "Total Coverage",
+ "totalAttainableCoverage": "Total Attainable Coverage",
+ "effectiveCoverage": "Effective Coverage",
"totalLiveCoverage": "Total Live Coverage",
"predictedQuestions": "Predicted Questions",
"totalScore": "Total Score",
@@ -905,6 +908,7 @@
"deletedAuthor": "deleted author",
"myScore": "My Score",
"coverageInfo": "Your Coverage on that question. If question hasn't resolved yet, this is the amount of the question's lifetime you've covered so far. This is a live value and will change over time and may jump when the question resolves.",
+ "coverageMaxInfo": "Your Coverage on that question, followed in parentheses by the maximum coverage attainable on it. The maximum is less than 100% when a question closes early (for example when it resolves before its scheduled close time), so nobody could have covered its full scheduled lifetime.",
"peerScoreInfo": "Your Peer Score on that question. If you see no score, then either the question has yet to resolve or you haven’t predicted it (or both!).",
"spotPeerScoreInfo": "Your Spot Peer Score on that question. If you see no score, then either the question has yet to resolve or you haven’t predicted it (or both!).",
"spotBaselineScoreInfo": "Your Spot Baseline Score on that question. If you see no score, then either the question has yet to resolve or you haven’t predicted it (or both!).",
@@ -920,6 +924,9 @@
"totalRelativeScoreInfo": "The question-weighted sum of your Relative Scores on all questions in the tournament (but only those that close before the end of the tournament).",
"predictedQuestionsInfo": "The number of resolved and open questions you predicted in this tournament.",
"totalLiveCoverageInfo": "The total amount of live coverage you have in the tournament. This is the sum of your live coverages divided by the number of resolved and open questions. If all questions resolved right now, you would have this much coverage in the tournament.",
+ "totalCoverageInfo": "The average of your coverage across all resolved questions in the tournament (counting questions you didn't predict as 0% coverage).",
+ "totalAttainableCoverageInfo": "The average of the maximum attainable coverage across all resolved questions in the tournament. This is less than 100% when some questions closed early.",
+ "effectiveCoverageInfo": "Your total coverage divided by the total attainable coverage: the sum of your coverage over all resolved questions divided by the sum of the maximum attainable coverage, with both sums weighted by each question's weight. This matches the coverage shown on the tournament leaderboard.",
"questionWeightInfo": "The weight of the question in the tournament. The score you earn from this question is multiplied by this weight.",
"relativeTakeInfo": "Your Take is your coverage times e to the power of your total score. (c*e^s)",
"backgroundInfo": "Background Info",
diff --git a/front_end/src/app/(main)/(leaderboards)/contributions/components/project_contributions.tsx b/front_end/src/app/(main)/(leaderboards)/contributions/components/project_contributions.tsx
index ad90c0d181..d02e07a777 100644
--- a/front_end/src/app/(main)/(leaderboards)/contributions/components/project_contributions.tsx
+++ b/front_end/src/app/(main)/(leaderboards)/contributions/components/project_contributions.tsx
@@ -31,15 +31,44 @@ const ProjectContributions: FC = async ({ project, userId }) => {
contribution.question_weight && contribution.question_weight !== 1.0
);
- const liveCoveragePercent =
- (
- (contributions.reduce(
+ const formatPercent = (value: number | null | undefined) =>
+ value == null ? "-" : `${(value * 100).toFixed(1)}%`;
+
+ // Questions for which the maximum attainable coverage is known (i.e. the
+ // question has resolved successfully). These are the questions that count
+ // towards the tournament's coverage.
+ const resolvedContributions = contributions.filter(
+ (contribution) => !isNil(contribution.attainable_coverage)
+ );
+
+ const totalCoverage = resolvedContributions.length
+ ? resolvedContributions.reduce(
(acc, contribution) => acc + (contribution.coverage || 0),
0
- ) /
- contributions.length) *
- 100
- ).toFixed(1) + "%";
+ ) / resolvedContributions.length
+ : null;
+ const totalAttainableCoverage = resolvedContributions.length
+ ? resolvedContributions.reduce(
+ (acc, contribution) => acc + (contribution.attainable_coverage || 0),
+ 0
+ ) / resolvedContributions.length
+ : null;
+ const coverageWeightedSum = resolvedContributions.reduce(
+ (acc, contribution) =>
+ acc + (contribution.coverage || 0) * (contribution.question_weight ?? 1),
+ 0
+ );
+ const attainableWeightedSum = resolvedContributions.reduce(
+ (acc, contribution) =>
+ acc +
+ (contribution.attainable_coverage || 0) *
+ (contribution.question_weight ?? 1),
+ 0
+ );
+ const effectiveCoverage = attainableWeightedSum
+ ? coverageWeightedSum / attainableWeightedSum
+ : null;
+
const predictedQuestions = contributions.filter(
(contribution) => contribution.coverage
).length;
@@ -55,7 +84,7 @@ const ProjectContributions: FC = async ({ project, userId }) => {
{t("Question")}