Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 22 additions & 20 deletions src/analysis/individualStudy/replay/AllTasksTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 }) {
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -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;
Expand Down
77 changes: 77 additions & 0 deletions src/analysis/individualStudy/replay/taskOrdering.spec.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
23 changes: 23 additions & 0 deletions src/analysis/individualStudy/replay/taskOrdering.ts
Original file line number Diff line number Diff line change
@@ -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<string, StoredAnswer> = {}) {
return Object.entries(answers).sort(compareReplayAnswerEntries);
}
Loading