Skip to content

Commit 3a96992

Browse files
committed
Fix replay task ordering
1 parent 7f18b80 commit 3a96992

3 files changed

Lines changed: 122 additions & 20 deletions

File tree

src/analysis/individualStudy/replay/AllTasksTimeline.tsx

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ import {
88
import { ParticipantData } from '../../../storage/types';
99
import { SingleTaskLabelLines } from './SingleTaskLabelLines';
1010
import { SingleTask } from './SingleTask';
11-
import { StoredAnswer, StudyConfig } from '../../../parser/types';
11+
import { StudyConfig } from '../../../parser/types';
1212
import { getComponentAnswerStatus } from '../../../utils/correctAnswer';
1313
import { parseConditionParam } from '../../../utils/handleConditionLogic';
1414
import { studyComponentToIndividualComponent } from '../../../utils/handleComponentInheritance';
15+
import {
16+
compareReplayAnswerEntries,
17+
orderedReplayAnswerEntries,
18+
} from './taskOrdering';
1519

1620
const LABEL_GAP = 25;
1721
const CHARACTER_SIZE = 8;
@@ -20,12 +24,6 @@ const margin = {
2024
left: 20, top: 20, right: 20, bottom: 20,
2125
};
2226

23-
const sortedTaskNames = (a: [string, StoredAnswer], b: [string, StoredAnswer]) => {
24-
const splitA = a[1].trialOrder.split('_');
25-
const splitB = b[1].trialOrder.split('_');
26-
return splitA[0] === splitB[0] ? +splitA[1] - +splitB[1] : +splitA[0] - +splitB[0];
27-
};
28-
2927
export function AllTasksTimeline({
3028
participantData, width, studyId, studyConfig, maxLength,
3129
}: { participantData: ParticipantData, width: number, studyId: string, studyConfig: StudyConfig | undefined, maxLength: number | undefined }) {
@@ -54,20 +52,25 @@ export function AllTasksTimeline({
5452
}, [participantData.answers, percentComplete, width]);
5553

5654
const maxHeight = useMemo(() => {
57-
// Sort the entires by start time and filter out entries without start time
58-
const sortedEntries = Object.entries(participantData.answers || {}).filter((answer) => !!(answer[1].startTime)).sort((a, b) => a[1].startTime - b[1].startTime);
55+
const incompleteEntries = Object.entries(participantData.answers || {}).filter((e) => e[1].startTime === 0).sort(compareReplayAnswerEntries);
56+
const incompleteEntryIndexes = new Map(incompleteEntries.map(([identifier], index) => [identifier, index]));
57+
const sortedEntries = orderedReplayAnswerEntries(participantData.answers);
5958

6059
let currentHeight = 0;
6160
let _maxHeight = 0;
6261

6362
sortedEntries.forEach((entry, i) => {
64-
const [_name, answer] = entry;
63+
const [identifier, answer] = entry;
6564

6665
// Check if the previous entry overlaps with the current entry
6766
const prev = i > 0 ? sortedEntries[i - currentHeight - 1] : null;
67+
const prevScale = prev && prev[1].startTime ? xScale : incompleteXScale;
68+
const prevStart = prev ? prev[1].startTime ? prev[1].startTime : incompleteEntryIndexes.get(prev[0]) ?? 0 : 0;
69+
const scale = answer.startTime === 0 ? incompleteXScale : xScale;
70+
const scaleStart = answer.startTime ? answer.startTime : incompleteEntryIndexes.get(identifier) ?? 0;
6871

6972
// If the previous entry overlaps with the current entry , increase the height
70-
if (prev && prev[0].length * (CHARACTER_SIZE + 1) + xScale(prev[1].startTime) > xScale(answer.startTime)) {
73+
if (prev && prev[0].length * (CHARACTER_SIZE + 1) + prevScale(prevStart) > scale(scaleStart)) {
7174
currentHeight += 1;
7275
} else {
7376
currentHeight = 0;
@@ -79,7 +82,7 @@ export function AllTasksTimeline({
7982
});
8083

8184
return (_maxHeight + 1) * LABEL_GAP + margin.top + margin.bottom;
82-
}, [participantData.answers, xScale]);
85+
}, [incompleteXScale, participantData.answers, xScale]);
8386

8487
const conditionParam = useMemo(() => {
8588
const parsedConditions = parseConditionParam(participantData.conditions ?? participantData.searchParams?.condition);
@@ -90,11 +93,9 @@ export function AllTasksTimeline({
9093
const tasks: { identifier: string, line: JSX.Element, label: JSX.Element }[] = useMemo(() => {
9194
let currentHeight = 0;
9295

93-
const incompleteEntries = Object.entries(participantData.answers || {}).filter((e) => e[1].startTime === 0).sort(sortedTaskNames);
94-
95-
const sortedEntries = Object.entries(participantData.answers || {}).filter((answer) => !!(answer[1].startTime)).sort((a, b) => a[1].startTime - b[1].startTime);
96-
97-
const combined = [...sortedEntries, ...incompleteEntries];
96+
const incompleteEntries = Object.entries(participantData.answers || {}).filter((e) => e[1].startTime === 0).sort(compareReplayAnswerEntries);
97+
const incompleteEntryIndexes = new Map(incompleteEntries.map(([identifier], index) => [identifier, index]));
98+
const combined = orderedReplayAnswerEntries(participantData.answers);
9899

99100
const allElements = combined.map((entry, i) => {
100101
const scale = entry[1].startTime === 0 ? incompleteXScale : xScale;
@@ -104,9 +105,10 @@ export function AllTasksTimeline({
104105
const prev = i > 0 ? combined[i - currentHeight - 1] : null;
105106

106107
const prevScale = prev && prev[1].startTime ? xScale : incompleteXScale;
107-
const prevStart = prev ? prev[1].startTime ? prev[1].startTime : incompleteEntries.indexOf(prev) : 0;
108-
const scaleStart = answer.startTime ? answer.startTime : incompleteEntries.indexOf(entry);
109-
const scaleEnd = answer.endTime > 0 ? answer.endTime : incompleteEntries.indexOf(entry) + 1;
108+
const prevStart = prev ? prev[1].startTime ? prev[1].startTime : incompleteEntryIndexes.get(prev[0]) ?? 0 : 0;
109+
const incompleteEntryIndex = incompleteEntryIndexes.get(identifier) ?? 0;
110+
const scaleStart = answer.startTime ? answer.startTime : incompleteEntryIndex;
111+
const scaleEnd = answer.endTime > 0 ? answer.endTime : incompleteEntryIndex + 1;
110112

111113
if (prev && prev[0].length * (CHARACTER_SIZE + 1) + prevScale(prevStart) > scale(scaleStart)) {
112114
currentHeight += 1;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { StoredAnswer } from '../../../parser/types';
2+
import { parseTrialOrder } from '../../../utils/parseTrialOrder';
3+
4+
export type ReplayAnswerEntry = [string, StoredAnswer];
5+
6+
export function compareReplayAnswerEntries(a: ReplayAnswerEntry, b: ReplayAnswerEntry) {
7+
const aOrder = parseTrialOrder(a[1].trialOrder);
8+
const bOrder = parseTrialOrder(b[1].trialOrder);
9+
10+
if (aOrder.step !== bOrder.step) {
11+
return (aOrder.step ?? Number.MAX_SAFE_INTEGER) - (bOrder.step ?? Number.MAX_SAFE_INTEGER);
12+
}
13+
14+
if (aOrder.funcIndex !== bOrder.funcIndex) {
15+
return (aOrder.funcIndex ?? -1) - (bOrder.funcIndex ?? -1);
16+
}
17+
18+
return a[0].localeCompare(b[0]);
19+
}
20+
21+
export function orderedReplayAnswerEntries(answers: Record<string, StoredAnswer> = {}) {
22+
return Object.entries(answers).sort(compareReplayAnswerEntries);
23+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import {
2+
describe,
3+
expect,
4+
test,
5+
} from 'vitest';
6+
import { StoredAnswer } from '../../../../parser/types';
7+
import { orderedReplayAnswerEntries } from '../taskOrdering';
8+
9+
function answer(identifier: string, trialOrder: string, startTime: number, endTime = startTime + 10): StoredAnswer {
10+
return {
11+
answer: {},
12+
identifier,
13+
componentName: identifier,
14+
trialOrder,
15+
incorrectAnswers: {},
16+
startTime,
17+
endTime,
18+
windowEvents: [],
19+
timedOut: false,
20+
helpButtonClickedCount: 0,
21+
parameters: {},
22+
correctAnswer: [],
23+
optionOrders: {},
24+
questionOrders: {},
25+
};
26+
}
27+
28+
describe('orderedReplayAnswerEntries', () => {
29+
test('orders replay entries by trial order instead of recorded timestamps', () => {
30+
const entries = orderedReplayAnswerEntries({
31+
task_2: answer('task_2', '2', 1000),
32+
task_0: answer('task_0', '0', 3000),
33+
task_1: answer('task_1', '1', 2000),
34+
});
35+
36+
expect(entries.map(([identifier]) => identifier)).toEqual(['task_0', 'task_1', 'task_2']);
37+
expect(entries.map(([, storedAnswer]) => storedAnswer.startTime)).toEqual([3000, 2000, 1000]);
38+
});
39+
40+
test('orders dynamic entries by parent step and function index', () => {
41+
const entries = orderedReplayAnswerEntries({
42+
task_3_2: answer('task_3_2', '3_2', 1000),
43+
task_4: answer('task_4', '4', 2000),
44+
task_3_0: answer('task_3_0', '3_0', 3000),
45+
task_3: answer('task_3', '3', 4000),
46+
task_3_1: answer('task_3_1', '3_1', 5000),
47+
});
48+
49+
expect(entries.map(([identifier]) => identifier)).toEqual([
50+
'task_3',
51+
'task_3_0',
52+
'task_3_1',
53+
'task_3_2',
54+
'task_4',
55+
]);
56+
});
57+
58+
test('keeps incomplete entries in sequence order with completed entries', () => {
59+
const entries = orderedReplayAnswerEntries({
60+
task_2: answer('task_2', '2', 0, 0),
61+
task_0: answer('task_0', '0', 1000),
62+
task_1: answer('task_1', '1', 0, 0),
63+
task_3: answer('task_3', '3', 2000),
64+
});
65+
66+
expect(entries.map(([identifier]) => identifier)).toEqual(['task_0', 'task_1', 'task_2', 'task_3']);
67+
});
68+
69+
test('uses the task identifier as a stable tie-breaker', () => {
70+
const entries = orderedReplayAnswerEntries({
71+
task_b: answer('task_b', '1', 1000),
72+
task_a: answer('task_a', '1', 2000),
73+
});
74+
75+
expect(entries.map(([identifier]) => identifier)).toEqual(['task_a', 'task_b']);
76+
});
77+
});

0 commit comments

Comments
 (0)