From eceb16ff5ee922aa3b08aef0c5215c12de2f9f5c Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 19 Sep 2025 17:44:35 +0300 Subject: [PATCH 1/5] feat: handle withdrawn predicitons for binary groups --- .../forecast_maker/forecast_choice_option.tsx | 30 +++++++++------ .../forecast_maker_group_binary.tsx | 38 +++++++++++++------ 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/front_end/src/components/forecast_maker/forecast_choice_option.tsx b/front_end/src/components/forecast_maker/forecast_choice_option.tsx index b480d8361..7f5656b24 100644 --- a/front_end/src/components/forecast_maker/forecast_choice_option.tsx +++ b/front_end/src/components/forecast_maker/forecast_choice_option.tsx @@ -46,6 +46,7 @@ type Props = { optionResolution?: OptionResolution; highlightedOptionId?: T; onOptionClick?: (id: T) => void; + withdrawn?: boolean; }; const ForecastChoiceOption = ({ @@ -65,6 +66,7 @@ const ForecastChoiceOption = ({ disabled = false, optionResolution, onOptionClick, + withdrawn = false, }: Props) => { const t = useTranslations(); @@ -194,17 +196,23 @@ const ForecastChoiceOption = ({ {forecastColumnValue} - setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - disabled={disabled} - /> + {withdrawn && !isDirty ? ( +
+ × +
+ ) : ( + setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + disabled={disabled} + /> + )}
diff --git a/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_binary.tsx b/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_binary.tsx index 459e7bab3..698f5fa8c 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_binary.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_binary.tsx @@ -74,6 +74,8 @@ type QuestionOption = { menu: ReactNode; status?: QuestionStatus; forecastExpiration?: ForecastExpirationValue; + defaultSliderValue: number; + wasWithdrawn: boolean; }; type Props = { @@ -216,26 +218,33 @@ const ForecastMakerGroupBinary: FC = ({ const [submitError, setSubmitError] = useState(); const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false); - const questionsToSubmit = useMemo( - () => - questionOptions.filter( - (option) => - option.forecast !== null && option.status === QuestionStatus.OPEN - ), - [questionOptions] - ); const isPickerDirty = useMemo( () => questionOptions.some((option) => option.isDirty), [questionOptions] ); + const questionsToSubmit = useMemo(() => { + const byId = new Map(questions.map((q) => [q.id, q])); + return questionOptions.filter((option) => { + if (option.status !== QuestionStatus.OPEN) return false; + if (option.isDirty) return true; + if (!isPickerDirty && hasSomeActiveUserForecasts) { + const q = byId.get(option.id); + return q ? isOpenQuestionPredicted(q) : false; + } + return false; + }); + }, [questionOptions, questions, isPickerDirty, hasSomeActiveUserForecasts]); + const resetForecasts = useCallback(() => { setQuestionOptions((prev) => prev.map((prevQuestion) => ({ ...prevQuestion, isDirty: false, - forecast: prevForecastValuesMap[prevQuestion.id] ?? null, + forecast: prevQuestion.wasWithdrawn + ? null + : prevForecastValuesMap[prevQuestion.id] ?? null, })) ); }, [prevForecastValuesMap]); @@ -359,7 +368,7 @@ const ForecastMakerGroupBinary: FC = ({ highlightedOptionId={highlightedQuestionId} onOptionClick={setHighlightedQuestionId} forecastValue={questionOption.forecast} - defaultSliderValue={50} + defaultSliderValue={questionOption.defaultSliderValue} choiceName={questionOption.name} choiceColor={questionOption.color} communityForecast={ @@ -369,6 +378,7 @@ const ForecastMakerGroupBinary: FC = ({ inputMax={BINARY_MAX_VALUE} onChange={handleForecastChange} isDirty={questionOption.isDirty} + withdrawn={questionOption.wasWithdrawn} isRowDirty={questionOption.isDirty} menu={questionOption.menu} disabled={ @@ -505,14 +515,20 @@ function generateChoiceOptions({ return questions.map((question, index) => { const latest = question.aggregations[question.default_aggregation_method].latest; + + const last = question.my_forecasts?.latest; + const wasWithdrawn = !!last?.end_time && last.end_time * 1000 < Date.now(); + const prev = prevForecastValuesMap[question.id]; return { id: question.id, name: question.label, communityForecast: latest && isForecastActive(latest) ? latest.centers?.[0] ?? null : null, - forecast: prevForecastValuesMap[question.id] ?? null, + forecast: wasWithdrawn ? null : prev ?? null, resolution: question.resolution, isDirty: false, + defaultSliderValue: prev ?? 50, + wasWithdrawn, color: MULTIPLE_CHOICE_COLOR_SCALE[index] ?? METAC_COLORS.gray["400"], status: question.status, forecastExpiration, From 80b5ef051f0dd6e1f2a5f7dac702229e1bab5061 Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 19 Sep 2025 17:44:39 +0300 Subject: [PATCH 2/5] feat: add tooltip for binary groups --- .../forecast_maker/forecast_choice_option.tsx | 58 ++++++++++++++++--- .../forecast_maker_group_binary.tsx | 3 + 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/front_end/src/components/forecast_maker/forecast_choice_option.tsx b/front_end/src/components/forecast_maker/forecast_choice_option.tsx index 7f5656b24..bcf7f0035 100644 --- a/front_end/src/components/forecast_maker/forecast_choice_option.tsx +++ b/front_end/src/components/forecast_maker/forecast_choice_option.tsx @@ -1,7 +1,9 @@ "use client"; +import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { isNil } from "lodash"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { FC, useCallback, @@ -20,9 +22,11 @@ import useMounted from "@/hooks/use_mounted"; import { Resolution } from "@/types/post"; import { ThemeColor } from "@/types/theme"; import cn from "@/utils/core/cn"; +import { formatRelativeDate } from "@/utils/formatters/date"; import { getForecastPctDisplayValue } from "@/utils/formatters/prediction"; import ForecastTextInput from "./forecast_text_input"; +import Tooltip from "../ui/tooltip"; type OptionResolution = { resolution: Resolution | null; @@ -47,6 +51,7 @@ type Props = { highlightedOptionId?: T; onOptionClick?: (id: T) => void; withdrawn?: boolean; + withdrawnEndTimeSec?: number | null; }; const ForecastChoiceOption = ({ @@ -67,12 +72,17 @@ const ForecastChoiceOption = ({ optionResolution, onOptionClick, withdrawn = false, + withdrawnEndTimeSec = null, }: Props) => { const t = useTranslations(); + const locale = useLocale(); - const inputDisplayValue = forecastValue - ? forecastValue?.toString() + "%" - : "—"; + const inputDisplayValue = + withdrawn && !isDirty + ? `${defaultSliderValue}%` + : forecastValue != null + ? `${forecastValue}%` + : "—"; const [inputValue, setInputValue] = useState(inputDisplayValue); const [isInputFocused, setIsInputFocused] = useState(false); const { getThemeColor } = useAppTheme(); @@ -127,6 +137,10 @@ const ForecastChoiceOption = ({ [id, onChange] ); + const withdrawnLabel = withdrawnEndTimeSec + ? `Withdrawn ${formatRelativeDate(locale, new Date(withdrawnEndTimeSec * 1000), { short: true })}` + : "Withdrawn"; + const SliderElement = (
({ {forecastColumnValue} - {withdrawn && !isDirty ? ( -
- × -
+ {withdrawn && !isDirty && !isInputFocused ? ( + +
+
+ { + setIsInputFocused(true); + onChange(id, defaultSliderValue); + }} + onBlur={() => setIsInputFocused(false)} + disabled={disabled} + /> +
+ +
+ + + +
+
+
) : ( = ({ onChange={handleForecastChange} isDirty={questionOption.isDirty} withdrawn={questionOption.wasWithdrawn} + withdrawnEndTimeSec={questionOption.withdrawnEndTimeSec} isRowDirty={questionOption.isDirty} menu={questionOption.menu} disabled={ @@ -529,6 +531,7 @@ function generateChoiceOptions({ isDirty: false, defaultSliderValue: prev ?? 50, wasWithdrawn, + withdrawnEndTimeSec: last?.end_time ?? null, color: MULTIPLE_CHOICE_COLOR_SCALE[index] ?? METAC_COLORS.gray["400"], status: question.status, forecastExpiration, From 4b58e0e062c2c58de0fc4d58bea86d33c51a1888 Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 19 Sep 2025 17:44:44 +0300 Subject: [PATCH 3/5] feat: update withdrawal flow for continuous forecasts --- .../charts/continuous_area_chart.tsx | 9 +- .../accordion_resolution_cell.tsx | 33 +++- .../group_forecast_accordion.tsx | 2 + .../group_forecast_accordion_item.tsx | 29 +++- .../continuous_prediction_chart.tsx | 37 ++++- .../forecast_maker/continuous_input/index.tsx | 16 ++ .../forecast_maker/continuous_table/index.tsx | 38 +++-- .../forecast_maker/forecast_expiration.tsx | 18 ++- .../continuous_input_wrapper.tsx | 42 +++-- .../forecast_maker_group_continuous.tsx | 151 ++++++++++++------ .../withdraw/withdraw_confirmation.tsx | 6 +- 11 files changed, 301 insertions(+), 80 deletions(-) diff --git a/front_end/src/components/charts/continuous_area_chart.tsx b/front_end/src/components/charts/continuous_area_chart.tsx index 1dd79fa93..14744fbcf 100644 --- a/front_end/src/components/charts/continuous_area_chart.tsx +++ b/front_end/src/components/charts/continuous_area_chart.tsx @@ -89,6 +89,7 @@ type Props = { forceTickCount?: number; // is used on feed page withResolutionChip?: boolean; withTodayLine?: boolean; + outlineUser?: boolean; }; const ContinuousAreaChart: FC = ({ @@ -106,6 +107,7 @@ const ContinuousAreaChart: FC = ({ forceTickCount, withResolutionChip = true, withTodayLine = true, + outlineUser = false, }) => { const locale = useLocale(); const { ref: chartContainerRef, width: containerWidth } = @@ -529,7 +531,12 @@ const ContinuousAreaChart: FC = ({ return undefined; } })(), - opacity: chart.type === "user_previous" ? 0.1 : 0.3, + opacity: + outlineUser && chart.type === "user" + ? 0 + : chart.type === "user_previous" + ? 0.1 + : 0.3, }, }} /> diff --git a/front_end/src/components/forecast_maker/continuous_group_accordion/accordion_resolution_cell.tsx b/front_end/src/components/forecast_maker/continuous_group_accordion/accordion_resolution_cell.tsx index 302b6ad79..f4e075ee4 100644 --- a/front_end/src/components/forecast_maker/continuous_group_accordion/accordion_resolution_cell.tsx +++ b/front_end/src/components/forecast_maker/continuous_group_accordion/accordion_resolution_cell.tsx @@ -1,7 +1,10 @@ +import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { isNil } from "lodash"; import { FC } from "react"; import ResolutionIcon from "@/components/icons/resolution"; +import Tooltip from "@/components/ui/tooltip"; import { QuestionStatus, Resolution } from "@/types/post"; import cn from "@/utils/core/cn"; import { isUnsuccessfullyResolved } from "@/utils/questions/resolution"; @@ -13,6 +16,7 @@ type Props = { resolution: Resolution | null; type: QuestionStatus.OPEN | QuestionStatus.CLOSED | QuestionStatus.RESOLVED; unit?: string; + withdrawnLabel?: string; }; const AccordionResolutionCell: FC = ({ @@ -21,6 +25,7 @@ const AccordionResolutionCell: FC = ({ userMedian, resolution, type, + withdrawnLabel, }) => { const isResolved = !isNil(resolution); if (isResolved) { @@ -54,9 +59,31 @@ const AccordionResolutionCell: FC = ({ {median}

{!!userMedian && ( -

- {userMedian} -

+
+ {withdrawnLabel ? ( + +
+ + {userMedian} + + +
+
+ ) : ( +

+ {userMedian} +

+ )} +
)}
); diff --git a/front_end/src/components/forecast_maker/continuous_group_accordion/group_forecast_accordion.tsx b/front_end/src/components/forecast_maker/continuous_group_accordion/group_forecast_accordion.tsx index ed379859e..7fcceada2 100644 --- a/front_end/src/components/forecast_maker/continuous_group_accordion/group_forecast_accordion.tsx +++ b/front_end/src/components/forecast_maker/continuous_group_accordion/group_forecast_accordion.tsx @@ -36,6 +36,8 @@ export type ContinuousGroupOption = { resolution: Resolution | null; menu?: ReactNode; forecastExpiration?: ForecastExpirationValue; + wasWithdrawn?: boolean; + withdrawnEndTimeSec?: number | null; }; type Props = { diff --git a/front_end/src/components/forecast_maker/continuous_group_accordion/group_forecast_accordion_item.tsx b/front_end/src/components/forecast_maker/continuous_group_accordion/group_forecast_accordion_item.tsx index 0b3ad96ad..7e577e09a 100644 --- a/front_end/src/components/forecast_maker/continuous_group_accordion/group_forecast_accordion_item.tsx +++ b/front_end/src/components/forecast_maker/continuous_group_accordion/group_forecast_accordion_item.tsx @@ -18,8 +18,10 @@ import { getQuantileNumericForecastDataset, getSliderNumericForecastDataset, } from "@/utils/forecasts/dataset"; +import { formatRelativeDate } from "@/utils/formatters/date"; import { getPredictionDisplayValue } from "@/utils/formatters/prediction"; import { formatResolution } from "@/utils/formatters/resolution"; +import { computeQuartilesFromCDF } from "@/utils/math"; import { AccordionOpenButton } from "./accordion_open_button"; import { AccordionResolutionCell } from "./accordion_resolution_cell"; @@ -94,7 +96,19 @@ const AccordionItem: FC> = memo( actual_resolve_time: option.question.actual_resolve_time ?? null, } ); - const userMedian = showUserPrediction + const endSec = option.withdrawnEndTimeSec; + const wasWithdrawn = endSec != null && endSec * 1000 < Date.now(); + const withdrawnMedian = + wasWithdrawn && question.my_forecasts?.latest?.forecast_values + ? computeQuartilesFromCDF(question.my_forecasts.latest.forecast_values) + .median + : undefined; + + const withdrawnLabel = wasWithdrawn + ? `Withdrawn ${formatRelativeDate(locale, new Date(endSec * 1000), { short: true })}` + : undefined; + + let userMedian = showUserPrediction ? forecastInputMode === ContinuousForecastInputType.Quantile ? getPredictionDisplayValue( option.userQuantileForecast?.find((q) => q.quantile === Quantile.q2) @@ -113,6 +127,15 @@ const AccordionItem: FC> = memo( }) : undefined; + if (wasWithdrawn && !isDirty && withdrawnMedian != null) { + userMedian = getPredictionDisplayValue(withdrawnMedian, { + questionType: option.question.type, + scaling: option.question.scaling, + unit, + actual_resolve_time: option.question.actual_resolve_time ?? null, + }); + } + const handleClick = () => { setIsModalOpen((prev) => !prev); }; @@ -171,6 +194,9 @@ const AccordionItem: FC> = memo( : undefined } type={type} + withdrawnLabel={ + wasWithdrawn && !isDirty ? withdrawnLabel : undefined + } />
> = memo( question={question} withResolutionChip={false} withTodayLine={false} + outlineUser={wasWithdrawn && !isDirty} />
diff --git a/front_end/src/components/forecast_maker/continuous_input/continuous_prediction_chart.tsx b/front_end/src/components/forecast_maker/continuous_input/continuous_prediction_chart.tsx index eb4f32c50..d0563199b 100644 --- a/front_end/src/components/forecast_maker/continuous_input/continuous_prediction_chart.tsx +++ b/front_end/src/components/forecast_maker/continuous_input/continuous_prediction_chart.tsx @@ -1,5 +1,5 @@ import { useTranslations } from "next-intl"; -import { FC, useCallback, useMemo, useState } from "react"; +import { FC, useCallback, useMemo, useRef, useState } from "react"; import { VictoryThemeDefinition } from "victory"; import ContinuousAreaChart, { @@ -38,6 +38,24 @@ type Props = { width?: number; showCP?: boolean; chartTheme?: VictoryThemeDefinition; + outlineUser?: boolean; +}; + +const arraysAlmostEqual = ( + a: ReadonlyArray | null | undefined, + b: ReadonlyArray | null | undefined, + eps = 1e-9 +): boolean => { + if (!a || !b) return false; + if (a.length !== b.length) return false; + + for (let i = 0; i < a.length; i++) { + const ai = a[i]; + const bi = b[i]; + if (ai === undefined || bi === undefined) return false; + if (Math.abs(ai - bi) > eps) return false; + } + return true; }; const ContinuousPredictionChart: FC = ({ @@ -50,13 +68,14 @@ const ContinuousPredictionChart: FC = ({ width = undefined, showCP = true, chartTheme, + outlineUser = false, }) => { const t = useTranslations(); const [hoverState, setHoverState] = useState( null ); - + const overlayLockedRef = useRef(outlineUser); const discreteValueOptions = getDiscreteValueOptions(question); const cursorDisplayData = useMemo(() => { @@ -126,7 +145,17 @@ const ContinuousPredictionChart: FC = ({ }); } - if (overlayPreviousForecast && myLatest) { + const sameAsPrev = + myLatest && arraysAlmostEqual(dataset.cdf, myLatest.forecast_values); + + const shouldShowPrev = + !!overlayPreviousForecast && + !!myLatest && + !overlayLockedRef.current && + !outlineUser && + !sameAsPrev; + + if (shouldShowPrev) { charts.push({ pmf: cdfToPmf(myLatest.forecast_values), cdf: myLatest.forecast_values, @@ -152,6 +181,7 @@ const ContinuousPredictionChart: FC = ({ myLatest, readOnly, dataset, + outlineUser, ]); const xLabel = cursorDisplayData?.xLabel ?? ""; @@ -184,6 +214,7 @@ const ContinuousPredictionChart: FC = ({ onCursorChange={handleCursorChange} extraTheme={chartTheme} alignChartTabs={true} + outlineUser={outlineUser} />
{cursorDisplayData && ( diff --git a/front_end/src/components/forecast_maker/continuous_input/index.tsx b/front_end/src/components/forecast_maker/continuous_input/index.tsx index 2b4ce736d..bd0760e96 100644 --- a/front_end/src/components/forecast_maker/continuous_input/index.tsx +++ b/front_end/src/components/forecast_maker/continuous_input/index.tsx @@ -54,6 +54,10 @@ type Props = { predictionMessage?: ReactNode; menu?: ReactNode; copyMenu?: ReactNode; + userPreviousLabel?: string; + userPreviousRowClassName?: string; + hideCurrentUserRow?: boolean; + outlineUser?: boolean; }; const ContinuousInput: FC = ({ @@ -78,6 +82,10 @@ const ContinuousInput: FC = ({ predictionMessage, menu, copyMenu, + userPreviousLabel, + userPreviousRowClassName, + hideCurrentUserRow, + outlineUser = false, }) => { const { user } = useAuth(); const { hideCP } = useHideCP(); @@ -120,6 +128,10 @@ const ContinuousInput: FC = ({ const discrete = question.type === QuestionType.Discrete; + const derivedHideCurrentUserRow = + hideCurrentUserRow ?? + (!isDirty && (!hasUserForecast || !userCdf || userCdf.length === 0)); + return ( = ({ question={question} readOnly={disabled} showCP={!user || !hideCP || !!question.resolution} + outlineUser={outlineUser} /> {forecastInputMode === ContinuousForecastInputType.Slider && ( @@ -187,6 +200,9 @@ const ContinuousInput: FC = ({ disableQuantileInput={disabled} hasUserForecast={hasUserForecast} forecastInputMode={forecastInputMode} + userPreviousLabel={userPreviousLabel} + userPreviousRowClassName={userPreviousRowClassName} + hideCurrentUserRow={derivedHideCurrentUserRow} /> {forecastInputMode === ContinuousForecastInputType.Quantile && ( diff --git a/front_end/src/components/forecast_maker/continuous_table/index.tsx b/front_end/src/components/forecast_maker/continuous_table/index.tsx index 3ec7210d3..6c9dede0a 100644 --- a/front_end/src/components/forecast_maker/continuous_table/index.tsx +++ b/front_end/src/components/forecast_maker/continuous_table/index.tsx @@ -50,6 +50,9 @@ type Props = { quantileComponents?: DistributionQuantileComponent; onQuantileChange?: (quantileComponents: QuantileValue[]) => void; disableQuantileInput?: boolean; + userPreviousLabel?: string; + userPreviousRowClassName?: string; + hideCurrentUserRow?: boolean; }; const ContinuousTable: FC = ({ @@ -68,6 +71,9 @@ const ContinuousTable: FC = ({ quantileComponents, onQuantileChange, disableQuantileInput = false, + userPreviousLabel, + userPreviousRowClassName, + hideCurrentUserRow = false, }) => { const t = useTranslations(); // initial state is a safety measure to avoid errors when we already have slider forecast @@ -238,7 +244,7 @@ const ContinuousTable: FC = ({ forecastInputMode === ContinuousForecastInputType.Slider && ( {t("myPrediction")} - {isDirty || hasUserForecast ? ( + {!hideCurrentUserRow && (isDirty || hasUserForecast) ? ( <> {question.open_lower_bound && ( @@ -418,8 +424,13 @@ const ContinuousTable: FC = ({ )} {withUserQuartiles && userPreviousQuartiles && ( - - {t("myPredictionPrevious")} + + {userPreviousLabel ?? t("myPredictionPrevious")} <> {question.open_lower_bound && ( @@ -463,8 +474,13 @@ const ContinuousTable: FC = ({ )} {withUserQuartiles && userPreviousQuartiles && ( - - {t("myPredictionPrevious")} + + {userPreviousLabel ?? t("myPredictionPrevious")} )} @@ -503,7 +519,9 @@ const ContinuousTable: FC = ({ ) : ( - {(isDirty || hasUserForecast) && userBounds + {!hideCurrentUserRow && + (isDirty || hasUserForecast) && + userBounds ? `${(userBounds.belowLower * 100).toFixed(1)}%` : "—"} @@ -557,7 +575,7 @@ const ContinuousTable: FC = ({ ) : ( - {isDirty || hasUserForecast ? ( + {!hideCurrentUserRow && (isDirty || hasUserForecast) ? ( <>{getDisplayValue(userQuartiles?.lower25)} ) : ( "—" @@ -601,7 +619,7 @@ const ContinuousTable: FC = ({ ) : ( - {isDirty || hasUserForecast ? ( + {!hideCurrentUserRow && (isDirty || hasUserForecast) ? ( <>{getDisplayValue(userQuartiles?.median)} ) : ( "—" @@ -645,7 +663,7 @@ const ContinuousTable: FC = ({ ) : ( - {isDirty || hasUserForecast ? ( + {!hideCurrentUserRow && (isDirty || hasUserForecast) ? ( <>{getDisplayValue(userQuartiles?.upper75)} ) : ( "—" @@ -699,7 +717,7 @@ const ContinuousTable: FC = ({ ) : ( - {(isDirty || hasUserForecast) && userBounds + {!hideCurrentUserRow && userBounds ? `${(userBounds.aboveUpper * 100).toFixed(1)}%` : "—"} diff --git a/front_end/src/components/forecast_maker/forecast_expiration.tsx b/front_end/src/components/forecast_maker/forecast_expiration.tsx index a13894522..2ff898dfe 100644 --- a/front_end/src/components/forecast_maker/forecast_expiration.tsx +++ b/front_end/src/components/forecast_maker/forecast_expiration.tsx @@ -24,6 +24,7 @@ import { import BaseModal from "../base_modal"; import Button from "../ui/button"; +import { ContinuousGroupOption } from "./continuous_group_accordion/group_forecast_accordion"; interface ForecastExpirationModalProps { isOpen: boolean; @@ -80,8 +81,10 @@ const modalPresets: Preset[] = [ { id: "neverWithdraw" }, ] as const; +type ForecastLike = Pick; + export const getTimeToExpireDays = ( - lastForecast: UserForecast | undefined + lastForecast: ForecastLike | undefined ): number | undefined => { if (lastForecast?.end_time) { const lastForecastExpiryDate = new Date(lastForecast.end_time * 1000); @@ -101,6 +104,19 @@ export const getTimeToExpireDays = ( } }; +export function getEffectiveLatest(opt: ContinuousGroupOption) { + if (opt.wasWithdrawn) { + return { + end_time: opt.withdrawnEndTimeSec ?? Math.floor(Date.now() / 1000), + }; + } + if (opt.forecastExpiration) { + const d = forecastExpirationToDate(opt.forecastExpiration); + if (d) return { end_time: Math.floor(d.getTime() / 1000) }; + } + return opt.question.my_forecasts?.latest; +} + export const buildDefaultForecastExpiration = ( question: QuestionWithForecasts, userPredictionExpirationPercent: number | undefined diff --git a/front_end/src/components/forecast_maker/forecast_maker_group/continuous_input_wrapper.tsx b/front_end/src/components/forecast_maker/forecast_maker_group/continuous_input_wrapper.tsx index 4831ee96a..d86486778 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_group/continuous_input_wrapper.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_group/continuous_input_wrapper.tsx @@ -103,12 +103,26 @@ const ContinuousInputWrapper: FC> = ({ const t = useTranslations(); const locale = useLocale(); + const userCdf: number[] | undefined = getSliderNumericForecastDataset( + getNormalizedContinuousForecast(option.userSliderForecast), + option.question + ).cdf; const previousForecast = option.question.my_forecasts?.latest; const [overlayPreviousForecast, setOverlayPreviousForecast] = - useState( - !!previousForecast?.forecast_values && - !previousForecast.distribution_input - ); + useState(() => { + // ensure we even have previous values to show + const hasValues = !!previousForecast?.forecast_values; + + // determine if the previous forecast should be considered “legacy” + // (no distribution_input) or “expired” (end_time in the past) + const isLegacy = !previousForecast?.distribution_input; + const isExpired = + !!previousForecast?.end_time && + previousForecast.end_time * 1000 < Date.now(); + + // we overlay if there are values AND (legacy OR expired) + return hasValues && (isLegacy || isExpired); + }); const [submitError, setSubmitError] = useState(); const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false); const { forecastInputMode, isDirty, userQuantileForecast } = option; @@ -139,6 +153,7 @@ const ContinuousInputWrapper: FC> = ({ const dataset = useMemo(() => { setSubmitError(undefined); + if (forecastInputMode === ContinuousForecastInputType.Slider) { return getSliderNumericForecastDataset( forecast as DistributionSliderComponent[], @@ -175,6 +190,13 @@ const ContinuousInputWrapper: FC> = ({ [option] ); + const showWithdrawnRow = option.wasWithdrawn && !option.isDirty; + + const userPreviousCdf: number[] | undefined = + showWithdrawnRow && previousForecast + ? previousForecast.forecast_values + : undefined; + const onSubmit = useCallback( async (forecastExpiration: ForecastExpirationValue) => { setSubmitError(undefined); @@ -210,14 +232,6 @@ const ContinuousInputWrapper: FC> = ({ const withdraw = () => onWithdraw(); - const userCdf: number[] | undefined = getSliderNumericForecastDataset( - getNormalizedContinuousForecast(option.userSliderForecast), - option.question - ).cdf; - const userPreviousCdf: number[] | undefined = - overlayPreviousForecast && previousForecast - ? previousForecast.forecast_values - : undefined; const communityCdf: number[] | undefined = option.question.aggregations[option.question.default_aggregation_method] .latest?.forecast_values; @@ -403,6 +417,10 @@ const ContinuousInputWrapper: FC> = ({ } menu={option.menu} copyMenu={copyMenu} + userPreviousLabel={showWithdrawnRow ? "(Withdrawn)" : undefined} + userPreviousRowClassName={showWithdrawnRow ? "text-xs" : undefined} + hideCurrentUserRow={showWithdrawnRow} + outlineUser={showWithdrawnRow} />
diff --git a/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_continuous.tsx b/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_continuous.tsx index a55731db3..bf8c891a8 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_continuous.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_continuous.tsx @@ -4,7 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { isNil } from "lodash"; import { useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import React, { +import { FC, ReactNode, useCallback, @@ -45,7 +45,6 @@ import { clearQuantileComponents, getNormalizedContinuousForecast, getUserContinuousQuartiles, - isAllQuantileComponentsDirty, isOpenQuestionPredicted, } from "@/utils/forecasts/helpers"; import { @@ -58,9 +57,7 @@ import { getSliderDistributionFromQuantiles, } from "@/utils/forecasts/switch_forecast_type"; import { computeQuartilesFromCDF } from "@/utils/math"; -import { canWithdrawForecast } from "@/utils/questions/predictions"; -import ForecastMakerGroupControls from "./forecast_maker_group_menu"; import GroupForecastAccordion, { ContinuousGroupOption, } from "../continuous_group_accordion/group_forecast_accordion"; @@ -68,10 +65,12 @@ import { buildDefaultForecastExpiration, forecastExpirationToDate, ForecastExpirationValue, + getEffectiveLatest, getTimeToExpireDays, } from "../forecast_expiration"; import { validateUserQuantileData } from "../helpers"; import PredictButton from "../predict_button"; +import ForecastMakerGroupControls from "./forecast_maker_group_menu"; import WithdrawButton from "../withdraw/withdraw_button"; type Props = { @@ -116,20 +115,6 @@ const ForecastMakerGroupContinuous: FC = ({ [questions] ); - const soonToExpireForecastsCount = useMemo(() => { - return questions.filter((q) => { - const timeToExpireDays = getTimeToExpireDays(q.my_forecasts?.latest); - return timeToExpireDays && timeToExpireDays > 0 && timeToExpireDays < 2; - }).length; - }, [questions]); - - const expiredForecastsCount = useMemo(() => { - return questions.filter((q) => { - const timeToExpireDays = getTimeToExpireDays(q.my_forecasts?.latest); - return timeToExpireDays && timeToExpireDays < 0; - }).length; - }, [questions]); - const [groupOptions, setGroupOptions] = useState( generateGroupOptions({ questions, @@ -140,7 +125,24 @@ const ForecastMakerGroupContinuous: FC = ({ }) ); - // ensure options have the latest forecast data + const soonToExpireCount = useMemo(() => { + return groupOptions.filter((opt) => { + if (opt.question.status !== QuestionStatus.OPEN) return false; + const timeToExpireDays = getTimeToExpireDays(getEffectiveLatest(opt)); + return timeToExpireDays && timeToExpireDays > 0 && timeToExpireDays < 2; + }).length; + }, [groupOptions]); + + const expiredCount = useMemo(() => { + return groupOptions.filter((opt) => { + if (opt.question.status !== QuestionStatus.OPEN) return false; + const timeToExpireDays = getTimeToExpireDays(getEffectiveLatest(opt)); + return timeToExpireDays && timeToExpireDays < 0; + }).length; + }, [groupOptions]); + + // sync with server: rebuild fresh options and either (a) fully reset rows in + // resetTarget or (b) patch server fields while keeping local edits. useEffect(() => { const newGroupOptions = generateGroupOptions({ questions, @@ -151,17 +153,23 @@ const ForecastMakerGroupContinuous: FC = ({ }); setGroupOptions((prev) => prev.map((o) => { - const newOption = newGroupOptions.find((q) => q.id === o.question.id); - // we want to reset all options if we withdraw/reaffirm all group subquestions using button unter the table - // but when updating a single subquestion, we want to reset only that subquestion state + // after withdraw/reaffirm: reset all rows + // on single-row updates, reset only that row. + const fresh = newGroupOptions.find((q) => q.id === o.question.id); + if (!fresh) return o; + + const shouldReset = + resetTarget.current === "all" || resetTarget.current === o.id; + const base = shouldReset ? { ...fresh } : o; + return { - ...o, - ...(resetTarget.current === "all" || resetTarget.current === o.id - ? newOption - : o), - resolution: newOption?.resolution ?? o.resolution, - menu: newOption?.menu ?? o.menu, - question: newOption?.question ?? o.question, + ...base, + resolution: fresh.resolution ?? base.resolution, + menu: fresh.menu ?? base.menu, + question: fresh.question ?? base.question, + wasWithdrawn: o.wasWithdrawn ?? base.wasWithdrawn, + withdrawnEndTimeSec: + o.withdrawnEndTimeSec ?? base.withdrawnEndTimeSec, }; }) ); @@ -174,8 +182,7 @@ const ForecastMakerGroupContinuous: FC = ({ () => groupOptions.filter( (option) => - option.question.status === QuestionStatus.OPEN && - (option.isDirty || option.hasUserForecast) + option.question.status === QuestionStatus.OPEN && option.isDirty ), [groupOptions] ); @@ -222,7 +229,7 @@ const ForecastMakerGroupContinuous: FC = ({ isDirty: forecastInputMode === ContinuousForecastInputType.Slider ? true - : isAllQuantileComponentsDirty(components), + : components.some((c) => c?.isDirty), }; } @@ -508,6 +515,27 @@ const ForecastMakerGroupContinuous: FC = ({ ), })); }, [questions, user?.prediction_expiration_percent]); + const hasActiveUserForecastUI = (opt: ContinuousGroupOption) => { + const latest = opt.question.my_forecasts?.latest; + return ( + opt.question.status === QuestionStatus.OPEN && + !!latest?.distribution_input && + !opt.wasWithdrawn + ); + }; + + const withdrawAllIds = useMemo( + () => + Array.from( + new Set( + groupOptions + .filter(hasActiveUserForecastUI) + .map((o) => o.question.id) + .filter((id): id is number => Number.isFinite(id)) + ) + ), + [groupOptions] + ); const handlePredictWithdraw = useCallback( async (questionId?: number) => { @@ -515,24 +543,47 @@ const ForecastMakerGroupContinuous: FC = ({ setSubmitError(undefined); setIsSubmitting(true); - const response = await withdrawForecasts( - postId, - isNil(questionId) - ? predictedQuestions.map((q) => ({ - question: q.id, - })) - : [{ question: questionId }] + const rawIds = isNil(questionId) ? withdrawAllIds : [questionId]; + const ids = Array.from( + new Set(rawIds.filter((id) => Number.isFinite(id))) ); + + if (ids.length === 0) { + setIsSubmitting(false); + setIsWithdrawModalOpen(false); + return; + } + + const payload: Array<{ question: number }> = ids.map((id) => ({ + question: id, + })); + + const response = await withdrawForecasts(postId, payload); setIsSubmitting(false); if (response && "errors" in response && !!response.errors) { setSubmitError(response.errors); + } else { + const withdrawnAtSec = Math.floor(Date.now() / 1000); + setGroupOptions((prev) => + prev.map((opt) => + ids.includes(opt.question.id) + ? { + ...opt, + wasWithdrawn: true, + withdrawnEndTimeSec: withdrawnAtSec, + forecastExpiration: undefined, + } + : opt + ) + ); } + setIsWithdrawModalOpen(false); onPredictionSubmit?.(); return response; }, - [postId, predictedQuestions, onPredictionSubmit] + [postId, withdrawAllIds, onPredictionSubmit] ); const handlePredictionReaffirm = useCallback(async () => { @@ -589,7 +640,7 @@ const ForecastMakerGroupContinuous: FC = ({ />
- {questions.some((q) => canWithdrawForecast(q, permission)) && ( + {groupOptions.some(hasActiveUserForecastUI) && ( = ({ )}
- {(soonToExpireForecastsCount > 0 || expiredForecastsCount > 0) && ( + {(soonToExpireCount > 0 || expiredCount > 0) && (
- {soonToExpireForecastsCount > 0 && ( + {soonToExpireCount > 0 && (
{t("predictionsSoonToBeWidthdrawnText", { - count: soonToExpireForecastsCount, + count: soonToExpireCount, })}
)} - {expiredForecastsCount > 0 && ( -
- {t("predictionsWithdrawnText", { count: expiredForecastsCount })} -
+ {expiredCount > 0 && ( +
{t("predictionsWithdrawnText", { count: expiredCount })}
)}
)} @@ -682,6 +731,8 @@ function generateGroupOptions({ q ); const latest = q.aggregations[q.default_aggregation_method].latest; + const last = q.my_forecasts?.latest; + const wasWithdrawn = !!last?.end_time && last.end_time * 1000 < Date.now(); return { id: q.id, name: q.label, @@ -701,6 +752,8 @@ function generateGroupOptions({ resolution: q.resolution, isDirty: false, hasUserForecast: !isNil(prevForecast), + wasWithdrawn, + withdrawnEndTimeSec: last?.end_time ?? null, menu: ( = ({ - From 37d3825f6d0acda0bc75a939099ec6d1a6977a33 Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 22 Sep 2025 11:43:45 +0300 Subject: [PATCH 4/5] fix: binary forecasts --- .../forecast_maker_question/forecast_maker_binary.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_binary.tsx b/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_binary.tsx index e6f723c89..e322350e8 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_binary.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_question/forecast_maker_binary.tsx @@ -139,7 +139,7 @@ const ForecastMakerBinary: FC = ({ const handlePredictWithdraw = async () => { setSubmitError(undefined); - if (!prevForecastValue) return; + if (!activeUserForecast) return; const response = await withdrawForecasts(post.id, [ { @@ -191,7 +191,7 @@ const ForecastMakerBinary: FC = ({
{canPredict && ( <> - {!!prevForecastValue && ( + {!!activeUserForecast && ( Date: Mon, 22 Sep 2025 11:43:50 +0300 Subject: [PATCH 5/5] fix: overlay problem --- .../continuous_prediction_chart.tsx | 34 +++++++------------ .../forecast_maker/continuous_input/index.tsx | 3 ++ .../continuous_input_wrapper.tsx | 19 +++++++---- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/front_end/src/components/forecast_maker/continuous_input/continuous_prediction_chart.tsx b/front_end/src/components/forecast_maker/continuous_input/continuous_prediction_chart.tsx index d0563199b..08bdb7d5a 100644 --- a/front_end/src/components/forecast_maker/continuous_input/continuous_prediction_chart.tsx +++ b/front_end/src/components/forecast_maker/continuous_input/continuous_prediction_chart.tsx @@ -1,5 +1,5 @@ import { useTranslations } from "next-intl"; -import { FC, useCallback, useMemo, useRef, useState } from "react"; +import { FC, useCallback, useMemo, useState } from "react"; import { VictoryThemeDefinition } from "victory"; import ContinuousAreaChart, { @@ -27,6 +27,7 @@ import { formatValueUnit } from "@/utils/questions/units"; type Props = { question: QuestionWithNumericForecasts; overlayPreviousForecast?: boolean; + previousCdf?: number[]; dataset: { cdf: number[]; pmf: number[]; @@ -61,6 +62,7 @@ const arraysAlmostEqual = ( const ContinuousPredictionChart: FC = ({ question, overlayPreviousForecast, + previousCdf, dataset, graphType, readOnly = false, @@ -75,7 +77,6 @@ const ContinuousPredictionChart: FC = ({ const [hoverState, setHoverState] = useState( null ); - const overlayLockedRef = useRef(outlineUser); const discreteValueOptions = getDiscreteValueOptions(question); const cursorDisplayData = useMemo(() => { @@ -126,11 +127,6 @@ const ContinuousPredictionChart: FC = ({ [question.aggregations, defaultAggMethod] ); - const myLatest = useMemo( - () => question.my_forecasts?.latest ?? null, - [question.my_forecasts] - ); - const data: ContinuousAreaGraphInput = useMemo(() => { const charts: ContinuousAreaGraphInput = []; @@ -145,25 +141,20 @@ const ContinuousPredictionChart: FC = ({ }); } - const sameAsPrev = - myLatest && arraysAlmostEqual(dataset.cdf, myLatest.forecast_values); - + const sameAsPrev = previousCdf + ? arraysAlmostEqual(dataset.cdf, previousCdf) + : false; const shouldShowPrev = - !!overlayPreviousForecast && - !!myLatest && - !overlayLockedRef.current && - !outlineUser && - !sameAsPrev; - - if (shouldShowPrev) { + !!overlayPreviousForecast && !!previousCdf?.length && !sameAsPrev; + if (shouldShowPrev && previousCdf) { charts.push({ - pmf: cdfToPmf(myLatest.forecast_values), - cdf: myLatest.forecast_values, + pmf: cdfToPmf(previousCdf), + cdf: previousCdf, type: "user_previous", }); } - if (!readOnly || !!myLatest) { + if (!readOnly || !!previousCdf) { charts.push({ pmf: dataset.pmf, cdf: dataset.cdf, @@ -178,10 +169,9 @@ const ContinuousPredictionChart: FC = ({ latestAggLatest, question.status, overlayPreviousForecast, - myLatest, + previousCdf, readOnly, dataset, - outlineUser, ]); const xLabel = cursorDisplayData?.xLabel ?? ""; diff --git a/front_end/src/components/forecast_maker/continuous_input/index.tsx b/front_end/src/components/forecast_maker/continuous_input/index.tsx index bd0760e96..ed731f7c6 100644 --- a/front_end/src/components/forecast_maker/continuous_input/index.tsx +++ b/front_end/src/components/forecast_maker/continuous_input/index.tsx @@ -37,6 +37,7 @@ type Props = { }; userCdf: number[] | undefined; userPreviousCdf: number[] | undefined; + overlayPreviousCdf?: number[] | undefined; communityCdf: number[] | undefined; sliderComponents: DistributionSliderComponent[]; onSliderChange: (components: DistributionSliderComponent[]) => void; @@ -85,6 +86,7 @@ const ContinuousInput: FC = ({ userPreviousLabel, userPreviousRowClassName, hideCurrentUserRow, + overlayPreviousCdf, outlineUser = false, }) => { const { user } = useAuth(); @@ -154,6 +156,7 @@ const ContinuousInput: FC = ({ : tableGraphType } overlayPreviousForecast={overlayPreviousForecast} + previousCdf={overlayPreviousCdf} question={question} readOnly={disabled} showCP={!user || !hideCP || !!question.resolution} diff --git a/front_end/src/components/forecast_maker/forecast_maker_group/continuous_input_wrapper.tsx b/front_end/src/components/forecast_maker/forecast_maker_group/continuous_input_wrapper.tsx index d86486778..55bbeb051 100644 --- a/front_end/src/components/forecast_maker/forecast_maker_group/continuous_input_wrapper.tsx +++ b/front_end/src/components/forecast_maker/forecast_maker_group/continuous_input_wrapper.tsx @@ -110,8 +110,12 @@ const ContinuousInputWrapper: FC> = ({ const previousForecast = option.question.my_forecasts?.latest; const [overlayPreviousForecast, setOverlayPreviousForecast] = useState(() => { + // Withdrawn case: + // If the user's last prediction was explicitly withdrawn, the overlay toggle + // should start OFF. The "(Withdrawn)" table row is shown independently of this + if (option.wasWithdrawn) return false; // ensure we even have previous values to show - const hasValues = !!previousForecast?.forecast_values; + const hasValues = !!previousForecast?.forecast_values?.length; // determine if the previous forecast should be considered “legacy” // (no distribution_input) or “expired” (end_time in the past) @@ -190,12 +194,14 @@ const ContinuousInputWrapper: FC> = ({ [option] ); + const rawPreviousCdf = previousForecast?.forecast_values; const showWithdrawnRow = option.wasWithdrawn && !option.isDirty; - - const userPreviousCdf: number[] | undefined = - showWithdrawnRow && previousForecast - ? previousForecast.forecast_values - : undefined; + const showPreviousRowByCheckbox = + !showWithdrawnRow && overlayPreviousForecast; + const userPreviousCdf = + showWithdrawnRow || showPreviousRowByCheckbox ? rawPreviousCdf : undefined; + const overlayPreviousCdf = + overlayPreviousForecast && rawPreviousCdf ? rawPreviousCdf : undefined; const onSubmit = useCallback( async (forecastExpiration: ForecastExpirationValue) => { @@ -387,6 +393,7 @@ const ContinuousInputWrapper: FC> = ({ dataset={dataset} userCdf={userCdf} userPreviousCdf={userPreviousCdf} + overlayPreviousCdf={overlayPreviousCdf} communityCdf={communityCdf} sliderComponents={option.userSliderForecast} onSliderChange={(components) =>