diff --git a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx index b5da4b4b30..4ee3927254 100644 --- a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx +++ b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx @@ -8,10 +8,15 @@ 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, + ReplayTaskOrder, +} from './taskOrdering'; const LABEL_GAP = 25; const CHARACTER_SIZE = 8; @@ -20,15 +25,9 @@ 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 }) { + 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,20 +53,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, taskOrder); 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 +83,7 @@ export function AllTasksTimeline({ }); return (_maxHeight + 1) * LABEL_GAP + margin.top + margin.bottom; - }, [participantData.answers, xScale]); + }, [incompleteXScale, participantData.answers, taskOrder, xScale]); const conditionParam = useMemo(() => { const parsedConditions = parseConditionParam(participantData.conditions ?? participantData.searchParams?.condition); @@ -90,11 +94,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, taskOrder); const allElements = combined.map((entry, i) => { const scale = entry[1].startTime === 0 ? incompleteXScale : xScale; @@ -104,9 +106,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; @@ -160,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 new file mode 100644 index 0000000000..00b952494e --- /dev/null +++ b/src/analysis/individualStudy/replay/taskOrdering.ts @@ -0,0 +1,40 @@ +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); + 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 = {}, + 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 new file mode 100644 index 0000000000..b28aeefb66 --- /dev/null +++ b/src/analysis/individualStudy/replay/tests/taskOrdering.spec.ts @@ -0,0 +1,98 @@ +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']); + }); + + 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