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 7df8d1f510..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
@@ -5,6 +5,9 @@ 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";
import DetailedQuestionCard from "@/components/detailed_question_card/detailed_question_card";
@@ -15,14 +18,15 @@ 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 { sortGroupPredictionOptions } from "@/utils/questions/groupOrdering";
import {
checkGroupOfQuestionsPostType,
isContinuousQuestion,
@@ -40,15 +44,17 @@ 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";
import QuestionHeaderCPStatus from "../question_view/forecaster_question_view/question_header/question_header_cp_status";
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 = {
@@ -147,9 +153,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 +161,21 @@ export const ConsumerShell: FC<{
!isContinuousSingleQuestion &&
!isMultipleChoice;
+ const isNRowBody =
+ isMultipleChoice || (isNonFanGroup && !isDateGroup) || isFanGraph;
+
+ const isContinuousNumericGroup =
+ isNonFanGroup &&
+ !isDateGroup &&
+ !isMultipleChoice &&
+ (checkGroupOfQuestionsPostType(postData, QuestionType.Numeric) ||
+ checkGroupOfQuestionsPostType(postData, QuestionType.Discrete));
+
const binaryForecastAvailability =
isBinarySingleQuestion && isQuestionPost(postData)
? getQuestionForecastAvailability(postData.question)
: null;
- const showSideBySide =
- isMultipleChoice ||
- isNonFanGroup ||
- isBinarySingleQuestion ||
- isContinuousSingleQuestion;
-
const showClosedMessageMultipleChoice =
isMultipleChoicePost(postData) &&
postData.question.status === QuestionStatus.CLOSED;
@@ -176,6 +183,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;
@@ -218,21 +233,8 @@ export const ConsumerShell: FC<{
{t("predictionClosedMessage")}
)}
-
- {isBinarySingleQuestion && isQuestionPost(postData) ? (
+ {isBinarySingleQuestion && isQuestionPost(postData) ? (
+
{hideCP ? (
@@ -247,47 +249,85 @@ export const ConsumerShell: FC<{
/>
)}
- ) : (
-
- {hideCP && !isContinuousSingleQuestion ? (
-
- ) : (
-
- )}
+
+
+ ) : isContinuousSingleQuestion ? (
+
+
+
- )}
- {!isFanGraph && !isDateGroup && (
- )}
- {showClosedMessageFanGraph && (
-
- {t("predictionClosedMessage")}
-
- )}
-
+
+ ) : isNRowBody ? (
+ <>
+
+ ) : isFanGraph && fanGraphQuestions ? (
+
+ ) : isContinuousNumericGroup ? (
+
+ ) : (
+
+ )
+ }
+ chartContent={
+ isFanGraph ? (
+
+ }
+ preselectedQuestionId={preselectedGroupQuestionId}
+ />
+ ) : isContinuousNumericGroup ? (
+
+ }
+ preselectedQuestionId={preselectedGroupQuestionId}
+ />
+ ) : (
+
+ )
+ }
+ />
+ {showClosedMessageFanGraph && (
+
+ {t("predictionClosedMessage")}
+
+ )}
+ >
+ ) : (
+
+ )}
{shouldShowKeyFactorsSection && (
diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_group_chart.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_group_chart.tsx
new file mode 100644
index 0000000000..b02b4f9b7e
--- /dev/null
+++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_group_chart.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { FC } from "react";
+
+import GroupTimeline from "@/app/(main)/questions/[id]/components/group_timeline";
+import { GroupOfQuestionsPost, PostStatus } from "@/types/post";
+import { QuestionWithNumericForecasts } from "@/types/question";
+import { getPostDrivenTime } from "@/utils/questions/helpers";
+
+import { useListChartExpanded } from "./consumer_list_chart_shell";
+
+type Props = {
+ post: GroupOfQuestionsPost
;
+ 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
new file mode 100644
index 0000000000..68c52ab4fc
--- /dev/null
+++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/consumer_list_chart_shell.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { createContext, ReactNode, useContext, useMemo, useState } from "react";
+
+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);
+
+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
+ );
+
+ // Memoize so isExpanded changes don't re-render context consumers (e.g. the chart).
+ const contextValue = useMemo(
+ () => ({ setIsExpanded, hoveredChoiceName, setHoveredChoiceName }),
+ [hoveredChoiceName, setHoveredChoiceName, setIsExpanded]
+ );
+
+ return (
+
+
+
+ {listContent}
+
+
+ {chartContent}
+
+
+
+ );
+};
+
+export default ConsumerListChartShell;
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;
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/charts/group_chart.tsx b/front_end/src/components/charts/group_chart.tsx
index a00a87f399..0b11507936 100644
--- a/front_end/src/components/charts/group_chart.tsx
+++ b/front_end/src/components/charts/group_chart.tsx
@@ -90,6 +90,8 @@ type Props = {
onTimelineMarkerLeave?: (marker: GroupTimelineMarker) => 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 2154e2ca44..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
@@ -1,3 +1,9 @@
+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";
@@ -5,83 +11,83 @@ import cn from "@/utils/core/cn";
type Props = {
otherItemsCount: number;
- othersTotal?: number;
expanded?: boolean;
onExpand?: () => void;
- hideOthersValue?: boolean;
+ onCollapse?: () => void;
compact?: boolean;
+ buttonVariant?: "primary" | "minimal";
+ className?: string;
};
const ForecastCardWrapper: FC> = ({
otherItemsCount,
- othersTotal = 0,
expanded = false,
onExpand,
- hideOthersValue = false,
+ onCollapse,
compact = false,
+ buttonVariant = "primary",
+ className,
children,
}) => {
const t = useTranslations();
- const showRow = !expanded && otherItemsCount > 0;
+ 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]",
+ "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 (
{children}
- {showRow && (
+ {showExpandRow &&
+ (isMinimal ? (
+
+
+ {t("otherWithCount", { count: otherItemsCount })}
+
+ ) : (
+
+ ))}
+
+ {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..f33590faf7 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;
@@ -37,10 +40,13 @@ const ForecastChoiceBar: FC = ({
displayedResolution,
resolution,
color,
- isBordered = false,
+ isBordered = true,
unit,
forceColorful = false,
compact = false,
+ onMouseEnter,
+ onMouseLeave,
+ className,
}) => {
const t = useTranslations();
const { getThemeColor } = useAppTheme();
@@ -50,13 +56,20 @@ const ForecastChoiceBar: FC = ({
const isResolutionSuccessful = isSuccessfullyResolved(resolution);
return (
= ({
= ({
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/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 578bafd4c0..e073856a05 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,9 +4,11 @@ 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";
+import cn from "@/utils/core/cn";
import { getPredictionDisplayValue } from "@/utils/formatters/prediction";
import { scaleInternalLocation } from "@/utils/math";
import { generateChoiceItemsFromGroupQuestions } from "@/utils/questions/choices";
@@ -22,12 +24,21 @@ type Props = {
post: PostWithForecasts;
forceColorful?: boolean;
compact?: boolean;
+ buttonVariant?: "primary" | "minimal";
+ fillHeight?: boolean;
};
-const NumericForecastCard: FC = ({ post, forceColorful, compact }) => {
+const NumericForecastCard: FC = ({
+ post,
+ forceColorful,
+ compact,
+ buttonVariant,
+ fillHeight = false,
+}) => {
const locale = useLocale();
const t = useTranslations();
const [expanded, setExpanded] = useState(false);
+ const { setIsExpanded, setHoveredChoiceName } = useListChartExpanded();
if (!isGroupOfQuestionsPost(post)) {
return null;
@@ -58,10 +69,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,18 +87,14 @@ const NumericForecastCard: FC = ({ post, forceColorful, compact }) => {
const maxScaledValue = Math.max(...scaledValues);
const minScaledValue = Math.min(...scaledValues);
- return (
- setExpanded(true)}
- hideOthersValue
- compact={compact}
- >
- {visibleChoices.map(
- ({
+ const renderBars = (
+ choices: typeof sortedChoices,
+ stretchBars = false,
+ hoverUpTo = Infinity
+ ) =>
+ choices.map(
+ (
+ {
closeTime,
aggregationValues,
scaling,
@@ -100,57 +105,98 @@ const NumericForecastCard: FC = ({ post, forceColorful, compact }) => {
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"),
+ },
+ index
+ ) => {
+ 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 (
+ setHoveredChoiceName(choice) : undefined
}
- );
-
- const scaledChoiceValue = !isNil(rawChoiceValue)
- ? scaleInternalLocation(rawChoiceValue, normalizedScaling)
- : NaN;
-
- const relativeWidth = !isNil(resolution)
- ? 100
- : calculateRelativeWidth({
- scaledChoiceValue,
- maxScaledValue,
- minScaledValue,
- });
-
- return (
-
- );
- }
+ 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}
+ >
+ {renderBars(collapsedChoices, effectiveFillHeight)}
+
+
+ {expanded && (
+
+ {
+ setExpanded(false);
+ setIsExpanded(false);
+ }}
+ compact={compact}
+ buttonVariant={buttonVariant}
+ >
+ {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 64891034d6..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
@@ -3,9 +3,11 @@
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";
+import cn from "@/utils/core/cn";
import { getPredictionDisplayValue } from "@/utils/formatters/prediction";
import {
generateChoiceItemsFromGroupQuestions,
@@ -23,23 +25,23 @@ type Props = {
post: PostWithForecasts;
forceColorful?: boolean;
compact?: boolean;
+ buttonVariant?: "primary" | "minimal";
+ fillHeight?: boolean;
};
const PercentageForecastCard: FC = ({
post,
forceColorful,
compact,
+ buttonVariant,
+ fillHeight = false,
}) => {
const locale = useLocale();
const t = useTranslations();
const [expanded, setExpanded] = useState(false);
+ 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()
@@ -87,43 +89,66 @@ 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 othersTotal = isMC
- ? Math.max(0, Math.min(100, 100 - Math.round(visibleSumMC)))
- : 0;
+ const renderBars = (choices: typeof allChoices, stretchBars = false) =>
+ choices.map((choice) => (
+
+ ));
+
+ // Only fill height when all items are visible (no expand button).
+ const effectiveFillHeight = fillHeight && hiddenCount === 0;
return (
- setExpanded(true)}
- hideOthersValue={isGroupBinary}
- compact={compact}
+
- {visible.map((choice) => (
-
- ))}
-
+
{
+ setExpanded(true);
+ setIsExpanded(true);
+ }}
+ compact={compact}
+ buttonVariant={buttonVariant}
+ className={effectiveFillHeight ? "flex-1" : undefined}
+ >
+ {renderBars(collapsedChoices, effectiveFillHeight)}
+
+ {expanded && (
+
+ {
+ setExpanded(false);
+ setIsExpanded(false);
+ }}
+ compact={compact}
+ buttonVariant={buttonVariant}
+ >
+ {renderBars(allChoices)}
+
+
+ )}
+
);
};
+
function generateChoiceItems(
post: PostWithForecasts,
visibleChoicesCount: number,
@@ -150,4 +175,5 @@ function generateChoiceItems(
}
return [];
}
+
export default PercentageForecastCard;
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(
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}
/>
);
};