From 3a96992e2bdef735d4e5d3df364333a9bba1a39d Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Tue, 9 Jun 2026 21:54:22 -0600 Subject: [PATCH 1/2] Fix replay task ordering --- .../replay/AllTasksTimeline.tsx | 42 +++++----- .../individualStudy/replay/taskOrdering.ts | 23 ++++++ .../replay/tests/taskOrdering.spec.ts | 77 +++++++++++++++++++ 3 files changed, 122 insertions(+), 20 deletions(-) create mode 100644 src/analysis/individualStudy/replay/taskOrdering.ts create mode 100644 src/analysis/individualStudy/replay/tests/taskOrdering.spec.ts diff --git a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx index b5da4b4b30..2d424a1d58 100644 --- a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx +++ b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx @@ -8,10 +8,14 @@ import { import { ParticipantData } from '../../../storage/types'; import { SingleTaskLabelLines } from './SingleTaskLabelLines'; import { SingleTask } from './SingleTask'; -import { StoredAnswer, StudyConfig } from '../../../parser/types'; +import { StudyConfig } from '../../../parser/types'; import { getComponentAnswerStatus } from '../../../utils/correctAnswer'; import { parseConditionParam } from '../../../utils/handleConditionLogic'; import { studyComponentToIndividualComponent } from '../../../utils/handleComponentInheritance'; +import { + compareReplayAnswerEntries, + orderedReplayAnswerEntries, +} from './taskOrdering'; const LABEL_GAP = 25; const CHARACTER_SIZE = 8; @@ -20,12 +24,6 @@ const margin = { left: 20, top: 20, right: 20, bottom: 20, }; -const sortedTaskNames = (a: [string, StoredAnswer], b: [string, StoredAnswer]) => { - const splitA = a[1].trialOrder.split('_'); - const splitB = b[1].trialOrder.split('_'); - return splitA[0] === splitB[0] ? +splitA[1] - +splitB[1] : +splitA[0] - +splitB[0]; -}; - export function AllTasksTimeline({ participantData, width, studyId, studyConfig, maxLength, }: { participantData: ParticipantData, width: number, studyId: string, studyConfig: StudyConfig | undefined, maxLength: number | undefined }) { @@ -54,20 +52,25 @@ export function AllTasksTimeline({ }, [participantData.answers, percentComplete, width]); const maxHeight = useMemo(() => { - // Sort the entires by start time and filter out entries without start time - const sortedEntries = Object.entries(participantData.answers || {}).filter((answer) => !!(answer[1].startTime)).sort((a, b) => a[1].startTime - b[1].startTime); + 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); let currentHeight = 0; let _maxHeight = 0; sortedEntries.forEach((entry, i) => { - const [_name, answer] = entry; + const [identifier, answer] = entry; // 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; // If the previous entry overlaps with the current entry , increase the height - if (prev && prev[0].length * (CHARACTER_SIZE + 1) + xScale(prev[1].startTime) > xScale(answer.startTime)) { + if (prev && prev[0].length * (CHARACTER_SIZE + 1) + prevScale(prevStart) > scale(scaleStart)) { currentHeight += 1; } else { currentHeight = 0; @@ -79,7 +82,7 @@ export function AllTasksTimeline({ }); return (_maxHeight + 1) * LABEL_GAP + margin.top + margin.bottom; - }, [participantData.answers, xScale]); + }, [incompleteXScale, participantData.answers, xScale]); const conditionParam = useMemo(() => { const parsedConditions = parseConditionParam(participantData.conditions ?? participantData.searchParams?.condition); @@ -90,11 +93,9 @@ export function AllTasksTimeline({ const tasks: { identifier: string, line: JSX.Element, label: JSX.Element }[] = useMemo(() => { let currentHeight = 0; - const incompleteEntries = Object.entries(participantData.answers || {}).filter((e) => e[1].startTime === 0).sort(sortedTaskNames); - - const sortedEntries = Object.entries(participantData.answers || {}).filter((answer) => !!(answer[1].startTime)).sort((a, b) => a[1].startTime - b[1].startTime); - - const combined = [...sortedEntries, ...incompleteEntries]; + 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); const allElements = combined.map((entry, i) => { const scale = entry[1].startTime === 0 ? incompleteXScale : xScale; @@ -104,9 +105,10 @@ export function AllTasksTimeline({ 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 : incompleteEntries.indexOf(prev) : 0; - const scaleStart = answer.startTime ? answer.startTime : incompleteEntries.indexOf(entry); - const scaleEnd = answer.endTime > 0 ? answer.endTime : incompleteEntries.indexOf(entry) + 1; + const prevStart = prev ? 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; if (prev && prev[0].length * (CHARACTER_SIZE + 1) + prevScale(prevStart) > scale(scaleStart)) { currentHeight += 1; diff --git a/src/analysis/individualStudy/replay/taskOrdering.ts b/src/analysis/individualStudy/replay/taskOrdering.ts new file mode 100644 index 0000000000..b8261aa1b3 --- /dev/null +++ b/src/analysis/individualStudy/replay/taskOrdering.ts @@ -0,0 +1,23 @@ +import { StoredAnswer } from '../../../parser/types'; +import { parseTrialOrder } from '../../../utils/parseTrialOrder'; + +export type ReplayAnswerEntry = [string, StoredAnswer]; + +export function compareReplayAnswerEntries(a: ReplayAnswerEntry, b: ReplayAnswerEntry) { + const aOrder = parseTrialOrder(a[1].trialOrder); + const bOrder = parseTrialOrder(b[1].trialOrder); + + if (aOrder.step !== bOrder.step) { + return (aOrder.step ?? Number.MAX_SAFE_INTEGER) - (bOrder.step ?? Number.MAX_SAFE_INTEGER); + } + + if (aOrder.funcIndex !== bOrder.funcIndex) { + return (aOrder.funcIndex ?? -1) - (bOrder.funcIndex ?? -1); + } + + return a[0].localeCompare(b[0]); +} + +export function orderedReplayAnswerEntries(answers: Record = {}) { + return Object.entries(answers).sort(compareReplayAnswerEntries); +} diff --git a/src/analysis/individualStudy/replay/tests/taskOrdering.spec.ts b/src/analysis/individualStudy/replay/tests/taskOrdering.spec.ts new file mode 100644 index 0000000000..08de1186ec --- /dev/null +++ b/src/analysis/individualStudy/replay/tests/taskOrdering.spec.ts @@ -0,0 +1,77 @@ +import { + describe, + expect, + test, +} from 'vitest'; +import { StoredAnswer } from '../../../../parser/types'; +import { orderedReplayAnswerEntries } from '../taskOrdering'; + +function answer(identifier: string, trialOrder: string, startTime: number, endTime = startTime + 10): StoredAnswer { + return { + answer: {}, + identifier, + componentName: identifier, + trialOrder, + incorrectAnswers: {}, + startTime, + endTime, + windowEvents: [], + timedOut: false, + helpButtonClickedCount: 0, + parameters: {}, + correctAnswer: [], + optionOrders: {}, + questionOrders: {}, + }; +} + +describe('orderedReplayAnswerEntries', () => { + test('orders replay entries by trial order instead of recorded timestamps', () => { + const entries = orderedReplayAnswerEntries({ + task_2: answer('task_2', '2', 1000), + task_0: answer('task_0', '0', 3000), + task_1: answer('task_1', '1', 2000), + }); + + expect(entries.map(([identifier]) => identifier)).toEqual(['task_0', 'task_1', 'task_2']); + expect(entries.map(([, storedAnswer]) => storedAnswer.startTime)).toEqual([3000, 2000, 1000]); + }); + + test('orders dynamic entries by parent step and function index', () => { + const entries = orderedReplayAnswerEntries({ + task_3_2: answer('task_3_2', '3_2', 1000), + task_4: answer('task_4', '4', 2000), + task_3_0: answer('task_3_0', '3_0', 3000), + task_3: answer('task_3', '3', 4000), + task_3_1: answer('task_3_1', '3_1', 5000), + }); + + expect(entries.map(([identifier]) => identifier)).toEqual([ + 'task_3', + 'task_3_0', + 'task_3_1', + 'task_3_2', + 'task_4', + ]); + }); + + test('keeps incomplete entries in sequence order with completed entries', () => { + const entries = orderedReplayAnswerEntries({ + task_2: answer('task_2', '2', 0, 0), + task_0: answer('task_0', '0', 1000), + task_1: answer('task_1', '1', 0, 0), + task_3: answer('task_3', '3', 2000), + }); + + expect(entries.map(([identifier]) => identifier)).toEqual(['task_0', 'task_1', 'task_2', 'task_3']); + }); + + test('uses the task identifier as a stable tie-breaker', () => { + const entries = orderedReplayAnswerEntries({ + task_b: answer('task_b', '1', 1000), + task_a: answer('task_a', '1', 2000), + }); + + expect(entries.map(([identifier]) => identifier)).toEqual(['task_a', 'task_b']); + }); +}); From bce4c9e50203649905ad4309807f4728518f45a5 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Fri, 17 Jul 2026 13:39:44 -0600 Subject: [PATCH 2/2] Finish replay ordering and stabilize demo tests --- .../replay/AllTasksTimeline.tsx | 13 +++---- .../individualStudy/replay/taskOrdering.ts | 21 ++++++++++-- .../replay/tests/taskOrdering.spec.ts | 21 ++++++++++++ .../individualStudy/table/TableView.tsx | 20 +++++++++-- .../table/tests/TableView.spec.tsx | 34 +++++++++++++++++-- src/components/audioAnalysis/Timer.tsx | 1 + tests/demo-image.spec.ts | 2 +- tests/demo-trrack.spec.ts | 9 +++-- 8 files changed, 104 insertions(+), 17 deletions(-) diff --git a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx index 2d424a1d58..4ee3927254 100644 --- a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx +++ b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx @@ -15,6 +15,7 @@ import { studyComponentToIndividualComponent } from '../../../utils/handleCompon import { compareReplayAnswerEntries, orderedReplayAnswerEntries, + ReplayTaskOrder, } from './taskOrdering'; const LABEL_GAP = 25; @@ -25,8 +26,8 @@ const margin = { }; export function AllTasksTimeline({ - participantData, width, studyId, studyConfig, maxLength, -}: { participantData: ParticipantData, width: number, studyId: string, studyConfig: StudyConfig | undefined, maxLength: number | undefined }) { + participantData, width, studyId, studyConfig, maxLength, taskOrder = 'sequence', +}: { participantData: ParticipantData, width: number, studyId: string, studyConfig: StudyConfig | undefined, maxLength: number | undefined, taskOrder?: ReplayTaskOrder }) { const [hoveredTaskIdentifier, setHoveredTaskIdentifier] = useState(null); const percentComplete = useMemo(() => { @@ -54,7 +55,7 @@ export function AllTasksTimeline({ 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); + const sortedEntries = orderedReplayAnswerEntries(participantData.answers, taskOrder); let currentHeight = 0; let _maxHeight = 0; @@ -82,7 +83,7 @@ export function AllTasksTimeline({ }); return (_maxHeight + 1) * LABEL_GAP + margin.top + margin.bottom; - }, [incompleteXScale, participantData.answers, xScale]); + }, [incompleteXScale, participantData.answers, taskOrder, xScale]); const conditionParam = useMemo(() => { const parsedConditions = parseConditionParam(participantData.conditions ?? participantData.searchParams?.condition); @@ -95,7 +96,7 @@ 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); + const combined = orderedReplayAnswerEntries(participantData.answers, taskOrder); const allElements = combined.map((entry, i) => { const scale = entry[1].startTime === 0 ? incompleteXScale : xScale; @@ -162,7 +163,7 @@ export function AllTasksTimeline({ }); return allElements; - }, [participantData.answers, participantData.participantId, incompleteXScale, xScale, studyConfig, maxHeight, studyId, conditionParam, hoveredTaskIdentifier]); + }, [participantData.answers, participantData.participantId, incompleteXScale, xScale, studyConfig, maxHeight, studyId, conditionParam, hoveredTaskIdentifier, taskOrder]); // Find entries of someone browsing away. Show them const browsedAway = useMemo(() => { diff --git a/src/analysis/individualStudy/replay/taskOrdering.ts b/src/analysis/individualStudy/replay/taskOrdering.ts index b8261aa1b3..00b952494e 100644 --- a/src/analysis/individualStudy/replay/taskOrdering.ts +++ b/src/analysis/individualStudy/replay/taskOrdering.ts @@ -2,6 +2,7 @@ import { StoredAnswer } from '../../../parser/types'; import { parseTrialOrder } from '../../../utils/parseTrialOrder'; export type ReplayAnswerEntry = [string, StoredAnswer]; +export type ReplayTaskOrder = 'sequence' | 'answer-time'; export function compareReplayAnswerEntries(a: ReplayAnswerEntry, b: ReplayAnswerEntry) { const aOrder = parseTrialOrder(a[1].trialOrder); @@ -18,6 +19,22 @@ export function compareReplayAnswerEntries(a: ReplayAnswerEntry, b: ReplayAnswer return a[0].localeCompare(b[0]); } -export function orderedReplayAnswerEntries(answers: Record = {}) { - return Object.entries(answers).sort(compareReplayAnswerEntries); +export function orderedReplayAnswerEntries( + answers: Record = {}, + taskOrder: ReplayTaskOrder = 'sequence', +) { + const entries = Object.entries(answers); + + if (taskOrder === 'answer-time') { + const completed = entries + .filter(([, answer]) => answer.startTime !== 0) + .sort((a, b) => a[1].startTime - b[1].startTime || compareReplayAnswerEntries(a, b)); + const incomplete = entries + .filter(([, answer]) => answer.startTime === 0) + .sort(compareReplayAnswerEntries); + + return [...completed, ...incomplete]; + } + + return entries.sort(compareReplayAnswerEntries); } diff --git a/src/analysis/individualStudy/replay/tests/taskOrdering.spec.ts b/src/analysis/individualStudy/replay/tests/taskOrdering.spec.ts index 08de1186ec..b28aeefb66 100644 --- a/src/analysis/individualStudy/replay/tests/taskOrdering.spec.ts +++ b/src/analysis/individualStudy/replay/tests/taskOrdering.spec.ts @@ -74,4 +74,25 @@ describe('orderedReplayAnswerEntries', () => { expect(entries.map(([identifier]) => identifier)).toEqual(['task_a', 'task_b']); }); + + test('orders completed entries chronologically when answer-time order is selected', () => { + const entries = orderedReplayAnswerEntries({ + task_0: answer('task_0', '0', 3000), + task_2: answer('task_2', '2', 1000), + task_1: answer('task_1', '1', 2000), + }, 'answer-time'); + + expect(entries.map(([identifier]) => identifier)).toEqual(['task_2', 'task_1', 'task_0']); + }); + + test('keeps incomplete entries after chronological entries in sequence order', () => { + const entries = orderedReplayAnswerEntries({ + task_3: answer('task_3', '3', 0, 0), + task_2: answer('task_2', '2', 1000), + task_1: answer('task_1', '1', 0, 0), + task_0: answer('task_0', '0', 2000), + }, 'answer-time'); + + expect(entries.map(([identifier]) => identifier)).toEqual(['task_2', 'task_0', 'task_1', 'task_3']); + }); }); diff --git a/src/analysis/individualStudy/table/TableView.tsx b/src/analysis/individualStudy/table/TableView.tsx index 1a044f2ee7..1220a9329f 100644 --- a/src/analysis/individualStudy/table/TableView.tsx +++ b/src/analysis/individualStudy/table/TableView.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/no-unstable-nested-components */ import { - Text, Flex, Group, Space, Tooltip, Badge, RingProgress, Stack, ActionIcon, + Text, Flex, Group, Space, Tooltip, Badge, RingProgress, Stack, ActionIcon, SegmentedControl, } from '@mantine/core'; import { JSX, useCallback, useEffect, useMemo, useState, @@ -19,6 +19,7 @@ import { StoredAnswer } from '../../../store/types'; import { ParticipantRejectModal } from '../ParticipantRejectModal'; import { participantName } from '../../../utils/participantName'; import { AllTasksTimeline } from '../replay/AllTasksTimeline'; +import { ReplayTaskOrder } from '../replay/taskOrdering'; import { youtubeReadableDuration } from '../../../utils/humanReadableDuration'; import { getSequenceFlatMap } from '../../../utils/getSequenceFlatMap'; import { MetaCell } from './MetaCell'; @@ -54,6 +55,7 @@ export function TableView({ }) { const { studyId } = useParams(); const [checked, setChecked] = useState({}); + const [taskOrder, setTaskOrder] = useState('sequence'); useEffect(() => { const newSelectedParticipants = Object.keys(checked).filter((v) => checked[v]) @@ -275,7 +277,7 @@ export function TableView({ } return ( - + ); }, defaultColumn: { @@ -286,7 +288,19 @@ export function TableView({ enableDensityToggle: false, positionToolbarAlertBanner: 'none', renderTopToolbarCustomActions: () => ( - + + + Order + setTaskOrder(value as ReplayTaskOrder)} + data={[ + { value: 'sequence', label: 'Sequence' }, + { value: 'answer-time', label: 'Answer time' }, + ]} + /> + ), diff --git a/src/analysis/individualStudy/table/tests/TableView.spec.tsx b/src/analysis/individualStudy/table/tests/TableView.spec.tsx index eabaf91699..db8cd306b1 100644 --- a/src/analysis/individualStudy/table/tests/TableView.spec.tsx +++ b/src/analysis/individualStudy/table/tests/TableView.spec.tsx @@ -18,11 +18,17 @@ type MrtColumn = { Cell: ({ cell }: { cell: { getValue(): unknown } }) => ReactNode; }; -let capturedTableOptions: { columns: MrtColumn[] } | null = null; +type CapturedTableOptions = { + columns: MrtColumn[]; + renderDetailPanel: ({ row }: { row: { original: ParticipantDataWithStatus } }) => ReactNode; + renderTopToolbarCustomActions: () => ReactNode; +}; + +let capturedTableOptions: CapturedTableOptions | null = null; vi.mock('mantine-react-table', () => ({ MantineReactTable: () =>
MantineReactTable
, - useMantineReactTable: (opts: { columns: MrtColumn[] }) => { capturedTableOptions = opts; return opts; }, + useMantineReactTable: (opts: CapturedTableOptions) => { capturedTableOptions = opts; return opts; }, })); vi.mock('react-router', () => ({ @@ -41,6 +47,7 @@ vi.mock('@mantine/core', () => ({ ActionIcon: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => , Spoiler: ({ children }: { children: ReactNode }) =>
{children}
, Box: ({ children }: { children: ReactNode }) =>
{children}
, + SegmentedControl: ({ data }: { data: { value: string; label: string }[] }) =>
{data.map(({ value, label }) => {label})}
, })); vi.mock('@tabler/icons-react', () => ({ @@ -51,7 +58,7 @@ vi.mock('@tabler/icons-react', () => ({ })); vi.mock('../../replay/AllTasksTimeline', () => ({ - AllTasksTimeline: () =>
AllTasksTimeline
, + AllTasksTimeline: ({ taskOrder }: { taskOrder: string }) =>
AllTasksTimeline
, })); vi.mock('../../ParticipantRejectModal', () => ({ @@ -126,6 +133,27 @@ describe('TableView', () => { expect(capturedTableOptions).not.toBeNull(); }); + test('offers sequence and answer-time ordering beside the timeline controls', () => { + renderToStaticMarkup( + , + ); + + const html = renderToStaticMarkup(capturedTableOptions!.renderTopToolbarCustomActions()); + expect(html).toContain('Order'); + expect(html).toContain('Sequence'); + expect(html).toContain('Answer time'); + }); + + test('uses sequence ordering by default for participant timelines', () => { + const participant = makeParticipant(); + renderToStaticMarkup( + , + ); + + const html = renderToStaticMarkup(capturedTableOptions!.renderDetailPanel({ row: { original: participant } })); + expect(html).toContain('data-task-order="sequence"'); + }); + // ── Status column ────────────────────────────────────────────────────────── test('Status Cell: rejected participant shows x-icon and reason', () => { diff --git a/src/components/audioAnalysis/Timer.tsx b/src/components/audioAnalysis/Timer.tsx index d2cbb73816..291b32e70a 100644 --- a/src/components/audioAnalysis/Timer.tsx +++ b/src/components/audioAnalysis/Timer.tsx @@ -50,6 +50,7 @@ export function Timer({ return ( { await expect(img2).toBeVisible(); // Select a response and click next - await page.getByLabel('No').check(); + await page.getByRole('radio', { name: 'No', exact: true }).check(); await page.keyboard.press('Enter'); // Check the page contains the question diff --git a/tests/demo-trrack.spec.ts b/tests/demo-trrack.spec.ts index 2b63efba24..d8feb5f277 100644 --- a/tests/demo-trrack.spec.ts +++ b/tests/demo-trrack.spec.ts @@ -72,7 +72,12 @@ async function readRecordedReplay(page: Page, studyId: string): Promise event.createdOn) .filter((createdOn): createdOn is number => typeof createdOn === 'number') ?? []; - if (typeof answer?.startTime !== 'number' || typeof answer.endTime !== 'number') { + if ( + typeof answer?.startTime !== 'number' + || typeof answer.endTime !== 'number' + || answer.startTime <= 0 + || answer.endTime < answer.startTime + ) { return null; } @@ -85,7 +90,7 @@ async function readRecordedReplay(page: Page, studyId: string): Promise