From abd56d3ef5a7d8703a24e1d5d81b9450cec46f2d Mon Sep 17 00:00:00 2001 From: lsabor Date: Wed, 25 Mar 2026 14:55:29 -0700 Subject: [PATCH 1/4] issue/3887/feat/max-attainable-coverage closes #3887 adds max attainable coverage helper text to score and performance sections on question page todo: quivalent text on group info is cramped and hover doesn't work quite correclty --- front_end/messages/cs.json | 1 + front_end/messages/en.json | 5 +- front_end/messages/es.json | 1 + front_end/messages/pt.json | 1 + front_end/messages/zh-TW.json | 1 + front_end/messages/zh.json | 1 + .../additional_scores_table.tsx | 66 +++++++++++++++++-- .../post_score_data/participation_summary.tsx | 55 +++++++++++++++- .../src/components/question/score_card.tsx | 2 - 9 files changed, 123 insertions(+), 10 deletions(-) diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index ad4a1b7afe..67bc50a71d 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -2081,5 +2081,6 @@ "tournamentUnfollowModalSubmit": "Zrušit sledování", "switchBackToSlidersHint": "přepněte zpět na posuvníky pro plynulé přizpůsobení", "view": "Zobrazit", + "maxAttainableCoverageExplanation": "Otázky, které se vyřeší dříve, mají své skóre a pokrytí upravené, aby se zachovala správnost skóre. Více se dozvíte zde.", "thousandsOfOpenQuestions": "20 000+ otevřených otázek" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 5b00e5bf43..3fd2ce3a31 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -242,6 +242,7 @@ "communityRelativeLegacyScore": "Community Relative Legacy Score", "communityRelativeLegacyArchivedScore": "Community Relative Legacy Archived Score", "myCoverage": "My coverage", + "maxAttainableCoverageExplanation": "Questions that resolve early have their scores and coverage truncated to preserve score properness. Learn more here.", "myWeightedCoverage": "My weighted coverage", "questionWillOpenAt": "This question will open {{date}}.", "questionNotOpenYet": "This question is not yet open for predictions.", @@ -1567,8 +1568,8 @@ "participations": "Participations", "participationSummary": "Participation Summary", "participationSummaryPredictionNrStats": "You made {userUpdates} {userUpdates, plural, =0 {updates} =1 {update} other {updates} } vs. a community average of {communityUpdates} {communityUpdates, plural, =0 {updates} =1 {update} other {updates} }.", - "participationSummaryCoverageBetterStats": "Your coverage was {userCoverage}%, better than the average forecaster on this question ({averageCoverage}%).", - "participationSummaryCoverageWorseStats": "Your coverage was {userCoverage}%, worse than the average forecaster on this question ({averageCoverage}%).", + "participationSummaryCoverageBetterStats": "Your coverage was {userCoverage}%, better than the average forecaster on this question ({averageCoverage}%).", + "participationSummaryCoverageWorseStats": "Your coverage was {userCoverage}%, worse than the average forecaster on this question ({averageCoverage}%).", "participationSummaryScoreOutperformance": "Congrats, you outperformed the Community Prediction in {scoreTypes}.", "peer": "Peer", "bothPeerAndBaseline": "both Peer and Baseline Scores", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 18ff4bd96c..f59453dcbe 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -2081,5 +2081,6 @@ "tournamentUnfollowModalSubmit": "Dejar de Seguir", "switchBackToSlidersHint": "vuelve a los deslizadores para un ajuste suave", "view": "Ver", + "maxAttainableCoverageExplanation": "Las preguntas que se resuelven pronto tienen sus puntuaciones y cobertura truncadas para preservar la propiedad adecuada de la puntuación. Aprende más aquí.", "thousandsOfOpenQuestions": "20,000+ preguntas abiertas" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index f31bf81d1f..5258d3be23 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -2079,5 +2079,6 @@ "tournamentUnfollowModalSubmit": "Deixar de Seguir", "switchBackToSlidersHint": "volte para os controles deslizantes para um ajuste suave", "view": "Visualizar", + "maxAttainableCoverageExplanation": "Questões que são resolvidas cedo têm suas pontuações e coberturas truncadas para preservar a correção da pontuação. Saiba mais aqui.", "thousandsOfOpenQuestions": "20.000+ perguntas abertas" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 5366a19851..5c9fc52b8c 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -2078,5 +2078,6 @@ "tournamentUnfollowModalSubmit": "取消關注", "switchBackToSlidersHint": "切換回滑桿以平滑調整", "view": "檢視", + "maxAttainableCoverageExplanation": "為了保持分數的正確性,早期解決的問題其分數和覆蓋率會被截斷。在這裡了解更多。", "thousandsOfOpenQuestions": "20,000+ 開放問題" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 3e36adf335..e120fe4386 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -2083,5 +2083,6 @@ "tournamentUnfollowModalSubmit": "取消关注", "switchBackToSlidersHint": "切回滑块以进行更精细的调整", "view": "查看", + "maxAttainableCoverageExplanation": "为确保分数的准确性,提前解决的问题,其分数和覆盖率会被截断。在此了解更多信息。", "thousandsOfOpenQuestions": "20,000+ 开放问题" } diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/additional_scores_table.tsx b/front_end/src/app/(main)/questions/[id]/components/post_score_data/additional_scores_table.tsx index 0882500957..763f0f0919 100644 --- a/front_end/src/app/(main)/questions/[id]/components/post_score_data/additional_scores_table.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/additional_scores_table.tsx @@ -1,8 +1,11 @@ +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { isNil } from "lodash"; import { useTranslations } from "next-intl"; -import React, { FC } from "react"; +import React, { FC, ReactNode } from "react"; import SectionToggle from "@/components/ui/section_toggle"; +import Tooltip from "@/components/ui/tooltip"; import { QuestionWithForecasts, ScoreData } from "@/types/question"; import { TranslationKey } from "@/types/translations"; import cn from "@/utils/core/cn"; @@ -14,8 +17,10 @@ type Props = { variant?: Variant; }; +type ScoreRow = { label: string; value: string; valueSuffix?: ReactNode }; + const ScoreTable: FC<{ - rows: { label: string; value: string }[]; + rows: ScoreRow[]; className?: string; variant?: Variant; }> = ({ rows, className, variant = "auto" }) => ( @@ -42,11 +47,12 @@ const ScoreTable: FC<{ {row.value} + {row.valueSuffix} ))} @@ -77,6 +83,21 @@ const buildScoreLabelKey = ( return (prefix + toCamel(key) + suffix) as TranslationKey; }; +/** + * Returns the max attainable peer coverage (0–1) for a question that resolved + * before its scheduled close time, or null if not applicable. + */ +const getMaxCoverage = (question: QuestionWithForecasts): number | null => { + const { open_time, actual_close_time, scheduled_close_time } = question; + if (!open_time || !actual_close_time || !scheduled_close_time) return null; + const open = new Date(open_time).getTime(); + const actualClose = new Date(actual_close_time).getTime(); + const scheduledClose = new Date(scheduled_close_time).getTime(); + const totalDuration = scheduledClose - open; + if (totalDuration <= 0) return null; + return (actualClose - open) / totalDuration; +}; + export const AdditionalScoresTable: FC = ({ question, separateCoverage, @@ -107,8 +128,41 @@ export const AdditionalScoresTable: FC = ({ "weighted_coverage", ]; - const coverageRows: { label: string; value: string }[] = []; - const otherRows: { label: string; value: string }[] = []; + // Peer coverage uses scheduled_close_time as total duration, so early + // resolution reduces the max attainable coverage. + const maxCoverage = getMaxCoverage(question); + let maxCoverageValueSuffix: ReactNode; + if (maxCoverage !== null) { + maxCoverageValueSuffix = ( + + {" (max. "} + {(maxCoverage * 100).toFixed(1)}% + ( + + {chunks} + + ), + })} + > + + + {")"} + + ); + } + + const coverageRows: ScoreRow[] = []; + const otherRows: ScoreRow[] = []; for (const key of scoreKeys) { if (key === peerKey || key === baselineKey) continue; @@ -126,6 +180,8 @@ export const AdditionalScoresTable: FC = ({ targetRows.push({ label: t(buildScoreLabelKey(key, "user")), value: formattedValue, + // Only peer coverage (key === "coverage") is affected by early resolution + ...(key === "coverage" ? { valueSuffix: maxCoverageValueSuffix } : {}), }); } diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary.tsx b/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary.tsx index a858fdf0d3..2b2ea85e37 100644 --- a/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary.tsx @@ -1,10 +1,15 @@ import { faClock } from "@fortawesome/free-regular-svg-icons"; -import { faFire, faRepeat } from "@fortawesome/free-solid-svg-icons"; +import { + faCircleInfo, + faFire, + faRepeat, +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { isNil } from "lodash"; import { useTranslations } from "next-intl"; import React, { PropsWithChildren, ReactNode } from "react"; +import Tooltip from "@/components/ui/tooltip"; import { QuestionWithForecasts } from "@/types/question"; import cn from "@/utils/core/cn"; @@ -34,6 +39,21 @@ type Props = { itemClassName?: string; }; +/** + * Returns the max attainable peer coverage (0–1) for a question that resolved + * before its scheduled close time, or null if not applicable. + */ +const getMaxCoverage = (question: QuestionWithForecasts): number | null => { + const { open_time, actual_close_time, scheduled_close_time } = question; + if (!open_time || !actual_close_time || !scheduled_close_time) return null; + const open = new Date(open_time).getTime(); + const actualClose = new Date(actual_close_time).getTime(); + const scheduledClose = new Date(scheduled_close_time).getTime(); + const totalDuration = scheduledClose - open; + if (totalDuration <= 0) return null; + return (actualClose - open) / totalDuration; +}; + export const ParticipationSummary: React.FC = ({ question, forecastsCount, @@ -91,6 +111,38 @@ export const ParticipationSummary: React.FC = ({ ); + // Only peer coverage (non-spot) is affected by early resolution. + const maxCoverage = isSpot ? null : getMaxCoverage(question); + const richMaxCoverageDisplay = (_chunks: ReactNode) => { + if (maxCoverage === null) return null; + return ( + + {" (max. "} + {Math.round(maxCoverage * 100)}% + ( + + {chunks} + + ), + })} + > + + + {")"} + + ); + }; + return (
= ({ strong: richStrong, userCoverage: Math.round(userCoverage * 100), averageCoverage: Math.round(averageCoverage * 100), + maxCoverageDisplay: richMaxCoverageDisplay, } )} diff --git a/front_end/src/components/question/score_card.tsx b/front_end/src/components/question/score_card.tsx index 5c9bfee036..49208678e0 100644 --- a/front_end/src/components/question/score_card.tsx +++ b/front_end/src/components/question/score_card.tsx @@ -466,8 +466,6 @@ export default function ScoreCard({ const t = useTranslations(); const isSpot = type.includes("spot"); - console.log("isSpot", isSpot, type); - if (type.includes("peer")) { return ( Date: Wed, 25 Mar 2026 15:12:17 -0700 Subject: [PATCH 2/4] fix minor group question issues --- .../additional_scores_table.tsx | 58 +++++++------------ .../post_score_data/participation_summary.tsx | 41 +++++++------ 2 files changed, 42 insertions(+), 57 deletions(-) diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/additional_scores_table.tsx b/front_end/src/app/(main)/questions/[id]/components/post_score_data/additional_scores_table.tsx index 763f0f0919..994ad22f16 100644 --- a/front_end/src/app/(main)/questions/[id]/components/post_score_data/additional_scores_table.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/additional_scores_table.tsx @@ -26,7 +26,7 @@ const ScoreTable: FC<{ }> = ({ rows, className, variant = "auto" }) => (
@@ -35,22 +35,10 @@ const ScoreTable: FC<{ key={index} className="flex items-center border-b border-gray-300 px-4 py-3 last:border-b-0 dark:border-gray-300-dark" > - + {row.label} - + {row.value} {row.valueSuffix} @@ -131,33 +119,31 @@ export const AdditionalScoresTable: FC = ({ // Peer coverage uses scheduled_close_time as total duration, so early // resolution reduces the max attainable coverage. const maxCoverage = getMaxCoverage(question); + const tooltipContent = t.rich("maxAttainableCoverageExplanation", { + link: (chunks) => ( + + {chunks} + + ), + }); let maxCoverageValueSuffix: ReactNode; if (maxCoverage !== null) { maxCoverageValueSuffix = ( - - {" (max. "} - {(maxCoverage * 100).toFixed(1)}% - ( - - {chunks} - - ), - })} - > + + + (max. {(maxCoverage * 100).toFixed(1)}% - - {")"} - + ) + + ); } diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary.tsx b/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary.tsx index 2b2ea85e37..096e4948b3 100644 --- a/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/participation_summary.tsx @@ -113,33 +113,32 @@ export const ParticipationSummary: React.FC = ({ // Only peer coverage (non-spot) is affected by early resolution. const maxCoverage = isSpot ? null : getMaxCoverage(question); + const coverageTooltipContent = t.rich("maxAttainableCoverageExplanation", { + link: (chunks) => ( + + {chunks} + + ), + }); const richMaxCoverageDisplay = (_chunks: ReactNode) => { if (maxCoverage === null) return null; return ( - - {" (max. "} - {Math.round(maxCoverage * 100)}% - ( - - {chunks} - - ), - })} - > + + + {" (max. "} + {Math.round(maxCoverage * 100)}% - - {")"} - + {")"} + + ); }; From a6b3163fa9345bb32ba4bbd5b05d65d605acfdc0 Mon Sep 17 00:00:00 2001 From: lsabor Date: Wed, 25 Mar 2026 15:24:24 -0700 Subject: [PATCH 3/4] update translations --- front_end/messages/cs.json | 4 ++-- front_end/messages/es.json | 4 ++-- front_end/messages/pt.json | 4 ++-- front_end/messages/zh-TW.json | 4 ++-- front_end/messages/zh.json | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index dfb14d1bff..32108b8ffd 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1684,8 +1684,8 @@ "resolutionScores": "Skóre výsledků", "participationSummary": "Shrnutí účasti", "participationSummaryPredictionNrStats": "Udělal(a) jste {userUpdates} {userUpdates, plural, =0 {aktualizací} =1 {aktualizaci} other {aktualizací} } vs. průměr komunity {communityUpdates} {communityUpdates, plural, =0 {aktualizací} =1 {aktualizaci} other {aktualizací} }.", - "participationSummaryCoverageBetterStats": "Vaše pokrytí bylo {userCoverage}%, což je lepší než průměrný předpovídač v této otázce ({averageCoverage}%).", - "participationSummaryCoverageWorseStats": "Vaše pokrytí bylo {userCoverage}%, což je horší než průměrný předpovídač v této otázce ({averageCoverage}%).", + "participationSummaryCoverageBetterStats": "Vaše pokrytí bylo {userCoverage}%, což je lepší než průměrný předpovídač v této otázce ({averageCoverage}%).", + "participationSummaryCoverageWorseStats": "Vaše pokrytí bylo {userCoverage}%, což je horší než průměrný předpovídač v této otázce ({averageCoverage}%).", "peer": "Vrstevník", "bothPeerAndBaseline": "jak vrstevnické, tak základní", "scores": "Skóre", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index ccf54b4229..5d887d3f7f 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1683,8 +1683,8 @@ "resolutionScores": "Puntajes de Resolución", "participationSummary": "Resumen de Participación", "participationSummaryPredictionNrStats": "Hiciste {userUpdates} {userUpdates, plural, =0 {actualizaciones} =1 {actualización} other {actualizaciones} } versus un promedio comunitario de {communityUpdates} {communityUpdates, plural, =0 {actualizaciones} =1 {actualización} other {actualizaciones} }.", - "participationSummaryCoverageBetterStats": "Tu cobertura fue del {userCoverage}%, mejor que el promedio de pronosticadores en esta pregunta ({averageCoverage}%).", - "participationSummaryCoverageWorseStats": "Tu cobertura fue del {userCoverage}%, peor que el promedio de pronosticadores en esta pregunta ({averageCoverage}%).", + "participationSummaryCoverageBetterStats": "Tu cobertura fue del {userCoverage}%, mejor que el promedio de pronosticadores en esta pregunta ({averageCoverage}%).", + "participationSummaryCoverageWorseStats": "Tu cobertura fue del {userCoverage}%, peor que el promedio de pronosticadores en esta pregunta ({averageCoverage}%).", "peer": "Pares", "bothPeerAndBaseline": "tanto en Pares como en Referencia", "scores": "Puntuaciones", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index e2619e7a37..b9ec186834 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -1681,8 +1681,8 @@ "resolutionScores": "Pontuações de Resolução", "participationSummary": "Resumo de Participação", "participationSummaryPredictionNrStats": "Você fez {userUpdates} {userUpdates, plural, =0 {atualizações} =1 {atualização} other {atualizações} } vs. uma média da comunidade de {communityUpdates} {communityUpdates, plural, =0 {atualizações} =1 {atualização} other {atualizações} }.", - "participationSummaryCoverageBetterStats": "Sua cobertura foi de {userCoverage}%, melhor que a do previsor médio nesta pergunta ({averageCoverage}%).", - "participationSummaryCoverageWorseStats": "Sua cobertura foi de {userCoverage}%, pior que a do previsor médio nesta pergunta ({averageCoverage}%).", + "participationSummaryCoverageBetterStats": "Sua cobertura foi de {userCoverage}%, melhor que a do previsor médio nesta pergunta ({averageCoverage}%).", + "participationSummaryCoverageWorseStats": "Sua cobertura foi de {userCoverage}%, pior que a do previsor médio nesta pergunta ({averageCoverage}%).", "peer": "Par", "bothPeerAndBaseline": "tanto Par quanto Referência", "scores": "Pontuações", diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 96de58d5c4..25e6793b1d 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1680,8 +1680,8 @@ "resolutionScores": "決議分數", "participationSummary": "參與總結", "participationSummaryPredictionNrStats": "您做出了 {userUpdates} {userUpdates, plural, =0 {次更新} =1 {次更新} other {次更新} },而社群平均為 {communityUpdates} {communityUpdates, plural, =0 {次更新} =1 {次更新} other {次更新} }。", - "participationSummaryCoverageBetterStats": "您的涵蓋率是 {userCoverage}%,比該問題的平均預測者更好({averageCoverage}%)。", - "participationSummaryCoverageWorseStats": "您的涵蓋率是 {userCoverage}%,比該問題的平均預測者更差({averageCoverage}%)。", + "participationSummaryCoverageBetterStats": "您的涵蓋率是 {userCoverage}%,比該問題的平均預測者更好({averageCoverage}%)。", + "participationSummaryCoverageWorseStats": "您的涵蓋率是 {userCoverage}%,比該問題的平均預測者更差({averageCoverage}%)。", "peer": "同行", "bothPeerAndBaseline": "同行和基準", "scores": "分數", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 7061f0deac..816ad4ac58 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1685,8 +1685,8 @@ "resolutionScores": "解答分数", "participationSummary": "参与摘要", "participationSummaryPredictionNrStats": "您做出了 {userUpdates} {userUpdates, plural, =0 {次更新} =1 {次更新} other {次更新} },社区平均为 {communityUpdates} {communityUpdates, plural, =0 {次更新} =1 {次更新} other {次更新} }。", - "participationSummaryCoverageBetterStats": "您的覆盖率为 {userCoverage}%,优于该问题上的平均预测者({averageCoverage}%)。", - "participationSummaryCoverageWorseStats": "您的覆盖率为 {userCoverage}%,逊于该问题上的平均预测者({averageCoverage}%)。", + "participationSummaryCoverageBetterStats": "您的覆盖率为 {userCoverage}%,优于该问题上的平均预测者({averageCoverage}%)。", + "participationSummaryCoverageWorseStats": "您的覆盖率为 {userCoverage}%,逊于该问题上的平均预测者({averageCoverage}%)。", "peer": "同行", "bothPeerAndBaseline": "同行和基准", "scores": "得分", From 3bb2c175e2faa08584f817237f1b31fbece99a53 Mon Sep 17 00:00:00 2001 From: lsabor Date: Wed, 25 Mar 2026 15:28:32 -0700 Subject: [PATCH 4/4] remove lint issue --- .../[id]/components/post_score_data/additional_scores_table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front_end/src/app/(main)/questions/[id]/components/post_score_data/additional_scores_table.tsx b/front_end/src/app/(main)/questions/[id]/components/post_score_data/additional_scores_table.tsx index 994ad22f16..86e7e6621f 100644 --- a/front_end/src/app/(main)/questions/[id]/components/post_score_data/additional_scores_table.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/post_score_data/additional_scores_table.tsx @@ -23,7 +23,7 @@ const ScoreTable: FC<{ rows: ScoreRow[]; className?: string; variant?: Variant; -}> = ({ rows, className, variant = "auto" }) => ( +}> = ({ rows, className }) => (