From 40f7dc8fcb4fb38154287ee482673b44f5162e59 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Tue, 9 Jun 2026 22:08:44 -0600 Subject: [PATCH 1/6] Add uniform participant timeline mode --- .../replay/AllTasksTimeline.tsx | 87 +++++++++++++------ .../individualStudy/replay/SingleTask.tsx | 26 +++++- .../replay/timelineLayout.spec.ts | 30 +++++++ .../individualStudy/replay/timelineLayout.ts | 29 +++++++ .../individualStudy/table/TableView.tsx | 13 ++- 5 files changed, 157 insertions(+), 28 deletions(-) create mode 100644 src/analysis/individualStudy/replay/timelineLayout.spec.ts create mode 100644 src/analysis/individualStudy/replay/timelineLayout.ts diff --git a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx index 4ee3927254..00c333bf62 100644 --- a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx +++ b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx @@ -3,7 +3,7 @@ import { } from 'react'; import * as d3 from 'd3'; import { - Center, Stack, Tooltip, Text, + Box, Center, Stack, Tooltip, Text, } from '@mantine/core'; import { ParticipantData } from '../../../storage/types'; import { SingleTaskLabelLines } from './SingleTaskLabelLines'; @@ -17,6 +17,10 @@ import { orderedReplayAnswerEntries, ReplayTaskOrder, } from './taskOrdering'; +import { + getUniformTimelineMetrics, + TimelineMode, +} from './timelineLayout'; const LABEL_GAP = 25; const CHARACTER_SIZE = 8; @@ -26,8 +30,8 @@ const margin = { }; export function AllTasksTimeline({ - participantData, width, studyId, studyConfig, maxLength, taskOrder = 'sequence', -}: { participantData: ParticipantData, width: number, studyId: string, studyConfig: StudyConfig | undefined, maxLength: number | undefined, taskOrder?: ReplayTaskOrder }) { + participantData, width, studyId, studyConfig, maxLength, taskOrder = 'sequence', timelineMode = 'time', +}: { participantData: ParticipantData, width: number, studyId: string, studyConfig: StudyConfig | undefined, maxLength: number | undefined, taskOrder?: ReplayTaskOrder, timelineMode?: TimelineMode }) { const [hoveredTaskIdentifier, setHoveredTaskIdentifier] = useState(null); const percentComplete = useMemo(() => { @@ -36,26 +40,43 @@ export function AllTasksTimeline({ return (Object.entries(participantData.answers).length - incompleteEntries.length) / (Object.entries(participantData.answers).length); }, [participantData.answers]); + const timelineWidth = useMemo(() => { + if (timelineMode === 'time') { + return width; + } + + return getUniformTimelineMetrics({ + availableWidth: width, + taskCount: Object.entries(participantData.answers || {}).length, + margin, + }).timelineWidth; + }, [participantData.answers, timelineMode, width]); + const xScale = useMemo(() => { + if (timelineMode === 'uniform') { + return d3.scaleLinear([margin.left, timelineWidth - margin.right]).domain([0, Math.max(Object.entries(participantData.answers || {}).length, 1)]).clamp(true); + } + const allStartTimes = Object.values(participantData.answers || {}).filter((answer) => answer.startTime).map((answer) => [answer.startTime, answer.endTime]).flat(); const extent = d3.extent(allStartTimes) as [number, number]; - const scale = d3.scaleLinear([margin.left, (width * percentComplete - (percentComplete !== 1 ? 0 : margin.right))]).domain([extent[0], maxLength ? extent[0] + maxLength : extent[1]]).clamp(true); + const scale = d3.scaleLinear([margin.left, (timelineWidth * percentComplete - (percentComplete !== 1 ? 0 : margin.right))]).domain([extent[0], maxLength ? extent[0] + maxLength : extent[1]]).clamp(true); return scale; - }, [maxLength, participantData.answers, percentComplete, width]); + }, [maxLength, participantData.answers, percentComplete, timelineMode, timelineWidth]); const incompleteXScale = useMemo(() => { - const scale = d3.scaleLinear([width * percentComplete, width - margin.right]).domain([0, Object.entries(participantData.answers || {}).filter((e) => e[1].startTime === 0).length]).clamp(true); + const scale = d3.scaleLinear([timelineWidth * percentComplete, timelineWidth - margin.right]).domain([0, Object.entries(participantData.answers || {}).filter((e) => e[1].startTime === 0).length]).clamp(true); return scale; - }, [participantData.answers, percentComplete, width]); + }, [participantData.answers, percentComplete, timelineWidth]); const maxHeight = useMemo(() => { const incompleteEntries = Object.entries(participantData.answers || {}).filter((e) => e[1].startTime === 0).sort(compareReplayAnswerEntries); const incompleteEntryIndexes = new Map(incompleteEntries.map(([identifier], index) => [identifier, index])); const sortedEntries = orderedReplayAnswerEntries(participantData.answers, taskOrder); + const entryIndexes = new Map(sortedEntries.map(([identifier], index) => [identifier, index])); let currentHeight = 0; let _maxHeight = 0; @@ -65,10 +86,10 @@ export function AllTasksTimeline({ // Check if the previous entry overlaps with the current entry const prev = i > 0 ? sortedEntries[i - currentHeight - 1] : null; - const prevScale = prev && prev[1].startTime ? xScale : incompleteXScale; - const prevStart = prev ? prev[1].startTime ? prev[1].startTime : incompleteEntryIndexes.get(prev[0]) ?? 0 : 0; - const scale = answer.startTime === 0 ? incompleteXScale : xScale; - const scaleStart = answer.startTime ? answer.startTime : incompleteEntryIndexes.get(identifier) ?? 0; + const prevScale = timelineMode === 'uniform' || (prev && prev[1].startTime) ? xScale : incompleteXScale; + const prevStart = prev ? timelineMode === 'uniform' ? entryIndexes.get(prev[0]) ?? 0 : prev[1].startTime ? prev[1].startTime : incompleteEntryIndexes.get(prev[0]) ?? 0 : 0; + const scale = timelineMode === 'uniform' || answer.startTime !== 0 ? xScale : incompleteXScale; + const scaleStart = timelineMode === 'uniform' ? entryIndexes.get(identifier) ?? 0 : answer.startTime ? answer.startTime : incompleteEntryIndexes.get(identifier) ?? 0; // If the previous entry overlaps with the current entry , increase the height if (prev && prev[0].length * (CHARACTER_SIZE + 1) + prevScale(prevStart) > scale(scaleStart)) { @@ -83,7 +104,7 @@ export function AllTasksTimeline({ }); return (_maxHeight + 1) * LABEL_GAP + margin.top + margin.bottom; - }, [incompleteXScale, participantData.answers, taskOrder, xScale]); + }, [incompleteXScale, participantData.answers, taskOrder, timelineMode, xScale]); const conditionParam = useMemo(() => { const parsedConditions = parseConditionParam(participantData.conditions ?? participantData.searchParams?.condition); @@ -97,19 +118,21 @@ export function AllTasksTimeline({ const incompleteEntries = Object.entries(participantData.answers || {}).filter((e) => e[1].startTime === 0).sort(compareReplayAnswerEntries); const incompleteEntryIndexes = new Map(incompleteEntries.map(([identifier], index) => [identifier, index])); const combined = orderedReplayAnswerEntries(participantData.answers, taskOrder); + const entryIndexes = new Map(combined.map(([identifier], index) => [identifier, index])); const allElements = combined.map((entry, i) => { - const scale = entry[1].startTime === 0 ? incompleteXScale : xScale; + const scale = timelineMode === 'uniform' || entry[1].startTime !== 0 ? xScale : incompleteXScale; const [identifier, answer] = entry; const prev = i > 0 ? combined[i - currentHeight - 1] : null; - const prevScale = prev && prev[1].startTime ? xScale : incompleteXScale; - const prevStart = prev ? prev[1].startTime ? prev[1].startTime : incompleteEntryIndexes.get(prev[0]) ?? 0 : 0; + const prevScale = timelineMode === 'uniform' || (prev && prev[1].startTime) ? xScale : incompleteXScale; + const prevStart = prev ? timelineMode === 'uniform' ? entryIndexes.get(prev[0]) ?? 0 : prev[1].startTime ? prev[1].startTime : incompleteEntryIndexes.get(prev[0]) ?? 0 : 0; const incompleteEntryIndex = incompleteEntryIndexes.get(identifier) ?? 0; - const scaleStart = answer.startTime ? answer.startTime : incompleteEntryIndex; - const scaleEnd = answer.endTime > 0 ? answer.endTime : incompleteEntryIndex + 1; + const uniformEntryIndex = entryIndexes.get(identifier) ?? 0; + const scaleStart = timelineMode === 'uniform' ? uniformEntryIndex : answer.startTime ? answer.startTime : incompleteEntryIndex; + const scaleEnd = timelineMode === 'uniform' ? uniformEntryIndex + 1 : answer.endTime > 0 ? answer.endTime : incompleteEntryIndex + 1; if (prev && prev[0].length * (CHARACTER_SIZE + 1) + prevScale(prevStart) > scale(scaleStart)) { currentHeight += 1; @@ -163,10 +186,14 @@ export function AllTasksTimeline({ }); return allElements; - }, [participantData.answers, participantData.participantId, incompleteXScale, xScale, studyConfig, maxHeight, studyId, conditionParam, hoveredTaskIdentifier, taskOrder]); + }, [participantData.answers, participantData.participantId, incompleteXScale, xScale, studyConfig, maxHeight, studyId, conditionParam, hoveredTaskIdentifier, taskOrder, timelineMode]); // Find entries of someone browsing away. Show them const browsedAway = useMemo(() => { + if (timelineMode === 'uniform') { + return []; + } + const sortedEntries = Object.entries(participantData.answers || {}).sort((a, b) => a[1].startTime - b[1].startTime); return sortedEntries.map((entry) => { @@ -195,7 +222,7 @@ export function AllTasksTimeline({ browsedAwayList.map((browse, i) => ) ); }); - }, [xScale, maxHeight, participantData.answers]); + }, [xScale, maxHeight, participantData.answers, timelineMode]); const hoveredTask = tasks.find((task) => task.identifier === hoveredTaskIdentifier); const nonHoveredTasks = tasks.filter((task) => task.identifier !== hoveredTaskIdentifier); @@ -203,12 +230,22 @@ export function AllTasksTimeline({ return (
- setHoveredTaskIdentifier(null)} style={{ width, height: maxHeight, overflow: 'visible' }}> - {tasks.map((t) => t.line)} - {nonHoveredTasks.map((t) => t.label)} - {hoveredTask?.label} - {browsedAway} - + + setHoveredTaskIdentifier(null)} + style={{ + width: timelineWidth, + minWidth: timelineWidth, + height: maxHeight, + overflow: 'visible', + }} + > + {tasks.map((t) => t.line)} + {nonHoveredTasks.map((t) => t.label)} + {hoveredTask?.label} + {browsedAway} + +
diff --git a/src/analysis/individualStudy/replay/SingleTask.tsx b/src/analysis/individualStudy/replay/SingleTask.tsx index 6de5493ac2..0546a582bf 100644 --- a/src/analysis/individualStudy/replay/SingleTask.tsx +++ b/src/analysis/individualStudy/replay/SingleTask.tsx @@ -18,6 +18,26 @@ const LABEL_MAX_WIDTH = 160; const ICON_SIZE = 14; const ICON_GAP = 2; +function taskFill({ + incomplete, + hasCorrect, + isCorrect, +}: { + incomplete: boolean, + hasCorrect: boolean, + isCorrect: boolean, +}) { + if (incomplete) { + return '#ffe8cc'; + } + + if (!hasCorrect) { + return '#dee2e6'; + } + + return isCorrect ? '#40c057' : '#fa5252'; +} + export function SingleTask({ xScale, identifier, @@ -92,8 +112,10 @@ export function SingleTask({ style={{ cursor: 'pointer' }} > { + test('fills available width when tasks can be wider than the minimum', () => { + const metrics = getUniformTimelineMetrics({ + availableWidth: 640, + taskCount: 5, + margin: { left: 20, right: 20 }, + }); + + expect(metrics.timelineWidth).toBe(640); + expect(metrics.taskWidth).toBe(120); + }); + + test('uses the minimum task width and expands the timeline when needed', () => { + const metrics = getUniformTimelineMetrics({ + availableWidth: 200, + taskCount: 6, + margin: { left: 20, right: 20 }, + }); + + expect(metrics.timelineWidth).toBe(328); + expect(metrics.taskWidth).toBe(48); + }); +}); diff --git a/src/analysis/individualStudy/replay/timelineLayout.ts b/src/analysis/individualStudy/replay/timelineLayout.ts new file mode 100644 index 0000000000..147b10a4fc --- /dev/null +++ b/src/analysis/individualStudy/replay/timelineLayout.ts @@ -0,0 +1,29 @@ +export type TimelineMode = 'time' | 'uniform'; + +export const UNIFORM_TASK_MIN_WIDTH = 48; + +type TimelineMargin = { + left: number; + right: number; +}; + +export function getUniformTimelineMetrics({ + availableWidth, + taskCount, + margin, + minTaskWidth = UNIFORM_TASK_MIN_WIDTH, +}: { + availableWidth: number; + taskCount: number; + margin: TimelineMargin; + minTaskWidth?: number; +}) { + const safeTaskCount = Math.max(taskCount, 1); + const availableInnerWidth = Math.max(0, availableWidth - margin.left - margin.right); + const timelineInnerWidth = Math.max(availableInnerWidth, safeTaskCount * minTaskWidth); + + return { + taskWidth: timelineInnerWidth / safeTaskCount, + timelineWidth: timelineInnerWidth + margin.left + margin.right, + }; +} diff --git a/src/analysis/individualStudy/table/TableView.tsx b/src/analysis/individualStudy/table/TableView.tsx index 1220a9329f..19918be12c 100644 --- a/src/analysis/individualStudy/table/TableView.tsx +++ b/src/analysis/individualStudy/table/TableView.tsx @@ -20,6 +20,7 @@ import { ParticipantRejectModal } from '../ParticipantRejectModal'; import { participantName } from '../../../utils/participantName'; import { AllTasksTimeline } from '../replay/AllTasksTimeline'; import { ReplayTaskOrder } from '../replay/taskOrdering'; +import { TimelineMode } from '../replay/timelineLayout'; import { youtubeReadableDuration } from '../../../utils/humanReadableDuration'; import { getSequenceFlatMap } from '../../../utils/getSequenceFlatMap'; import { MetaCell } from './MetaCell'; @@ -56,6 +57,7 @@ export function TableView({ const { studyId } = useParams(); const [checked, setChecked] = useState({}); const [taskOrder, setTaskOrder] = useState('sequence'); + const [timelineMode, setTimelineMode] = useState('time'); useEffect(() => { const newSelectedParticipants = Object.keys(checked).filter((v) => checked[v]) @@ -277,7 +279,7 @@ export function TableView({ } return ( - + ); }, defaultColumn: { @@ -302,6 +304,15 @@ export function TableView({ /> + setTimelineMode(value as TimelineMode)} + data={[ + { value: 'time', label: 'Time' }, + { value: 'uniform', label: 'Uniform' }, + ]} + /> ), }); From 5b0e9ef012ae4a23e58e6a26a8ad5307b6b1023f Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Thu, 18 Jun 2026 10:21:13 -0600 Subject: [PATCH 2/6] Address participant timeline review feedback --- .../individualStudy/replay/AllTasksTimeline.tsx | 17 ++++++++--------- .../individualStudy/replay/SingleTask.tsx | 11 ++++++----- .../individualStudy/table/TableView.tsx | 4 ++-- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx index 00c333bf62..42869aa4b4 100644 --- a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx +++ b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx @@ -224,25 +224,24 @@ export function AllTasksTimeline({ }); }, [xScale, maxHeight, participantData.answers, timelineMode]); - const hoveredTask = tasks.find((task) => task.identifier === hoveredTaskIdentifier); - const nonHoveredTasks = tasks.filter((task) => task.identifier !== hoveredTaskIdentifier); - return ( -
- - +
+ + setHoveredTaskIdentifier(null)} style={{ width: timelineWidth, - minWidth: timelineWidth, height: maxHeight, + display: 'block', overflow: 'visible', }} > {tasks.map((t) => t.line)} - {nonHoveredTasks.map((t) => t.label)} - {hoveredTask?.label} + {tasks.map((t) => t.label)} {browsedAway} diff --git a/src/analysis/individualStudy/replay/SingleTask.tsx b/src/analysis/individualStudy/replay/SingleTask.tsx index 0546a582bf..736642f2a7 100644 --- a/src/analysis/individualStudy/replay/SingleTask.tsx +++ b/src/analysis/individualStudy/replay/SingleTask.tsx @@ -28,14 +28,14 @@ function taskFill({ isCorrect: boolean, }) { if (incomplete) { - return '#ffe8cc'; + return '#e9ecef'; } if (!hasCorrect) { - return '#dee2e6'; + return 'lightgray'; } - return isCorrect ? '#40c057' : '#fa5252'; + return isCorrect ? '#bfe8c6' : '#f3c1c1'; } export function SingleTask({ @@ -107,8 +107,9 @@ export function SingleTask({ return ( navigateToTrial(trialOrder, participantId, studyId, condition)} - onMouseEnter={onHover} - onMouseLeave={onHoverEnd} + onPointerEnter={onHover} + onPointerLeave={onHoverEnd} + onPointerCancel={onHoverEnd} style={{ cursor: 'pointer' }} > - setTimelineMode(value as TimelineMode)} data={[ @@ -313,6 +312,7 @@ export function TableView({ { value: 'uniform', label: 'Uniform' }, ]} /> + ), }); From 9e0738e023909accccbad1d5105119cc1b873c48 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Thu, 18 Jun 2026 10:30:30 -0600 Subject: [PATCH 3/6] Fix participant timeline detail width --- .../replay/AllTasksTimeline.tsx | 55 ++++++++++--------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx index 42869aa4b4..aa7b14e67a 100644 --- a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx +++ b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx @@ -3,8 +3,9 @@ import { } from 'react'; import * as d3 from 'd3'; import { - Box, Center, Stack, Tooltip, Text, + Box, Stack, Tooltip, Text, } from '@mantine/core'; +import { useResizeObserver } from '@mantine/hooks'; import { ParticipantData } from '../../../storage/types'; import { SingleTaskLabelLines } from './SingleTaskLabelLines'; import { SingleTask } from './SingleTask'; @@ -33,6 +34,8 @@ export function AllTasksTimeline({ participantData, width, studyId, studyConfig, maxLength, taskOrder = 'sequence', timelineMode = 'time', }: { participantData: ParticipantData, width: number, studyId: string, studyConfig: StudyConfig | undefined, maxLength: number | undefined, taskOrder?: ReplayTaskOrder, timelineMode?: TimelineMode }) { const [hoveredTaskIdentifier, setHoveredTaskIdentifier] = useState(null); + const [timelineContainerRef, { width: containerWidth }] = useResizeObserver(); + const availableWidth = Math.max(containerWidth || width, 0); const percentComplete = useMemo(() => { const incompleteEntries = Object.entries(participantData.answers || {}).filter((e) => e[1].startTime === 0); @@ -42,15 +45,15 @@ export function AllTasksTimeline({ const timelineWidth = useMemo(() => { if (timelineMode === 'time') { - return width; + return availableWidth; } return getUniformTimelineMetrics({ - availableWidth: width, + availableWidth, taskCount: Object.entries(participantData.answers || {}).length, margin, }).timelineWidth; - }, [participantData.answers, timelineMode, width]); + }, [availableWidth, participantData.answers, timelineMode]); const xScale = useMemo(() => { if (timelineMode === 'uniform') { @@ -225,28 +228,30 @@ export function AllTasksTimeline({ }, [xScale, maxHeight, participantData.answers, timelineMode]); return ( -
- - + setHoveredTaskIdentifier(null)} + style={{ + width: timelineWidth, + height: maxHeight, + display: 'block', + overflow: 'visible', }} - > - setHoveredTaskIdentifier(null)} - style={{ - width: timelineWidth, - height: maxHeight, - display: 'block', - overflow: 'visible', - }} - > - {tasks.map((t) => t.line)} - {tasks.map((t) => t.label)} - {browsedAway} - - - -
+ > + {tasks.map((t) => t.line)} + {tasks.map((t) => t.label)} + {browsedAway} + + ); } From e3f46b665189da40c8776d65ae2f13992f42bbdb Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Thu, 18 Jun 2026 10:39:21 -0600 Subject: [PATCH 4/6] Fix table detail panel width --- src/analysis/individualStudy/table/TableView.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/analysis/individualStudy/table/TableView.tsx b/src/analysis/individualStudy/table/TableView.tsx index f51a20105d..86ede4591e 100644 --- a/src/analysis/individualStudy/table/TableView.tsx +++ b/src/analysis/individualStudy/table/TableView.tsx @@ -270,6 +270,14 @@ export function TableView({ enablePagination: false, enableRowVirtualization: true, mantinePaperProps: { style: { maxHeight: '100%', display: 'flex', flexDirection: 'column' } }, + mantineDetailPanelProps: { + style: { + flex: '1 1 100%', + maxWidth: '100%', + minWidth: 0, + width: '100%', + }, + }, layoutMode: 'grid', renderDetailPanel: ({ row }) => { const r = row.original; From ec7181e22bd6178d72bbca2ae10f93aa7ef36d6c Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Thu, 23 Jul 2026 10:15:39 -0600 Subject: [PATCH 5/6] Fix timeline task label hover handling --- src/analysis/individualStudy/replay/AllTasksTimeline.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx index aa7b14e67a..817f207140 100644 --- a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx +++ b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx @@ -227,6 +227,8 @@ export function AllTasksTimeline({ }); }, [xScale, maxHeight, participantData.answers, timelineMode]); + const hoveredTask = tasks.find((task) => task.identifier === hoveredTaskIdentifier); + return ( setHoveredTaskIdentifier(null)} + onPointerLeave={() => setHoveredTaskIdentifier(null)} + onPointerCancel={() => setHoveredTaskIdentifier(null)} style={{ width: timelineWidth, height: maxHeight, @@ -248,8 +252,9 @@ export function AllTasksTimeline({ }} > {tasks.map((t) => t.line)} - {tasks.map((t) => t.label)} + {tasks.filter((t) => t.identifier !== hoveredTaskIdentifier).map((t) => t.label)} {browsedAway} + {hoveredTask?.label} From a652410caecabe65517c2f8920a9a65f02da1fa5 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Thu, 23 Jul 2026 10:19:03 -0600 Subject: [PATCH 6/6] Integrate uniform timeline with replay controls --- .../individualStudy/replay/SingleTask.tsx | 18 +++++---- .../replay/tests/AllTasksTimeline.spec.tsx | 37 +++++++++++++++++++ .../table/tests/TableView.spec.tsx | 9 ++++- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/analysis/individualStudy/replay/SingleTask.tsx b/src/analysis/individualStudy/replay/SingleTask.tsx index 736642f2a7..a0e16c7180 100644 --- a/src/analysis/individualStudy/replay/SingleTask.tsx +++ b/src/analysis/individualStudy/replay/SingleTask.tsx @@ -20,22 +20,24 @@ const ICON_GAP = 2; function taskFill({ incomplete, - hasCorrect, - isCorrect, + answerStatus, }: { incomplete: boolean, - hasCorrect: boolean, - isCorrect: boolean, + answerStatus: ComponentAnswerStatus | null, }) { if (incomplete) { return '#e9ecef'; } - if (!hasCorrect) { - return 'lightgray'; + if (answerStatus === 'correct') { + return '#bfe8c6'; + } + + if (answerStatus === 'incorrect') { + return '#f3c1c1'; } - return isCorrect ? '#bfe8c6' : '#f3c1c1'; + return 'lightgray'; } export function SingleTask({ @@ -114,7 +116,7 @@ export function SingleTask({ > ({ + Box: ({ children }: { children: ReactNode }) =>
{children}
, Center: ({ children }: { children: ReactNode }) =>
{children}
, Stack: ({ children }: { children: ReactNode }) =>
{children}
, Tooltip: ({ children, label }: { children: ReactNode; label?: ReactNode }) => ( @@ -137,6 +138,7 @@ describe('SingleTask', () => { , ); expect(html).toContain('icon-check'); + expect(html).toContain('fill="#bfe8c6"'); }); test('shows x icon for an incorrect response', () => { @@ -144,6 +146,7 @@ describe('SingleTask', () => { , ); expect(html).toContain('icon-x'); + expect(html).toContain('fill="#f3c1c1"'); }); test('shows an accessible grey checkmark for an unknown response', () => { @@ -153,6 +156,7 @@ describe('SingleTask', () => { expect(html).toContain('icon-check'); expect(html).toContain('var(--mantine-color-gray-6)'); expect(html).toContain('Response recorded; correctness not configured.'); + expect(html).toContain('fill="lightgray"'); }); test('shows microphone icon when hasAudio', () => { @@ -277,6 +281,39 @@ describe('AllTasksTimeline', () => { expect(html).toContain(' { + const participant = makeParticipant({ + answers: { + trial1_0: makeAnswer({ + windowEvents: [ + [t0 + 1_000, 'visibility', 'hidden'], + [t0 + 3_000, 'visibility', 'visible'], + ], + }), + trial2_1: makeAnswer({ + componentName: 'trial2', + trialOrder: '1_0', + startTime: t0 + 20_000, + endTime: t0 + 40_000, + }), + }, + }); + + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('width:136px'); + expect(html).not.toContain('Browsed away'); + }); + test('handles all tracked window event types without error', () => { const participant = makeParticipant({ answers: { diff --git a/src/analysis/individualStudy/table/tests/TableView.spec.tsx b/src/analysis/individualStudy/table/tests/TableView.spec.tsx index db8cd306b1..eb5583cd4c 100644 --- a/src/analysis/individualStudy/table/tests/TableView.spec.tsx +++ b/src/analysis/individualStudy/table/tests/TableView.spec.tsx @@ -58,7 +58,9 @@ vi.mock('@tabler/icons-react', () => ({ })); vi.mock('../../replay/AllTasksTimeline', () => ({ - AllTasksTimeline: ({ taskOrder }: { taskOrder: string }) =>
AllTasksTimeline
, + AllTasksTimeline: ({ taskOrder, timelineMode }: { taskOrder: string; timelineMode: string }) => ( +
AllTasksTimeline
+ ), })); vi.mock('../../ParticipantRejectModal', () => ({ @@ -142,9 +144,11 @@ describe('TableView', () => { expect(html).toContain('Order'); expect(html).toContain('Sequence'); expect(html).toContain('Answer time'); + expect(html).toContain('Time'); + expect(html).toContain('Uniform'); }); - test('uses sequence ordering by default for participant timelines', () => { + test('uses sequence ordering and time sizing by default for participant timelines', () => { const participant = makeParticipant(); renderToStaticMarkup( , @@ -152,6 +156,7 @@ describe('TableView', () => { const html = renderToStaticMarkup(capturedTableOptions!.renderDetailPanel({ row: { original: participant } })); expect(html).toContain('data-task-order="sequence"'); + expect(html).toContain('data-timeline-mode="time"'); }); // ── Status column ──────────────────────────────────────────────────────────