Skip to content

Commit c39da3b

Browse files
authored
Consumer side-by-side shell, row restyle, and expand/collapse for N-row question types (#4712)
* feat: add ConsumerListChartShell for N-row consumer side-by-side layout * fix: remove extra top/bottom margin on group prediction inside the side-by-side shell and fix styles in ConsumerListChartShell * feat: restyle consumer MC and group forecast rows with chevron expand/collapse and expanded panel * feat: restyle sidebar expand button with ellipsis/border variant and hide legend in consumer MC and binary-group chart views * fix: restore isBordered prop in ForecastChoiceBar and show TimeSeriesChart in fan graph left panel * fix: stretch group forecast card to full width and use minimal expand button in feed * feat: add consumer side-by-side shell for continuous numeric group questions with proportional bars, hover-synced chart highlight, and endpoint dots * fix: scope bar hover effects to wired handlers, fill height for all bar-row types, cover discrete groups, and prevent expand height jump * fix: render minimal expand indicator as div to fix accessibility violation * fix: remove expand button from DOM and clamp hiddenCount when overlay is open
1 parent 97b2945 commit c39da3b

19 files changed

Lines changed: 592 additions & 235 deletions

File tree

front_end/src/app/(main)/questions/[id]/components/group_timeline.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,14 @@ type Props = QuestionsDataProps & {
5252
embedMode?: boolean;
5353
withLegend?: boolean;
5454
className?: string;
55+
externalHighlightedChoice?: string | null;
5556
prioritizeOpen?: boolean;
5657
timelineMarkers?: GroupTimelineMarker[];
5758
activeTimelineMarkerId?: string | null;
5859
onTimelineMarkerEnter?: (marker: GroupTimelineMarker) => void;
5960
onTimelineMarkerLeave?: (marker: GroupTimelineMarker) => void;
61+
withHighlightArea?: boolean;
62+
withHighlightEndpoint?: boolean;
6063
};
6164

6265
/**
@@ -86,6 +89,9 @@ const GroupTimeline: FC<Props> = ({
8689
activeTimelineMarkerId,
8790
onTimelineMarkerEnter,
8891
onTimelineMarkerLeave,
92+
externalHighlightedChoice,
93+
withHighlightArea,
94+
withHighlightEndpoint,
8995
}) => {
9096
const t = useTranslations();
9197
const { user } = useAuth();
@@ -168,6 +174,17 @@ const GroupTimeline: FC<Props> = ({
168174
setChoiceItems(generateList(questions, group, preselectedQuestionId));
169175
}, [questions, preselectedQuestionId, generateList, group]);
170176

177+
// apply external highlight from parent (e.g. consumer row hover)
178+
useEffect(() => {
179+
if (externalHighlightedChoice === undefined) return;
180+
setChoiceItems((prev) =>
181+
prev.map((item) => ({
182+
...item,
183+
highlighted: item.choice === externalHighlightedChoice,
184+
}))
185+
);
186+
}, [externalHighlightedChoice]);
187+
171188
const [cursorTimestamp, _tooltipDate, handleCursorChange] =
172189
useTimestampCursor(timestamps);
173190
const tooltipChoices = useMemo<ChoiceTooltipItem[]>(() => {
@@ -335,6 +352,8 @@ const GroupTimeline: FC<Props> = ({
335352
activeTimelineMarkerId={activeTimelineMarkerId}
336353
onTimelineMarkerEnter={onTimelineMarkerEnter}
337354
onTimelineMarkerLeave={onTimelineMarkerLeave}
355+
withHighlightArea={withHighlightArea}
356+
withHighlightEndpoint={withHighlightEndpoint}
338357
/>
339358
);
340359
};

front_end/src/app/(main)/questions/[id]/components/multiple_choices_chart_view/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ type Props = {
4949
activeTimelineMarkerId?: string | null;
5050
onTimelineMarkerEnter?: (marker: GroupTimelineMarker) => void;
5151
onTimelineMarkerLeave?: (marker: GroupTimelineMarker) => void;
52+
withHighlightArea?: boolean;
53+
withHighlightEndpoint?: boolean;
5254
};
5355

5456
const MultiChoicesChartView: FC<Props> = ({
@@ -82,6 +84,8 @@ const MultiChoicesChartView: FC<Props> = ({
8284
activeTimelineMarkerId,
8385
onTimelineMarkerEnter,
8486
onTimelineMarkerLeave,
87+
withHighlightArea = true,
88+
withHighlightEndpoint = false,
8589
}) => {
8690
const { user } = useAuth();
8791
const isInteracted = useRef(false);
@@ -243,6 +247,8 @@ const MultiChoicesChartView: FC<Props> = ({
243247
forceAutoZoom: isInteracted.current,
244248
forecastAvailability,
245249
attachRef,
250+
withHighlightArea,
251+
withHighlightEndpoint,
246252
} as const;
247253

248254
return (

front_end/src/app/(main)/questions/[id]/components/question_page_shell/index.tsx

Lines changed: 103 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { FC, Fragment, ReactNode, useEffect } from "react";
55

66
import useCoherenceLinksContext from "@/app/(main)/components/coherence_links_provider";
77
import { PostStatusBox } from "@/app/(main)/questions/[id]/components/post_status_box";
8+
import NumericForecastCard from "@/components/consumer_post_card/group_forecast_card/numeric_forecast_card";
9+
import PercentageForecastCard from "@/components/consumer_post_card/group_forecast_card/percentage_forecast_card";
10+
import TimeSeriesChart from "@/components/consumer_post_card/time_series_chart";
811
import UpcomingCP from "@/components/consumer_post_card/upcoming_cp";
912
import DetailedGroupCard from "@/components/detailed_question_card/detailed_group_card";
1013
import DetailedQuestionCard from "@/components/detailed_question_card/detailed_question_card";
@@ -15,14 +18,15 @@ import { useHideCP } from "@/contexts/cp_context";
1518
import { useContentTranslatedBannerContext } from "@/contexts/translations_banner_context";
1619
import {
1720
GroupOfQuestionsGraphType,
21+
GroupOfQuestionsPost,
1822
PostStatus,
1923
PostWithForecasts,
2024
QuestionStatus,
2125
} from "@/types/post";
2226
import { TournamentType } from "@/types/projects";
23-
import { QuestionType } from "@/types/question";
24-
import cn from "@/utils/core/cn";
27+
import { QuestionType, QuestionWithNumericForecasts } from "@/types/question";
2528
import { getQuestionForecastAvailability } from "@/utils/questions/forecastAvailability";
29+
import { sortGroupPredictionOptions } from "@/utils/questions/groupOrdering";
2630
import {
2731
checkGroupOfQuestionsPostType,
2832
isContinuousQuestion,
@@ -40,15 +44,17 @@ import PostScoreData from "../post_score_data";
4044
import { QuestionLayoutProvider } from "../question_layout/question_layout_context";
4145
import { QuestionVariantComposer } from "../question_variant_composer";
4246
import ActionRow from "../question_view/action_row";
47+
import ConsumerGroupChart from "../question_view/consumer_question_view/consumer_group_chart";
48+
import ConsumerListChartShell from "../question_view/consumer_question_view/consumer_list_chart_shell";
4349
import ConsumerQuestionPrediction from "../question_view/consumer_question_view/prediction";
4450
import QuestionTimeline from "../question_view/consumer_question_view/timeline";
4551
import QuestionHeaderCPStatus from "../question_view/forecaster_question_view/question_header/question_header_cp_status";
4652
import RevealCPButton from "../reveal_cp_button";
4753

4854
const baseSectionClassName =
49-
"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";
55+
"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";
5056

51-
const mainSectionClassName = `${baseSectionClassName} bg-gray-0 dark:bg-gray-0-dark`;
57+
const mainSectionClassName = `${baseSectionClassName} z-10 bg-gray-0 dark:bg-gray-0-dark`;
5258
const commentSectionClassName = `${baseSectionClassName} bg-blue-100 dark:bg-gray-0-dark`;
5359

5460
type ShellProps = {
@@ -147,9 +153,6 @@ export const ConsumerShell: FC<{
147153
const isMultipleChoice = isMultipleChoicePost(postData);
148154
const isNonFanGroup = isGroupOfQuestionsPost(postData) && !isFanGraph;
149155

150-
const reverseOrder =
151-
(isMultipleChoice || isGroupOfQuestionsPost(postData)) && !isDateGroup;
152-
153156
const isContinuousSingleQuestion =
154157
isQuestionPost(postData) && isContinuousQuestion(postData.question);
155158

@@ -158,24 +161,36 @@ export const ConsumerShell: FC<{
158161
!isContinuousSingleQuestion &&
159162
!isMultipleChoice;
160163

164+
const isNRowBody =
165+
isMultipleChoice || (isNonFanGroup && !isDateGroup) || isFanGraph;
166+
167+
const isContinuousNumericGroup =
168+
isNonFanGroup &&
169+
!isDateGroup &&
170+
!isMultipleChoice &&
171+
(checkGroupOfQuestionsPostType(postData, QuestionType.Numeric) ||
172+
checkGroupOfQuestionsPostType(postData, QuestionType.Discrete));
173+
161174
const binaryForecastAvailability =
162175
isBinarySingleQuestion && isQuestionPost(postData)
163176
? getQuestionForecastAvailability(postData.question)
164177
: null;
165178

166-
const showSideBySide =
167-
isMultipleChoice ||
168-
isNonFanGroup ||
169-
isBinarySingleQuestion ||
170-
isContinuousSingleQuestion;
171-
172179
const showClosedMessageMultipleChoice =
173180
isMultipleChoicePost(postData) &&
174181
postData.question.status === QuestionStatus.CLOSED;
175182

176183
const showClosedMessageFanGraph =
177184
isFanGraph && postData.status === PostStatus.CLOSED;
178185

186+
const fanGraphQuestions = isFanGraph
187+
? sortGroupPredictionOptions(
188+
(postData.group_of_questions?.questions ??
189+
[]) as QuestionWithNumericForecasts[],
190+
postData.group_of_questions
191+
)
192+
: null;
193+
179194
const questionLinkAggregates =
180195
aggregateCoherenceLinks?.data.filter(isDisplayableQuestionLink) ?? [];
181196
const hasKeyFactors = (postData.key_factors?.length ?? 0) > 0;
@@ -218,21 +233,8 @@ export const ConsumerShell: FC<{
218233
{t("predictionClosedMessage")}
219234
</p>
220235
)}
221-
<div
222-
className={cn(
223-
"flex flex-col",
224-
reverseOrder &&
225-
!isMultipleChoice &&
226-
!isNonFanGroup &&
227-
"flex-col-reverse",
228-
showSideBySide &&
229-
cn("sm:flex-row sm:items-center", {
230-
"sm:gap-0 md:gap-8": isBinarySingleQuestion,
231-
"sm:gap-8": !isBinarySingleQuestion,
232-
})
233-
)}
234-
>
235-
{isBinarySingleQuestion && isQuestionPost(postData) ? (
236+
{isBinarySingleQuestion && isQuestionPost(postData) ? (
237+
<div className="flex flex-col sm:flex-row sm:items-center sm:gap-0 md:gap-8">
236238
<div className="order-1 flex w-64 flex-col items-center justify-center gap-[18px] self-center sm:self-stretch">
237239
{hideCP ? (
238240
<RevealCPButton />
@@ -247,47 +249,85 @@ export const ConsumerShell: FC<{
247249
/>
248250
)}
249251
</div>
250-
) : (
251-
<div
252-
className={cn(
253-
showSideBySide && !isDateGroup ? "order-1" : undefined,
254-
isContinuousSingleQuestion && "md:hidden",
255-
showSideBySide &&
256-
!isDateGroup &&
257-
!isContinuousSingleQuestion &&
258-
"sm:max-w-[200px]",
259-
hideCP &&
260-
!isContinuousSingleQuestion &&
261-
(isDateGroup || isFanGraph) &&
262-
"flex w-full justify-center"
263-
)}
264-
>
265-
{hideCP && !isContinuousSingleQuestion ? (
266-
<RevealCPButton />
267-
) : (
268-
<ConsumerQuestionPrediction postData={postData} />
269-
)}
252+
<QuestionTimeline
253+
postData={postData}
254+
keyFactors={postData.key_factors}
255+
isConsumerView
256+
preselectedGroupQuestionId={preselectedGroupQuestionId}
257+
className="order-2 mt-0 hidden flex-1 sm:block"
258+
/>
259+
</div>
260+
) : isContinuousSingleQuestion ? (
261+
<div className="flex flex-col sm:flex-row sm:items-center sm:gap-8">
262+
<div className="order-1 md:hidden">
263+
<ConsumerQuestionPrediction postData={postData} />
270264
</div>
271-
)}
272-
{!isFanGraph && !isDateGroup && (
273265
<QuestionTimeline
274266
postData={postData}
275267
keyFactors={postData.key_factors}
276-
isConsumerView={true}
268+
isConsumerView={false}
277269
preselectedGroupQuestionId={preselectedGroupQuestionId}
278-
className={cn(
279-
"hidden sm:block",
280-
showSideBySide && "order-2 mt-0 flex-1",
281-
isContinuousSingleQuestion && "mt-0"
282-
)}
270+
className="order-2 mt-0 hidden flex-1 sm:block"
283271
/>
284-
)}
285-
{showClosedMessageFanGraph && (
286-
<p className="my-8 text-center text-sm leading-[20px] text-gray-700 dark:text-gray-700-dark">
287-
{t("predictionClosedMessage")}
288-
</p>
289-
)}
290-
</div>
272+
</div>
273+
) : isNRowBody ? (
274+
<>
275+
<ConsumerListChartShell
276+
stretchListContent={!hideCP && !isFanGraph}
277+
listContent={
278+
hideCP ? (
279+
<RevealCPButton />
280+
) : isFanGraph && fanGraphQuestions ? (
281+
<TimeSeriesChart
282+
questions={fanGraphQuestions}
283+
variant="colorful"
284+
height={180}
285+
/>
286+
) : isContinuousNumericGroup ? (
287+
<NumericForecastCard post={postData} fillHeight />
288+
) : (
289+
<PercentageForecastCard
290+
post={postData}
291+
forceColorful
292+
fillHeight
293+
/>
294+
)
295+
}
296+
chartContent={
297+
isFanGraph ? (
298+
<DetailedGroupCard
299+
post={
300+
postData as GroupOfQuestionsPost<QuestionWithNumericForecasts>
301+
}
302+
preselectedQuestionId={preselectedGroupQuestionId}
303+
/>
304+
) : isContinuousNumericGroup ? (
305+
<ConsumerGroupChart
306+
post={
307+
postData as GroupOfQuestionsPost<QuestionWithNumericForecasts>
308+
}
309+
preselectedQuestionId={preselectedGroupQuestionId}
310+
/>
311+
) : (
312+
<QuestionTimeline
313+
postData={postData}
314+
keyFactors={postData.key_factors}
315+
isConsumerView
316+
preselectedGroupQuestionId={preselectedGroupQuestionId}
317+
className="mt-0"
318+
/>
319+
)
320+
}
321+
/>
322+
{showClosedMessageFanGraph && (
323+
<p className="my-8 text-center text-sm leading-[20px] text-gray-700 dark:text-gray-700-dark">
324+
{t("predictionClosedMessage")}
325+
</p>
326+
)}
327+
</>
328+
) : (
329+
<ConsumerQuestionPrediction postData={postData} />
330+
)}
291331
</div>
292332
{shouldShowKeyFactorsSection && (
293333
<div className="order-3 sm:order-none">
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"use client";
2+
3+
import { FC } from "react";
4+
5+
import GroupTimeline from "@/app/(main)/questions/[id]/components/group_timeline";
6+
import { GroupOfQuestionsPost, PostStatus } from "@/types/post";
7+
import { QuestionWithNumericForecasts } from "@/types/question";
8+
import { getPostDrivenTime } from "@/utils/questions/helpers";
9+
10+
import { useListChartExpanded } from "./consumer_list_chart_shell";
11+
12+
type Props = {
13+
post: GroupOfQuestionsPost<QuestionWithNumericForecasts>;
14+
preselectedQuestionId?: number;
15+
};
16+
17+
const ConsumerGroupChart: FC<Props> = ({ post, preselectedQuestionId }) => {
18+
const { hoveredChoiceName, setHoveredChoiceName } = useListChartExpanded();
19+
const { open_time, actual_close_time, scheduled_close_time, status } = post;
20+
const refCloseTime = actual_close_time ?? scheduled_close_time;
21+
22+
return (
23+
<div onMouseLeave={() => setHoveredChoiceName(null)}>
24+
<GroupTimeline
25+
group={post.group_of_questions}
26+
actualCloseTime={getPostDrivenTime(refCloseTime)}
27+
openTime={getPostDrivenTime(open_time)}
28+
isClosed={status === PostStatus.CLOSED}
29+
preselectedQuestionId={preselectedQuestionId}
30+
withLegend={false}
31+
withHighlightArea={false}
32+
withHighlightEndpoint
33+
externalHighlightedChoice={hoveredChoiceName}
34+
/>
35+
</div>
36+
);
37+
};
38+
39+
export default ConsumerGroupChart;

0 commit comments

Comments
 (0)