From ac0afeb5483fe203f8eb1a645172af264c3bd87a Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 14 May 2026 14:19:27 +0300 Subject: [PATCH 1/9] feat: add ConsumerListChartShell for N-row consumer side-by-side layout --- .../components/question_page_shell/index.tsx | 122 +++++++++--------- .../consumer_list_chart_shell.tsx | 30 +++++ 2 files changed, 91 insertions(+), 61 deletions(-) create mode 100644 front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx index 7df8d1f510..c1bdde51c7 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx @@ -15,13 +15,13 @@ import { useHideCP } from "@/contexts/cp_context"; import { useContentTranslatedBannerContext } from "@/contexts/translations_banner_context"; import { GroupOfQuestionsGraphType, + GroupOfQuestionsPost, PostStatus, PostWithForecasts, QuestionStatus, } from "@/types/post"; import { TournamentType } from "@/types/projects"; -import { QuestionType } from "@/types/question"; -import cn from "@/utils/core/cn"; +import { QuestionType, QuestionWithNumericForecasts } from "@/types/question"; import { getQuestionForecastAvailability } from "@/utils/questions/forecastAvailability"; import { checkGroupOfQuestionsPostType, @@ -40,6 +40,7 @@ import PostScoreData from "../post_score_data"; import { QuestionLayoutProvider } from "../question_layout/question_layout_context"; import { QuestionVariantComposer } from "../question_variant_composer"; import ActionRow from "../question_view/action_row"; +import ConsumerListChartShell from "../question_view/consumer_question_view/consumer_list_chart_shell"; import ConsumerQuestionPrediction from "../question_view/consumer_question_view/prediction"; import QuestionTimeline from "../question_view/consumer_question_view/timeline"; import QuestionHeaderCPStatus from "../question_view/forecaster_question_view/question_header/question_header_cp_status"; @@ -147,9 +148,6 @@ export const ConsumerShell: FC<{ const isMultipleChoice = isMultipleChoicePost(postData); const isNonFanGroup = isGroupOfQuestionsPost(postData) && !isFanGraph; - const reverseOrder = - (isMultipleChoice || isGroupOfQuestionsPost(postData)) && !isDateGroup; - const isContinuousSingleQuestion = isQuestionPost(postData) && isContinuousQuestion(postData.question); @@ -158,17 +156,14 @@ export const ConsumerShell: FC<{ !isContinuousSingleQuestion && !isMultipleChoice; + const isNRowBody = + isMultipleChoice || (isNonFanGroup && !isDateGroup) || isFanGraph; + const binaryForecastAvailability = isBinarySingleQuestion && isQuestionPost(postData) ? getQuestionForecastAvailability(postData.question) : null; - const showSideBySide = - isMultipleChoice || - isNonFanGroup || - isBinarySingleQuestion || - isContinuousSingleQuestion; - const showClosedMessageMultipleChoice = isMultipleChoicePost(postData) && postData.question.status === QuestionStatus.CLOSED; @@ -218,21 +213,8 @@ export const ConsumerShell: FC<{ {t("predictionClosedMessage")}

)} -
- {isBinarySingleQuestion && isQuestionPost(postData) ? ( + {isBinarySingleQuestion && isQuestionPost(postData) ? ( +
{hideCP ? ( @@ -247,47 +229,65 @@ export const ConsumerShell: FC<{ /> )}
- ) : ( -
- {hideCP && !isContinuousSingleQuestion ? ( - - ) : ( - - )} + +
+ ) : isContinuousSingleQuestion ? ( +
+
+
- )} - {!isFanGraph && !isDateGroup && ( - )} - {showClosedMessageFanGraph && ( -

- {t("predictionClosedMessage")} -

- )} -
+
+ ) : isNRowBody ? ( + <> + + ) : ( + + ) + } + chartContent={ + isFanGraph ? ( + + } + preselectedQuestionId={preselectedGroupQuestionId} + /> + ) : ( + + ) + } + /> + {showClosedMessageFanGraph && ( +

+ {t("predictionClosedMessage")} +

+ )} + + ) : ( + + )}
{shouldShowKeyFactorsSection && (
diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx new file mode 100644 index 0000000000..299c1feb58 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx @@ -0,0 +1,30 @@ +import { ReactNode } from "react"; + +import cn from "@/utils/core/cn"; + +type Props = { + listContent: ReactNode; + chartContent: ReactNode; + className?: string; +}; + +const ConsumerListChartShell: React.FC = ({ + listContent, + chartContent, + className, +}) => ( +
+
{listContent}
+
+ {chartContent} +
+
+); + +export default ConsumerListChartShell; From 139413589315aac0f21035e787ab20ba969bc244 Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 14 May 2026 14:19:31 +0300 Subject: [PATCH 2/9] fix: remove extra top/bottom margin on group prediction inside the side-by-side shell and fix styles in ConsumerListChartShell --- .../[id]/components/question_page_shell/index.tsx | 5 ++++- .../consumer_list_chart_shell.tsx | 2 +- .../prediction/group_of_questions_prediction/index.tsx | 9 +++++++-- .../consumer_question_view/prediction/index.tsx | 10 ++++++++-- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx index c1bdde51c7..330254882d 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx @@ -257,7 +257,10 @@ export const ConsumerShell: FC<{ hideCP ? ( ) : ( - + ) } chartContent={ diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx index 299c1feb58..bdfbf03b1f 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx @@ -20,7 +20,7 @@ const ConsumerListChartShell: React.FC = ({ className )} > -
{listContent}
+
{listContent}
{chartContent}
diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/group_of_questions_prediction/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/group_of_questions_prediction/index.tsx index eca1efc3cd..c2d89805cc 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/group_of_questions_prediction/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/group_of_questions_prediction/index.tsx @@ -4,6 +4,7 @@ import PercentageForecastCard from "@/components/consumer_post_card/group_foreca import TimeSeriesChart from "@/components/consumer_post_card/time_series_chart"; import { GroupOfQuestionsGraphType, PostWithForecasts } from "@/types/post"; import { QuestionType } from "@/types/question"; +import cn from "@/utils/core/cn"; import { getGroupForecastAvailability } from "@/utils/questions/forecastAvailability"; import { sortGroupPredictionOptions } from "@/utils/questions/groupOrdering"; import { @@ -13,9 +14,13 @@ import { type Props = { postData: PostWithForecasts; + className?: string; }; -const GroupOfQuestionsPrediction: React.FC = ({ postData }) => { +const GroupOfQuestionsPrediction: React.FC = ({ + postData, + className, +}) => { let content: React.ReactNode | null = null; if ( @@ -72,7 +77,7 @@ const GroupOfQuestionsPrediction: React.FC = ({ postData }) => { ? "md:mb-7" : "md:mt-7"; - return
{content}
; + return
{content}
; }; export default GroupOfQuestionsPrediction; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/index.tsx index a7c3d670c3..4b9f6cf4d5 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/prediction/index.tsx @@ -14,9 +14,13 @@ import SingleQuestionPrediction from "./single_question_prediction"; type Props = { postData: PostWithForecasts; + className?: string; }; -const ConsumerQuestionPrediction: React.FC = ({ postData }) => { +const ConsumerQuestionPrediction: React.FC = ({ + postData, + className, +}) => { const { user } = useAuth(); if (isQuestionPost(postData) && !isMultipleChoicePost(postData)) { @@ -29,7 +33,9 @@ const ConsumerQuestionPrediction: React.FC = ({ postData }) => { } if (isMultipleChoicePost(postData) || isGroupOfQuestionsPost(postData)) { - return ; + return ( + + ); } return null; From 272edc3ef0a93da3ccac03c7162d26767f8b9368 Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 14 May 2026 14:19:36 +0300 Subject: [PATCH 3/9] feat: restyle consumer MC and group forecast rows with chevron expand/collapse and expanded panel --- .../components/question_page_shell/index.tsx | 4 +- .../consumer_list_chart_shell.tsx | 54 ++++-- .../forecast_card_wrapper.tsx | 73 +++----- .../forecast_choice_bar.tsx | 2 - .../numeric_forecast_card.tsx | 168 ++++++++++-------- .../percentage_forecast_card.tsx | 88 ++++++--- 6 files changed, 220 insertions(+), 169 deletions(-) diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx index 330254882d..47fb957000 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx @@ -47,9 +47,9 @@ import QuestionHeaderCPStatus from "../question_view/forecaster_question_view/qu import RevealCPButton from "../reveal_cp_button"; const baseSectionClassName = - "relative z-10 flex w-[59rem] max-w-full flex-col gap-6 overflow-x-clip rounded border border-blue-400 p-4 text-gray-900 dark:border-blue-200-dark dark:text-gray-900-dark lg:p-8"; + "relative flex w-[59rem] max-w-full flex-col gap-6 overflow-x-clip rounded border border-blue-400 p-4 text-gray-900 dark:border-blue-200-dark dark:text-gray-900-dark lg:p-8"; -const mainSectionClassName = `${baseSectionClassName} bg-gray-0 dark:bg-gray-0-dark`; +const mainSectionClassName = `${baseSectionClassName} z-10 bg-gray-0 dark:bg-gray-0-dark`; const commentSectionClassName = `${baseSectionClassName} bg-blue-100 dark:bg-gray-0-dark`; type ShellProps = { diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx index bdfbf03b1f..4b372560e4 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx @@ -1,7 +1,19 @@ -import { ReactNode } from "react"; +"use client"; + +import { createContext, ReactNode, useContext, useState } from "react"; import cn from "@/utils/core/cn"; +type ListChartExpandedContextType = { + setIsExpanded: (value: boolean) => void; +}; + +const ListChartExpandedContext = createContext({ + setIsExpanded: () => {}, +}); + +export const useListChartExpanded = () => useContext(ListChartExpandedContext); + type Props = { listContent: ReactNode; chartContent: ReactNode; @@ -12,19 +24,31 @@ const ConsumerListChartShell: React.FC = ({ listContent, chartContent, className, -}) => ( -
-
{listContent}
-
- {chartContent} -
-
-); +}) => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( + +
+
{listContent}
+ +
+
+ ); +}; export default ConsumerListChartShell; diff --git a/front_end/src/components/consumer_post_card/group_forecast_card/forecast_card_wrapper.tsx b/front_end/src/components/consumer_post_card/group_forecast_card/forecast_card_wrapper.tsx index 2154e2ca44..da48fa8de3 100644 --- a/front_end/src/components/consumer_post_card/group_forecast_card/forecast_card_wrapper.tsx +++ b/front_end/src/components/consumer_post_card/group_forecast_card/forecast_card_wrapper.tsx @@ -1,3 +1,5 @@ +import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useTranslations } from "next-intl"; import { FC, PropsWithChildren } from "react"; @@ -8,21 +10,28 @@ type Props = { othersTotal?: number; expanded?: boolean; onExpand?: () => void; + onCollapse?: () => void; hideOthersValue?: boolean; compact?: boolean; }; const ForecastCardWrapper: FC> = ({ otherItemsCount, - othersTotal = 0, expanded = false, onExpand, - hideOthersValue = false, + onCollapse, compact = false, children, }) => { const t = useTranslations(); - const showRow = !expanded && otherItemsCount > 0; + const showExpandRow = !expanded && otherItemsCount > 0; + + const toggleButtonClassName = cn( + "flex w-full self-stretch items-center gap-2 rounded-lg px-[13px]", + "bg-blue-500/20 dark:bg-blue-500-dark/20", + "text-sm font-medium leading-4 text-blue-700 dark:text-blue-700-dark", + compact ? "h-6 md:h-8" : "h-8" + ); return (
> = ({ > {children} - {showRow && ( + {showExpandRow && ( + )} - {!hideOthersValue && ( - - {Math.round(othersTotal)}% - - )} + {expanded && onCollapse && ( + )}
diff --git a/front_end/src/components/consumer_post_card/group_forecast_card/forecast_choice_bar.tsx b/front_end/src/components/consumer_post_card/group_forecast_card/forecast_choice_bar.tsx index 10dab1bcb0..5b3f002ba4 100644 --- a/front_end/src/components/consumer_post_card/group_forecast_card/forecast_choice_bar.tsx +++ b/front_end/src/components/consumer_post_card/group_forecast_card/forecast_choice_bar.tsx @@ -37,7 +37,6 @@ const ForecastChoiceBar: FC = ({ displayedResolution, resolution, color, - isBordered = false, unit, forceColorful = false, compact = false, @@ -56,7 +55,6 @@ const ForecastChoiceBar: FC = ({ ? "h-6 px-2 py-0.5 text-xs leading-4 md:h-8 md:px-2.5 md:py-1 md:text-base md:leading-6" : "h-8 px-2.5 py-1 text-base leading-6", { - "border-transparent": !isBordered, "text-purple-800 dark:text-purple-800-dark": isResolutionSuccessful, "border-2 border-gray-400 text-gray-700 dark:border-gray-400-dark dark:text-gray-700-dark": !isNil(resolution) && !isResolutionSuccessful, diff --git a/front_end/src/components/consumer_post_card/group_forecast_card/numeric_forecast_card.tsx b/front_end/src/components/consumer_post_card/group_forecast_card/numeric_forecast_card.tsx index 578bafd4c0..218cdd11a5 100644 --- a/front_end/src/components/consumer_post_card/group_forecast_card/numeric_forecast_card.tsx +++ b/front_end/src/components/consumer_post_card/group_forecast_card/numeric_forecast_card.tsx @@ -4,6 +4,7 @@ import { isNil } from "lodash"; import { useLocale, useTranslations } from "next-intl"; import { FC, useState } from "react"; +import { useListChartExpanded } from "@/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell"; import { getEffectiveVisibleCount } from "@/constants/questions"; import { PostStatus, PostWithForecasts } from "@/types/post"; import { QuestionType, Scaling } from "@/types/question"; @@ -28,6 +29,7 @@ const NumericForecastCard: FC = ({ post, forceColorful, compact }) => { const locale = useLocale(); const t = useTranslations(); const [expanded, setExpanded] = useState(false); + const { setIsExpanded } = useListChartExpanded(); if (!isGroupOfQuestionsPost(post)) { return null; @@ -58,10 +60,8 @@ const NumericForecastCard: FC = ({ post, forceColorful, compact }) => { }); const isPostClosed = post.status === PostStatus.CLOSED; - - const visibleChoices = expanded - ? sortedChoices - : sortedChoices.slice(0, visibleChoicesCount); + const hiddenCount = Math.max(0, sortedChoices.length - visibleChoicesCount); + const collapsedChoices = sortedChoices.slice(0, visibleChoicesCount); const scaledValues = [...sortedChoices] .filter((choice) => isNil(choice.resolution)) @@ -78,79 +78,97 @@ const NumericForecastCard: FC = ({ post, forceColorful, compact }) => { const maxScaledValue = Math.max(...scaledValues); const minScaledValue = Math.min(...scaledValues); - return ( - + choices.map( + ({ + closeTime, + aggregationValues, + scaling, + resolution, + id, + color, + displayedResolution, + choice, + actual_resolve_time, + unit, + }) => { + const isChoiceClosed = closeTime ? closeTime < Date.now() : false; + const rawChoiceValue = + aggregationValues[aggregationValues.length - 1] ?? null; + const normalizedScaling: Scaling = { + range_min: scaling?.range_min ?? 0, + range_max: scaling?.range_max ?? 1, + zero_point: scaling?.zero_point ?? null, + }; + const formattedChoiceValue = getPredictionDisplayValue(rawChoiceValue, { + questionType: isDateGroup ? QuestionType.Date : QuestionType.Numeric, + scaling: normalizedScaling, + actual_resolve_time: actual_resolve_time ?? null, + emptyLabel: t("Upcoming"), + }); + const scaledChoiceValue = !isNil(rawChoiceValue) + ? scaleInternalLocation(rawChoiceValue, normalizedScaling) + : NaN; + const relativeWidth = !isNil(resolution) + ? 100 + : calculateRelativeWidth({ + scaledChoiceValue, + maxScaledValue, + minScaledValue, + }); + + return ( + + ); } - expanded={expanded} - onExpand={() => setExpanded(true)} - hideOthersValue - compact={compact} - > - {visibleChoices.map( - ({ - closeTime, - aggregationValues, - scaling, - resolution, - id, - color, - displayedResolution, - choice, - actual_resolve_time, - unit, - }) => { - const isChoiceClosed = closeTime ? closeTime < Date.now() : false; - const rawChoiceValue = - aggregationValues[aggregationValues.length - 1] ?? null; - const normalizedScaling: Scaling = { - range_min: scaling?.range_min ?? 0, - range_max: scaling?.range_max ?? 1, - zero_point: scaling?.zero_point ?? null, - }; - const formattedChoiceValue = getPredictionDisplayValue( - rawChoiceValue, - { - questionType: isDateGroup - ? QuestionType.Date - : QuestionType.Numeric, - scaling: normalizedScaling, - actual_resolve_time: actual_resolve_time ?? null, - emptyLabel: t("Upcoming"), - } - ); - - const scaledChoiceValue = !isNil(rawChoiceValue) - ? scaleInternalLocation(rawChoiceValue, normalizedScaling) - : NaN; - - const relativeWidth = !isNil(resolution) - ? 100 - : calculateRelativeWidth({ - scaledChoiceValue, - maxScaledValue, - minScaledValue, - }); - - return ( - - ); - } + ); + + return ( +
+
+ { + setExpanded(true); + setIsExpanded(true); + }} + hideOthersValue + compact={compact} + > + {renderBars(collapsedChoices)} + +
+ + {expanded && ( +
+ { + setExpanded(false); + setIsExpanded(false); + }} + hideOthersValue + compact={compact} + > + {renderBars(sortedChoices)} + +
)} - +
); }; diff --git a/front_end/src/components/consumer_post_card/group_forecast_card/percentage_forecast_card.tsx b/front_end/src/components/consumer_post_card/group_forecast_card/percentage_forecast_card.tsx index 64891034d6..813c515d25 100644 --- a/front_end/src/components/consumer_post_card/group_forecast_card/percentage_forecast_card.tsx +++ b/front_end/src/components/consumer_post_card/group_forecast_card/percentage_forecast_card.tsx @@ -3,6 +3,7 @@ import { useLocale, useTranslations } from "next-intl"; import { FC, useMemo, useState } from "react"; +import { useListChartExpanded } from "@/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell"; import { getEffectiveVisibleCount } from "@/constants/questions"; import { PostStatus, PostWithForecasts } from "@/types/post"; import { QuestionType } from "@/types/question"; @@ -33,6 +34,7 @@ const PercentageForecastCard: FC = ({ const locale = useLocale(); const t = useTranslations(); const [expanded, setExpanded] = useState(false); + const { setIsExpanded } = useListChartExpanded(); const isMC = isMultipleChoicePost(post); const isGroupBinary = @@ -87,43 +89,70 @@ const PercentageForecastCard: FC = ({ const isPostClosed = post.status === PostStatus.CLOSED; - const visible = expanded - ? allChoices - : allChoices.slice(0, visibleChoicesCount); - const hidden = expanded ? [] : allChoices.slice(visibleChoicesCount); + const collapsedChoices = allChoices.slice(0, visibleChoicesCount); + const hiddenCount = allChoices.length - visibleChoicesCount; - const visibleSumMC = visible.reduce((s, c) => s + c.percent, 0); + const collapsedSumMC = collapsedChoices.reduce((s, c) => s + c.percent, 0); const othersTotal = isMC - ? Math.max(0, Math.min(100, 100 - Math.round(visibleSumMC))) + ? Math.max(0, Math.min(100, 100 - Math.round(collapsedSumMC))) : 0; + const renderBars = (choices: typeof allChoices) => + choices.map((choice) => ( + + )); + return ( - setExpanded(true)} - hideOthersValue={isGroupBinary} - compact={compact} - > - {visible.map((choice) => ( - + {/* Always in normal flow — keeps the left panel and shell at collapsed height */} +
+ { + setExpanded(true); + setIsExpanded(true); + }} + hideOthersValue={isGroupBinary} compact={compact} - /> - ))} - + > + {renderBars(collapsedChoices)} + +
+ + {/* Expanded panel — absolutely positioned so it overflows below the shell */} + {expanded && ( +
+ { + setExpanded(false); + setIsExpanded(false); + }} + hideOthersValue={isGroupBinary} + compact={compact} + > + {renderBars(allChoices)} + +
+ )} +
); }; + function generateChoiceItems( post: PostWithForecasts, visibleChoicesCount: number, @@ -150,4 +179,5 @@ function generateChoiceItems( } return []; } + export default PercentageForecastCard; From 0a175c3625c23d725c6c46687b82075667bd1515 Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 14 May 2026 14:19:41 +0300 Subject: [PATCH 4/9] feat: restyle sidebar expand button with ellipsis/border variant and hide legend in consumer MC and binary-group chart views --- .../consumer_question_view/timeline/index.tsx | 1 + .../similar_question_prediction_chip.tsx | 9 +++-- .../forecast_card_wrapper.tsx | 34 +++++++++++++++---- .../group_forecast_card/index.tsx | 27 ++++++++++++--- .../numeric_forecast_card.tsx | 10 +++++- .../percentage_forecast_card.tsx | 4 +++ .../detailed_group_card/index.tsx | 3 ++ .../detailed_question_card/index.tsx | 1 + .../multiple_choice_chart_card.tsx | 3 ++ 9 files changed, 78 insertions(+), 14 deletions(-) diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/timeline/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/timeline/index.tsx index af7e4f7fd1..bd28107aad 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/timeline/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/timeline/index.tsx @@ -87,6 +87,7 @@ const QuestionTimeline: React.FC = ({ )} diff --git a/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_question_prediction_chip.tsx b/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_question_prediction_chip.tsx index f5a730f78d..8a990a77a9 100644 --- a/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_question_prediction_chip.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/sidebar/similar_questions/similar_question_prediction_chip.tsx @@ -36,14 +36,19 @@ const SimilarPredictionChip: FC = ({ post, variant }) => { ) { return (
- +
); } if (isGroupOfQuestionsPost(post)) { return (
- +
); } diff --git a/front_end/src/components/consumer_post_card/group_forecast_card/forecast_card_wrapper.tsx b/front_end/src/components/consumer_post_card/group_forecast_card/forecast_card_wrapper.tsx index da48fa8de3..41fac55180 100644 --- a/front_end/src/components/consumer_post_card/group_forecast_card/forecast_card_wrapper.tsx +++ b/front_end/src/components/consumer_post_card/group_forecast_card/forecast_card_wrapper.tsx @@ -1,4 +1,8 @@ -import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons"; +import { + faChevronDown, + faChevronUp, + faEllipsis, +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useTranslations } from "next-intl"; import { FC, PropsWithChildren } from "react"; @@ -13,6 +17,7 @@ type Props = { onCollapse?: () => void; hideOthersValue?: boolean; compact?: boolean; + buttonVariant?: "primary" | "minimal"; }; const ForecastCardWrapper: FC> = ({ @@ -21,16 +26,21 @@ const ForecastCardWrapper: FC> = ({ onExpand, onCollapse, compact = false, + buttonVariant = "primary", children, }) => { const t = useTranslations(); const showExpandRow = !expanded && otherItemsCount > 0; + const isMinimal = buttonVariant === "minimal"; + const toggleButtonClassName = cn( "flex w-full self-stretch items-center gap-2 rounded-lg px-[13px]", - "bg-blue-500/20 dark:bg-blue-500-dark/20", - "text-sm font-medium leading-4 text-blue-700 dark:text-blue-700-dark", - compact ? "h-6 md:h-8" : "h-8" + "font-medium leading-4", + compact ? "h-6 md:h-8" : "h-8", + isMinimal + ? "border border-gray-300 text-xs text-gray-700 sm:text-sm dark:border-gray-300-dark dark:text-gray-700-dark" + : "bg-blue-500/20 text-sm text-blue-700 dark:bg-blue-500-dark/20 dark:text-blue-700-dark" ); return ( @@ -45,11 +55,16 @@ const ForecastCardWrapper: FC> = ({ {showExpandRow && ( )} @@ -61,7 +76,12 @@ const ForecastCardWrapper: FC> = ({ aria-pressed={true} className={toggleButtonClassName} > - + {t("collapse")} )} diff --git a/front_end/src/components/consumer_post_card/group_forecast_card/index.tsx b/front_end/src/components/consumer_post_card/group_forecast_card/index.tsx index 47797ae3f9..b784bfed6d 100644 --- a/front_end/src/components/consumer_post_card/group_forecast_card/index.tsx +++ b/front_end/src/components/consumer_post_card/group_forecast_card/index.tsx @@ -17,9 +17,10 @@ import PercentageForecastCard from "./percentage_forecast_card"; type Props = { post: PostWithForecasts; compact?: boolean; + buttonVariant?: "primary" | "minimal"; }; -const GroupForecastCard: FC = ({ post, compact }) => { +const GroupForecastCard: FC = ({ post, compact, buttonVariant }) => { // Check forecast availability for group posts const forecastAvailability = post.group_of_questions ? getGroupForecastAvailability(post.group_of_questions.questions) @@ -47,20 +48,38 @@ const GroupForecastCard: FC = ({ post, compact }) => { isMultipleChoicePost(post) || checkGroupOfQuestionsPostType(post, QuestionType.Binary) ) { - return ; + return ( + + ); } if ( checkGroupOfQuestionsPostType(post, QuestionType.Numeric) || checkGroupOfQuestionsPostType(post, QuestionType.Discrete) ) { - return ; + return ( + + ); } if ( post.group_of_questions && checkGroupOfQuestionsPostType(post, QuestionType.Date) ) { if (compact) { - return ; + return ( + + ); } return ( diff --git a/front_end/src/components/consumer_post_card/group_forecast_card/numeric_forecast_card.tsx b/front_end/src/components/consumer_post_card/group_forecast_card/numeric_forecast_card.tsx index 218cdd11a5..aaa623c70f 100644 --- a/front_end/src/components/consumer_post_card/group_forecast_card/numeric_forecast_card.tsx +++ b/front_end/src/components/consumer_post_card/group_forecast_card/numeric_forecast_card.tsx @@ -23,9 +23,15 @@ type Props = { post: PostWithForecasts; forceColorful?: boolean; compact?: boolean; + buttonVariant?: "primary" | "minimal"; }; -const NumericForecastCard: FC = ({ post, forceColorful, compact }) => { +const NumericForecastCard: FC = ({ + post, + forceColorful, + compact, + buttonVariant, +}) => { const locale = useLocale(); const t = useTranslations(); const [expanded, setExpanded] = useState(false); @@ -147,6 +153,7 @@ const NumericForecastCard: FC = ({ post, forceColorful, compact }) => { }} hideOthersValue compact={compact} + buttonVariant={buttonVariant} > {renderBars(collapsedChoices)} @@ -163,6 +170,7 @@ const NumericForecastCard: FC = ({ post, forceColorful, compact }) => { }} hideOthersValue compact={compact} + buttonVariant={buttonVariant} > {renderBars(sortedChoices)} diff --git a/front_end/src/components/consumer_post_card/group_forecast_card/percentage_forecast_card.tsx b/front_end/src/components/consumer_post_card/group_forecast_card/percentage_forecast_card.tsx index 813c515d25..cd05a5391f 100644 --- a/front_end/src/components/consumer_post_card/group_forecast_card/percentage_forecast_card.tsx +++ b/front_end/src/components/consumer_post_card/group_forecast_card/percentage_forecast_card.tsx @@ -24,12 +24,14 @@ type Props = { post: PostWithForecasts; forceColorful?: boolean; compact?: boolean; + buttonVariant?: "primary" | "minimal"; }; const PercentageForecastCard: FC = ({ post, forceColorful, compact, + buttonVariant, }) => { const locale = useLocale(); const t = useTranslations(); @@ -127,6 +129,7 @@ const PercentageForecastCard: FC = ({ }} hideOthersValue={isGroupBinary} compact={compact} + buttonVariant={buttonVariant} > {renderBars(collapsedChoices)} @@ -144,6 +147,7 @@ const PercentageForecastCard: FC = ({ }} hideOthersValue={isGroupBinary} compact={compact} + buttonVariant={buttonVariant} > {renderBars(allChoices)} diff --git a/front_end/src/components/detailed_question_card/detailed_group_card/index.tsx b/front_end/src/components/detailed_question_card/detailed_group_card/index.tsx index d64adce9f1..b4aa05053c 100644 --- a/front_end/src/components/detailed_question_card/detailed_group_card/index.tsx +++ b/front_end/src/components/detailed_question_card/detailed_group_card/index.tsx @@ -44,6 +44,7 @@ type Props = { onLegendHeightChange?: (height: number) => void; chartTheme?: VictoryThemeDefinition; defaultZoom?: TimelineChartZoomOption; + withLegend?: boolean; }; const DetailedGroupCard: FC = ({ @@ -56,6 +57,7 @@ const DetailedGroupCard: FC = ({ onLegendHeightChange, chartTheme, defaultZoom, + withLegend, }) => { const { open_time, @@ -186,6 +188,7 @@ const DetailedGroupCard: FC = ({ chartHeight={embedChartHeight} chartTheme={chartTheme} defaultZoom={defaultZoom} + withLegend={withLegend} /> ); } diff --git a/front_end/src/components/detailed_question_card/detailed_question_card/index.tsx b/front_end/src/components/detailed_question_card/detailed_question_card/index.tsx index 95c8353717..e56c3c7d14 100644 --- a/front_end/src/components/detailed_question_card/detailed_question_card/index.tsx +++ b/front_end/src/components/detailed_question_card/detailed_question_card/index.tsx @@ -99,6 +99,7 @@ const DetailedQuestionCard: FC = ({ onLegendHeightChange={onLegendHeightChange} chartTheme={chartTheme} defaultZoom={defaultZoom} + isConsumerView={isConsumerView} /> ); diff --git a/front_end/src/components/detailed_question_card/detailed_question_card/multiple_choice_chart_card.tsx b/front_end/src/components/detailed_question_card/detailed_question_card/multiple_choice_chart_card.tsx index ff10c37c28..0df9789082 100644 --- a/front_end/src/components/detailed_question_card/detailed_question_card/multiple_choice_chart_card.tsx +++ b/front_end/src/components/detailed_question_card/detailed_question_card/multiple_choice_chart_card.tsx @@ -33,6 +33,7 @@ type Props = { forecastAvailability?: ForecastAvailability; onLegendHeightChange?: (height: number) => void; hideTitle?: boolean; + isConsumerView?: boolean; }; const DetailedMultipleChoiceChartCard: FC = ({ @@ -45,6 +46,7 @@ const DetailedMultipleChoiceChartCard: FC = ({ forecastAvailability, onLegendHeightChange, hideTitle, + isConsumerView, }) => { const t = useTranslations(); const [isChartHovered, setIsChartHovered] = useState(false); @@ -308,6 +310,7 @@ const DetailedMultipleChoiceChartCard: FC = ({ defaultZoom={defaultZoom} forecastAvailability={forecastAvailability} openTime={openTime} + withLegend={!isConsumerView} /> ); }; From 24c25a2fb0f4a799901928feaef51e50c1c8cd48 Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 14 May 2026 14:19:46 +0300 Subject: [PATCH 5/9] fix: restore isBordered prop in ForecastChoiceBar and show TimeSeriesChart in fan graph left panel --- .../components/question_page_shell/index.tsx | 16 ++++++++++++++++ .../group_forecast_card/forecast_choice_bar.tsx | 6 +++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx index 47fb957000..3f1acb677c 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx @@ -5,6 +5,7 @@ import { FC, Fragment, ReactNode, useEffect } from "react"; import useCoherenceLinksContext from "@/app/(main)/components/coherence_links_provider"; import { PostStatusBox } from "@/app/(main)/questions/[id]/components/post_status_box"; +import TimeSeriesChart from "@/components/consumer_post_card/time_series_chart"; import UpcomingCP from "@/components/consumer_post_card/upcoming_cp"; import DetailedGroupCard from "@/components/detailed_question_card/detailed_group_card"; import DetailedQuestionCard from "@/components/detailed_question_card/detailed_question_card"; @@ -23,6 +24,7 @@ import { import { TournamentType } from "@/types/projects"; import { QuestionType, QuestionWithNumericForecasts } from "@/types/question"; import { getQuestionForecastAvailability } from "@/utils/questions/forecastAvailability"; +import { sortGroupPredictionOptions } from "@/utils/questions/groupOrdering"; import { checkGroupOfQuestionsPostType, isContinuousQuestion, @@ -171,6 +173,14 @@ export const ConsumerShell: FC<{ const showClosedMessageFanGraph = isFanGraph && postData.status === PostStatus.CLOSED; + const fanGraphQuestions = isFanGraph + ? sortGroupPredictionOptions( + (postData.group_of_questions?.questions ?? + []) as QuestionWithNumericForecasts[], + postData.group_of_questions + ) + : null; + const questionLinkAggregates = aggregateCoherenceLinks?.data.filter(isDisplayableQuestionLink) ?? []; const hasKeyFactors = (postData.key_factors?.length ?? 0) > 0; @@ -256,6 +266,12 @@ export const ConsumerShell: FC<{ listContent={ hideCP ? ( + ) : isFanGraph && fanGraphQuestions ? ( + ) : ( = ({ displayedResolution, resolution, color, + isBordered = true, unit, forceColorful = false, compact = false, @@ -50,7 +51,10 @@ const ForecastChoiceBar: FC = ({ return (
Date: Thu, 14 May 2026 14:19:51 +0300 Subject: [PATCH 6/9] fix: stretch group forecast card to full width and use minimal expand button in feed --- front_end/src/components/consumer_post_card/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/front_end/src/components/consumer_post_card/index.tsx b/front_end/src/components/consumer_post_card/index.tsx index 33bcc0953a..1c48c25179 100644 --- a/front_end/src/components/consumer_post_card/index.tsx +++ b/front_end/src/components/consumer_post_card/index.tsx @@ -50,7 +50,9 @@ const ConsumerPostCard: FC = ({ )} {(isGroupOfQuestionsPost(post) || isMultipleChoicePost(post)) && ( - +
+ +
)} {[PostStatus.PENDING_RESOLUTION, PostStatus.CLOSED].includes( From 14dfa4f03c3651c3566e4ea6296654be6c7b63fc Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 14 May 2026 14:19:55 +0300 Subject: [PATCH 7/9] feat: add consumer side-by-side shell for continuous numeric group questions with proportional bars, hover-synced chart highlight, and endpoint dots --- .../[id]/components/group_timeline.tsx | 19 ++++++++ .../multiple_choices_chart_view/index.tsx | 6 +++ .../components/question_page_shell/index.tsx | 18 ++++++++ .../consumer_group_chart.tsx | 39 ++++++++++++++++ .../consumer_list_chart_shell.tsx | 22 ++++++++- .../src/components/charts/group_chart.tsx | 46 +++++++++++++++++-- .../forecast_card_wrapper.tsx | 7 +-- .../forecast_choice_bar.tsx | 18 ++++++-- .../numeric_forecast_card.tsx | 25 +++++++--- .../percentage_forecast_card.tsx | 13 ------ 10 files changed, 180 insertions(+), 33 deletions(-) create mode 100644 front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_group_chart.tsx diff --git a/front_end/src/app/(main)/questions/[id]/components/group_timeline.tsx b/front_end/src/app/(main)/questions/[id]/components/group_timeline.tsx index bdf4d40f92..5577b5ff8a 100644 --- a/front_end/src/app/(main)/questions/[id]/components/group_timeline.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/group_timeline.tsx @@ -52,11 +52,14 @@ type Props = QuestionsDataProps & { embedMode?: boolean; withLegend?: boolean; className?: string; + externalHighlightedChoice?: string | null; prioritizeOpen?: boolean; timelineMarkers?: GroupTimelineMarker[]; activeTimelineMarkerId?: string | null; onTimelineMarkerEnter?: (marker: GroupTimelineMarker) => void; onTimelineMarkerLeave?: (marker: GroupTimelineMarker) => void; + withHighlightArea?: boolean; + withHighlightEndpoint?: boolean; }; /** @@ -86,6 +89,9 @@ const GroupTimeline: FC = ({ activeTimelineMarkerId, onTimelineMarkerEnter, onTimelineMarkerLeave, + externalHighlightedChoice, + withHighlightArea, + withHighlightEndpoint, }) => { const t = useTranslations(); const { user } = useAuth(); @@ -168,6 +174,17 @@ const GroupTimeline: FC = ({ setChoiceItems(generateList(questions, group, preselectedQuestionId)); }, [questions, preselectedQuestionId, generateList, group]); + // apply external highlight from parent (e.g. consumer row hover) + useEffect(() => { + if (externalHighlightedChoice === undefined) return; + setChoiceItems((prev) => + prev.map((item) => ({ + ...item, + highlighted: item.choice === externalHighlightedChoice, + })) + ); + }, [externalHighlightedChoice]); + const [cursorTimestamp, _tooltipDate, handleCursorChange] = useTimestampCursor(timestamps); const tooltipChoices = useMemo(() => { @@ -335,6 +352,8 @@ const GroupTimeline: FC = ({ activeTimelineMarkerId={activeTimelineMarkerId} onTimelineMarkerEnter={onTimelineMarkerEnter} onTimelineMarkerLeave={onTimelineMarkerLeave} + withHighlightArea={withHighlightArea} + withHighlightEndpoint={withHighlightEndpoint} /> ); }; diff --git a/front_end/src/app/(main)/questions/[id]/components/multiple_choices_chart_view/index.tsx b/front_end/src/app/(main)/questions/[id]/components/multiple_choices_chart_view/index.tsx index 336da2af11..46291776c9 100644 --- a/front_end/src/app/(main)/questions/[id]/components/multiple_choices_chart_view/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/multiple_choices_chart_view/index.tsx @@ -49,6 +49,8 @@ type Props = { activeTimelineMarkerId?: string | null; onTimelineMarkerEnter?: (marker: GroupTimelineMarker) => void; onTimelineMarkerLeave?: (marker: GroupTimelineMarker) => void; + withHighlightArea?: boolean; + withHighlightEndpoint?: boolean; }; const MultiChoicesChartView: FC = ({ @@ -82,6 +84,8 @@ const MultiChoicesChartView: FC = ({ activeTimelineMarkerId, onTimelineMarkerEnter, onTimelineMarkerLeave, + withHighlightArea = true, + withHighlightEndpoint = false, }) => { const { user } = useAuth(); const isInteracted = useRef(false); @@ -243,6 +247,8 @@ const MultiChoicesChartView: FC = ({ forceAutoZoom: isInteracted.current, forecastAvailability, attachRef, + withHighlightArea, + withHighlightEndpoint, } as const; return ( diff --git a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx index 3f1acb677c..d41b5203db 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx @@ -5,6 +5,7 @@ import { FC, Fragment, ReactNode, useEffect } from "react"; import useCoherenceLinksContext from "@/app/(main)/components/coherence_links_provider"; import { PostStatusBox } from "@/app/(main)/questions/[id]/components/post_status_box"; +import NumericForecastCard from "@/components/consumer_post_card/group_forecast_card/numeric_forecast_card"; import TimeSeriesChart from "@/components/consumer_post_card/time_series_chart"; import UpcomingCP from "@/components/consumer_post_card/upcoming_cp"; import DetailedGroupCard from "@/components/detailed_question_card/detailed_group_card"; @@ -42,6 +43,7 @@ import PostScoreData from "../post_score_data"; import { QuestionLayoutProvider } from "../question_layout/question_layout_context"; import { QuestionVariantComposer } from "../question_variant_composer"; import ActionRow from "../question_view/action_row"; +import ConsumerGroupChart from "../question_view/consumer_question_view/consumer_group_chart"; import ConsumerListChartShell from "../question_view/consumer_question_view/consumer_list_chart_shell"; import ConsumerQuestionPrediction from "../question_view/consumer_question_view/prediction"; import QuestionTimeline from "../question_view/consumer_question_view/timeline"; @@ -161,6 +163,12 @@ export const ConsumerShell: FC<{ const isNRowBody = isMultipleChoice || (isNonFanGroup && !isDateGroup) || isFanGraph; + const isContinuousNumericGroup = + isNonFanGroup && + !isDateGroup && + !isMultipleChoice && + checkGroupOfQuestionsPostType(postData, QuestionType.Numeric); + const binaryForecastAvailability = isBinarySingleQuestion && isQuestionPost(postData) ? getQuestionForecastAvailability(postData.question) @@ -263,6 +271,7 @@ export const ConsumerShell: FC<{ ) : isNRowBody ? ( <> @@ -272,6 +281,8 @@ export const ConsumerShell: FC<{ variant="colorful" height={180} /> + ) : isContinuousNumericGroup ? ( + ) : ( + ) : isContinuousNumericGroup ? ( + + } + preselectedQuestionId={preselectedGroupQuestionId} + /> ) : ( ; + preselectedQuestionId?: number; +}; + +const ConsumerGroupChart: FC = ({ post, preselectedQuestionId }) => { + const { hoveredChoiceName, setHoveredChoiceName } = useListChartExpanded(); + const { open_time, actual_close_time, scheduled_close_time, status } = post; + const refCloseTime = actual_close_time ?? scheduled_close_time; + + return ( +
setHoveredChoiceName(null)}> + +
+ ); +}; + +export default ConsumerGroupChart; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx index 4b372560e4..74e7ee98fa 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx @@ -6,10 +6,14 @@ import cn from "@/utils/core/cn"; type ListChartExpandedContextType = { setIsExpanded: (value: boolean) => void; + hoveredChoiceName: string | null; + setHoveredChoiceName: (name: string | null) => void; }; const ListChartExpandedContext = createContext({ setIsExpanded: () => {}, + hoveredChoiceName: null, + setHoveredChoiceName: () => {}, }); export const useListChartExpanded = () => useContext(ListChartExpandedContext); @@ -17,18 +21,25 @@ export const useListChartExpanded = () => useContext(ListChartExpandedContext); type Props = { listContent: ReactNode; chartContent: ReactNode; + stretchListContent?: boolean; className?: string; }; const ConsumerListChartShell: React.FC = ({ listContent, chartContent, + stretchListContent = false, className, }) => { const [isExpanded, setIsExpanded] = useState(false); + const [hoveredChoiceName, setHoveredChoiceName] = useState( + null + ); return ( - +
= ({ className )} > -
{listContent}
+
+ {listContent} +