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}
+
+ {chartContent}
+
+
+
+ );
+};
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}
+
void;
animate?: object;
leftPadding?: number;
+ withHighlightArea?: boolean;
+ withHighlightEndpoint?: boolean;
};
const LABEL_FONT_FAMILY = "Inter";
@@ -133,6 +135,8 @@ const GroupChart: FC
= ({
onTimelineMarkerLeave,
animate,
leftPadding = 0,
+ withHighlightArea = true,
+ withHighlightEndpoint = false,
}) => {
const t = useTranslations();
const {
@@ -377,10 +381,14 @@ const GroupChart: FC = ({
}
},
onMouseLeave: () => {
- if (!onCursorChange) return;
inPlotRef.current = false;
setIsCursorActive(false);
setLocalCursorTimestamp(null);
+ // Reset to last timestamp so lines don't stay frozen at last hovered position.
+ const lastTs = timestamps.at(-1);
+ if (onCursorChange && !isNil(lastTs)) {
+ onCursorChange(lastTs, () => "");
+ }
},
},
},
@@ -509,13 +517,45 @@ const GroupChart: FC = ({
: highlighted
? 1
: 0.3,
- strokeWidth: 1.5,
+ strokeWidth: isHighlightActive && highlighted ? 3 : 1.5,
},
}}
interpolation="stepAfter"
/>
);
})}
+ {/* Line endpoint dot */}
+ {withHighlightEndpoint &&
+ graphs.map(
+ ({ color, active, line, highlighted, isClosed }, index) => {
+ if (!active) return null;
+ const filteredLine = filteredLines[index];
+ if (!filteredLine) return null;
+ const point = {
+ x: isClosed
+ ? line?.at(-1)?.x ?? Number(xDomain[1])
+ : Number(xDomain[1]),
+ y: line?.at(-1)?.y ?? 0,
+ };
+ return (
+
+ );
+ }
+ )}
{/* Line cursor points */}
{graphs.map(
({ color, active, line, highlighted, isClosed }, index) => {
@@ -569,7 +609,7 @@ const GroupChart: FC = ({
style={{
data: {
fill: getThemeColor(color),
- opacity: highlighted ? 0.3 : 0,
+ opacity: withHighlightArea && highlighted ? 0.3 : 0,
},
}}
interpolation="stepAfter"
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 41fac55180..dce2e40c93 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
@@ -11,13 +11,12 @@ import cn from "@/utils/core/cn";
type Props = {
otherItemsCount: number;
- othersTotal?: number;
expanded?: boolean;
onExpand?: () => void;
onCollapse?: () => void;
- hideOthersValue?: boolean;
compact?: boolean;
buttonVariant?: "primary" | "minimal";
+ className?: string;
};
const ForecastCardWrapper: FC> = ({
@@ -27,6 +26,7 @@ const ForecastCardWrapper: FC> = ({
onCollapse,
compact = false,
buttonVariant = "primary",
+ className,
children,
}) => {
const t = useTranslations();
@@ -47,7 +47,8 @@ const ForecastCardWrapper: FC> = ({
{children}
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 deba190049..5bd5c8e90a 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
@@ -25,6 +25,9 @@ type Props = {
unit?: string;
forceColorful?: boolean;
compact?: boolean;
+ onMouseEnter?: () => void;
+ onMouseLeave?: () => void;
+ className?: string;
};
const WIDTH_ADJUSTMENT = 2;
@@ -41,6 +44,9 @@ const ForecastChoiceBar: FC
= ({
unit,
forceColorful = false,
compact = false,
+ onMouseEnter,
+ onMouseLeave,
+ className,
}) => {
const t = useTranslations();
const { getThemeColor } = useAppTheme();
@@ -50,8 +56,11 @@ const ForecastChoiceBar: FC = ({
const isResolutionSuccessful = isSuccessfullyResolved(resolution);
return (
= ({
{isCpRevealed && (
= ({
mounted
? getThemeColor(METAC_COLORS.gray["500"])
: METAC_COLORS.gray["500"].DEFAULT,
- 0.3
+ 0.4
);
}
return addOpacityToHex(
mounted ? getThemeColor(color) : color.DEFAULT,
- 0.3
+ 0.4
);
})(),
borderColor: (() => {
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 aaa623c70f..aaf4c3a266 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
@@ -8,6 +8,7 @@ import { useListChartExpanded } from "@/app/(main)/questions/[id]/components/que
import { getEffectiveVisibleCount } from "@/constants/questions";
import { PostStatus, PostWithForecasts } from "@/types/post";
import { QuestionType, Scaling } from "@/types/question";
+import cn from "@/utils/core/cn";
import { getPredictionDisplayValue } from "@/utils/formatters/prediction";
import { scaleInternalLocation } from "@/utils/math";
import { generateChoiceItemsFromGroupQuestions } from "@/utils/questions/choices";
@@ -24,6 +25,7 @@ type Props = {
forceColorful?: boolean;
compact?: boolean;
buttonVariant?: "primary" | "minimal";
+ fillHeight?: boolean;
};
const NumericForecastCard: FC
= ({
@@ -31,11 +33,12 @@ const NumericForecastCard: FC = ({
forceColorful,
compact,
buttonVariant,
+ fillHeight = false,
}) => {
const locale = useLocale();
const t = useTranslations();
const [expanded, setExpanded] = useState(false);
- const { setIsExpanded } = useListChartExpanded();
+ const { setIsExpanded, setHoveredChoiceName } = useListChartExpanded();
if (!isGroupOfQuestionsPost(post)) {
return null;
@@ -84,7 +87,7 @@ const NumericForecastCard: FC = ({
const maxScaledValue = Math.max(...scaledValues);
const minScaledValue = Math.min(...scaledValues);
- const renderBars = (choices: typeof sortedChoices) =>
+ const renderBars = (choices: typeof sortedChoices, stretchBars = false) =>
choices.map(
({
closeTime,
@@ -136,14 +139,23 @@ const NumericForecastCard: FC = ({
unit={unit}
forceColorful={forceColorful}
compact={compact}
+ isBordered={false}
+ onMouseEnter={() => setHoveredChoiceName(choice)}
+ onMouseLeave={() => setHoveredChoiceName(null)}
+ className={stretchBars ? "flex-1" : undefined}
/>
);
}
);
return (
-
-
+
+
= ({
setExpanded(true);
setIsExpanded(true);
}}
- hideOthersValue
compact={compact}
buttonVariant={buttonVariant}
+ className={fillHeight ? "flex-1" : undefined}
>
- {renderBars(collapsedChoices)}
+ {renderBars(collapsedChoices, fillHeight)}
@@ -168,7 +180,6 @@ const NumericForecastCard: FC
= ({
setExpanded(false);
setIsExpanded(false);
}}
- hideOthersValue
compact={compact}
buttonVariant={buttonVariant}
>
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 cd05a5391f..581ab11a5a 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
@@ -39,11 +39,6 @@ const PercentageForecastCard: FC = ({
const { setIsExpanded } = useListChartExpanded();
const isMC = isMultipleChoicePost(post);
- const isGroupBinary =
- isGroupOfQuestionsPost(post) &&
- post.group_of_questions?.questions?.every(
- (q) => q.type === QuestionType.Binary
- );
const cpRevealTime = post.question?.cp_reveal_time;
const emptyLabel =
cpRevealTime && new Date(cpRevealTime).getTime() > Date.now()
@@ -94,11 +89,6 @@ const PercentageForecastCard: FC = ({
const collapsedChoices = allChoices.slice(0, visibleChoicesCount);
const hiddenCount = allChoices.length - visibleChoicesCount;
- const collapsedSumMC = collapsedChoices.reduce((s, c) => s + c.percent, 0);
- const othersTotal = isMC
- ? Math.max(0, Math.min(100, 100 - Math.round(collapsedSumMC)))
- : 0;
-
const renderBars = (choices: typeof allChoices) =>
choices.map((choice) => (
= ({
{
setExpanded(true);
setIsExpanded(true);
}}
- hideOthersValue={isGroupBinary}
compact={compact}
buttonVariant={buttonVariant}
>
@@ -145,7 +133,6 @@ const PercentageForecastCard: FC = ({
setExpanded(false);
setIsExpanded(false);
}}
- hideOthersValue={isGroupBinary}
compact={compact}
buttonVariant={buttonVariant}
>
From bad894980efceedf69958693b3fecc835210bfee Mon Sep 17 00:00:00 2001
From: Nikita
Date: Thu, 14 May 2026 14:20:01 +0300
Subject: [PATCH 8/9] fix: scope bar hover effects to wired handlers, fill
height for all bar-row types, cover discrete groups, and prevent expand
height jump
---
.../components/question_page_shell/index.tsx | 13 +--
.../consumer_list_chart_shell.tsx | 12 ++-
.../forecast_choice_bar.tsx | 8 +-
.../numeric_forecast_card.tsx | 81 ++++++++++---------
.../percentage_forecast_card.tsx | 43 +++++-----
5 files changed, 91 insertions(+), 66 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 d41b5203db..f026476578 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
@@ -6,6 +6,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 PercentageForecastCard from "@/components/consumer_post_card/group_forecast_card/percentage_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";
@@ -167,7 +168,8 @@ export const ConsumerShell: FC<{
isNonFanGroup &&
!isDateGroup &&
!isMultipleChoice &&
- checkGroupOfQuestionsPostType(postData, QuestionType.Numeric);
+ (checkGroupOfQuestionsPostType(postData, QuestionType.Numeric) ||
+ checkGroupOfQuestionsPostType(postData, QuestionType.Discrete));
const binaryForecastAvailability =
isBinarySingleQuestion && isQuestionPost(postData)
@@ -271,7 +273,7 @@ export const ConsumerShell: FC<{
) : isNRowBody ? (
<>
@@ -284,9 +286,10 @@ export const ConsumerShell: FC<{
) : isContinuousNumericGroup ? (
) : (
-
)
}
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 74e7ee98fa..68c52ab4fc 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,6 +1,6 @@
"use client";
-import { createContext, ReactNode, useContext, useState } from "react";
+import { createContext, ReactNode, useContext, useMemo, useState } from "react";
import cn from "@/utils/core/cn";
@@ -36,10 +36,14 @@ const ConsumerListChartShell: React.FC = ({
null
);
+ // Memoize so isExpanded changes don't re-render context consumers (e.g. the chart).
+ const contextValue = useMemo(
+ () => ({ setIsExpanded, hoveredChoiceName, setHoveredChoiceName }),
+ [hoveredChoiceName, setHoveredChoiceName, setIsExpanded]
+ );
+
return (
-
+
= ({
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={cn(
- "group relative flex w-full items-center justify-between gap-2 rounded-lg bg-transparent font-medium text-gray-800 transition-colors hover:bg-blue-500/20 dark:text-gray-800-dark dark:hover:bg-blue-500-dark/20",
+ "relative flex w-full items-center justify-between gap-2 rounded-lg bg-transparent font-medium text-gray-800 dark:text-gray-800-dark",
+ onMouseEnter &&
+ "group transition-colors hover:bg-blue-500/20 dark:hover:bg-blue-500-dark/20",
className,
isBordered
? "border border-blue-400 dark:border-blue-400-dark"
@@ -109,7 +111,9 @@ const ForecastChoiceBar: FC
= ({
{isCpRevealed && (
= ({
const maxScaledValue = Math.max(...scaledValues);
const minScaledValue = Math.min(...scaledValues);
- const renderBars = (choices: typeof sortedChoices, stretchBars = false) =>
+ const renderBars = (
+ choices: typeof sortedChoices,
+ stretchBars = false,
+ hoverUpTo = Infinity
+ ) =>
choices.map(
- ({
- closeTime,
- aggregationValues,
- scaling,
- resolution,
- id,
- color,
- displayedResolution,
- choice,
- actual_resolve_time,
- unit,
- }) => {
+ (
+ {
+ closeTime,
+ aggregationValues,
+ scaling,
+ resolution,
+ id,
+ color,
+ displayedResolution,
+ choice,
+ actual_resolve_time,
+ unit,
+ },
+ index
+ ) => {
const isChoiceClosed = closeTime ? closeTime < Date.now() : false;
const rawChoiceValue =
aggregationValues[aggregationValues.length - 1] ?? null;
@@ -140,36 +147,38 @@ const NumericForecastCard: FC
= ({
forceColorful={forceColorful}
compact={compact}
isBordered={false}
- onMouseEnter={() => setHoveredChoiceName(choice)}
- onMouseLeave={() => setHoveredChoiceName(null)}
+ onMouseEnter={
+ index < hoverUpTo ? () => setHoveredChoiceName(choice) : undefined
+ }
+ onMouseLeave={
+ index < hoverUpTo ? () => setHoveredChoiceName(null) : undefined
+ }
className={stretchBars ? "flex-1" : undefined}
/>
);
}
);
+ // Only fill height when all items are visible (no expand button).
+ const effectiveFillHeight = fillHeight && hiddenCount === 0;
+
return (
-
-
+ {
+ setExpanded(true);
+ setIsExpanded(true);
+ }}
+ compact={compact}
+ buttonVariant={buttonVariant}
+ className={effectiveFillHeight ? "flex-1" : undefined}
>
- {
- setExpanded(true);
- setIsExpanded(true);
- }}
- compact={compact}
- buttonVariant={buttonVariant}
- className={fillHeight ? "flex-1" : undefined}
- >
- {renderBars(collapsedChoices, fillHeight)}
-
-
+ {renderBars(collapsedChoices, effectiveFillHeight)}
+
{expanded && (
@@ -183,7 +192,7 @@ const NumericForecastCard: FC
= ({
compact={compact}
buttonVariant={buttonVariant}
>
- {renderBars(sortedChoices)}
+ {renderBars(sortedChoices, false, visibleChoicesCount)}
)}
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 581ab11a5a..8167f58d42 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
@@ -7,6 +7,7 @@ import { useListChartExpanded } from "@/app/(main)/questions/[id]/components/que
import { getEffectiveVisibleCount } from "@/constants/questions";
import { PostStatus, PostWithForecasts } from "@/types/post";
import { QuestionType } from "@/types/question";
+import cn from "@/utils/core/cn";
import { getPredictionDisplayValue } from "@/utils/formatters/prediction";
import {
generateChoiceItemsFromGroupQuestions,
@@ -25,6 +26,7 @@ type Props = {
forceColorful?: boolean;
compact?: boolean;
buttonVariant?: "primary" | "minimal";
+ fillHeight?: boolean;
};
const PercentageForecastCard: FC
= ({
@@ -32,6 +34,7 @@ const PercentageForecastCard: FC = ({
forceColorful,
compact,
buttonVariant,
+ fillHeight = false,
}) => {
const locale = useLocale();
const t = useTranslations();
@@ -89,7 +92,7 @@ const PercentageForecastCard: FC = ({
const collapsedChoices = allChoices.slice(0, visibleChoicesCount);
const hiddenCount = allChoices.length - visibleChoicesCount;
- const renderBars = (choices: typeof allChoices) =>
+ const renderBars = (choices: typeof allChoices, stretchBars = false) =>
choices.map((choice) => (
= ({
color={choice.color}
forceColorful={forceColorful}
compact={compact}
+ className={stretchBars ? "flex-1" : undefined}
/>
));
+ // Only fill height when all items are visible (no expand button).
+ const effectiveFillHeight = fillHeight && hiddenCount === 0;
+
return (
-
- {/* Always in normal flow — keeps the left panel and shell at collapsed height */}
-
- {
- setExpanded(true);
- setIsExpanded(true);
- }}
- compact={compact}
- buttonVariant={buttonVariant}
- >
- {renderBars(collapsedChoices)}
-
-
-
- {/* Expanded panel — absolutely positioned so it overflows below the shell */}
+
+
{
+ setExpanded(true);
+ setIsExpanded(true);
+ }}
+ compact={compact}
+ buttonVariant={buttonVariant}
+ className={effectiveFillHeight ? "flex-1" : undefined}
+ >
+ {renderBars(collapsedChoices, effectiveFillHeight)}
+
{expanded && (
Date: Thu, 14 May 2026 14:20:05 +0300
Subject: [PATCH 9/9] fix: render minimal expand indicator as div to fix
accessibility violation
---
.../forecast_card_wrapper.tsx | 36 ++++++++++---------
1 file changed, 20 insertions(+), 16 deletions(-)
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 dce2e40c93..99397f4dc8 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
@@ -53,22 +53,26 @@ const ForecastCardWrapper: FC> = ({
>
{children}
- {showExpandRow && (
-
- )}
+ {showExpandRow &&
+ (isMinimal ? (
+
+
+ {t("otherWithCount", { count: otherItemsCount })}
+
+ ) : (
+
+ ))}
{expanded && onCollapse && (