From 9b8de055a54bfab5f9eae286703aafbc310aca94 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Tue, 9 Jun 2026 21:54:22 -0600 Subject: [PATCH] Fix replay task ordering --- .../replay/AllTasksTimeline.tsx | 42 +++++----- .../replay/taskOrdering.spec.ts | 77 +++++++++++++++++++ .../individualStudy/replay/taskOrdering.ts | 23 ++++++ 3 files changed, 122 insertions(+), 20 deletions(-) create mode 100644 src/analysis/individualStudy/replay/taskOrdering.spec.ts create mode 100644 src/analysis/individualStudy/replay/taskOrdering.ts diff --git a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx index 6443ead2ed..4b00c56ab7 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 { componentAnswersAreCorrect } 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.spec.ts b/src/analysis/individualStudy/replay/taskOrdering.spec.ts new file mode 100644 index 0000000000..1cd28e0fdf --- /dev/null +++ b/src/analysis/individualStudy/replay/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']); + }); +}); 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); +}