({
{forecastColumnValue}
- setIsInputFocused(true)}
- onBlur={() => setIsInputFocused(false)}
- disabled={disabled}
- />
+ {withdrawn && !isDirty && !isInputFocused ? (
+
+
+
+ {
+ setIsInputFocused(true);
+ onChange(id, defaultSliderValue);
+ }}
+ onBlur={() => setIsInputFocused(false)}
+ disabled={disabled}
+ />
+
+
+
+
+
+
+
+
+
+ ) : (
+ setIsInputFocused(true)}
+ onBlur={() => setIsInputFocused(false)}
+ disabled={disabled}
+ />
+ )}
|
diff --git a/front_end/src/components/forecast_maker/forecast_expiration.tsx b/front_end/src/components/forecast_maker/forecast_expiration.tsx
index a138945222..2ff898dfee 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 4831ee96a9..55bbeb0511 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,30 @@ 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(() => {
+ // 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?.length;
+
+ // 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 +157,7 @@ const ContinuousInputWrapper: FC> = ({
const dataset = useMemo(() => {
setSubmitError(undefined);
+
if (forecastInputMode === ContinuousForecastInputType.Slider) {
return getSliderNumericForecastDataset(
forecast as DistributionSliderComponent[],
@@ -175,6 +194,15 @@ const ContinuousInputWrapper: FC> = ({
[option]
);
+ const rawPreviousCdf = previousForecast?.forecast_values;
+ const showWithdrawnRow = option.wasWithdrawn && !option.isDirty;
+ const showPreviousRowByCheckbox =
+ !showWithdrawnRow && overlayPreviousForecast;
+ const userPreviousCdf =
+ showWithdrawnRow || showPreviousRowByCheckbox ? rawPreviousCdf : undefined;
+ const overlayPreviousCdf =
+ overlayPreviousForecast && rawPreviousCdf ? rawPreviousCdf : undefined;
+
const onSubmit = useCallback(
async (forecastExpiration: ForecastExpirationValue) => {
setSubmitError(undefined);
@@ -210,14 +238,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;
@@ -373,6 +393,7 @@ const ContinuousInputWrapper: FC> = ({
dataset={dataset}
userCdf={userCdf}
userPreviousCdf={userPreviousCdf}
+ overlayPreviousCdf={overlayPreviousCdf}
communityCdf={communityCdf}
sliderComponents={option.userSliderForecast}
onSliderChange={(components) =>
@@ -403,6 +424,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_binary.tsx b/front_end/src/components/forecast_maker/forecast_maker_group/forecast_maker_group_binary.tsx
index 459e7bab33..3b78b90781 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,9 @@ type QuestionOption = {
menu: ReactNode;
status?: QuestionStatus;
forecastExpiration?: ForecastExpirationValue;
+ defaultSliderValue: number;
+ wasWithdrawn: boolean;
+ withdrawnEndTimeSec?: number | null;
};
type Props = {
@@ -216,26 +219,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 +369,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 +379,8 @@ const ForecastMakerGroupBinary: FC = ({
inputMax={BINARY_MAX_VALUE}
onChange={handleForecastChange}
isDirty={questionOption.isDirty}
+ withdrawn={questionOption.wasWithdrawn}
+ withdrawnEndTimeSec={questionOption.withdrawnEndTimeSec}
isRowDirty={questionOption.isDirty}
menu={questionOption.menu}
disabled={
@@ -505,14 +517,21 @@ 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,
+ withdrawnEndTimeSec: last?.end_time ?? null,
color: MULTIPLE_CHOICE_COLOR_SCALE[index] ?? METAC_COLORS.gray["400"],
status: question.status,
forecastExpiration,
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 a55731db33..bf8c891a86 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: (
= ({
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 && (
= ({
-
|