Skip to content
Merged
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
49 changes: 26 additions & 23 deletions src/analysis/individualStudy/replay/AllTasksTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string | null>(null);

const percentComplete = useMemo(() => {
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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(() => {
Expand Down
40 changes: 40 additions & 0 deletions src/analysis/individualStudy/replay/taskOrdering.ts
Original file line number Diff line number Diff line change
@@ -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<string, StoredAnswer> = {},
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);
}
98 changes: 98 additions & 0 deletions src/analysis/individualStudy/replay/tests/taskOrdering.spec.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
20 changes: 17 additions & 3 deletions src/analysis/individualStudy/table/TableView.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -54,6 +55,7 @@ export function TableView({
}) {
const { studyId } = useParams();
const [checked, setChecked] = useState<MrtRowSelectionState>({});
const [taskOrder, setTaskOrder] = useState<ReplayTaskOrder>('sequence');

useEffect(() => {
const newSelectedParticipants = Object.keys(checked).filter((v) => checked[v])
Expand Down Expand Up @@ -275,7 +277,7 @@ export function TableView({
}

return (
<AllTasksTimeline maxLength={undefined} studyConfig={allConfigs[r.participantConfigHash] ?? studyConfig} studyId={studyId || ''} participantData={r} width={width - 60} />
<AllTasksTimeline maxLength={undefined} studyConfig={allConfigs[r.participantConfigHash] ?? studyConfig} studyId={studyId || ''} participantData={r} width={width - 60} taskOrder={taskOrder} />
);
},
defaultColumn: {
Expand All @@ -286,7 +288,19 @@ export function TableView({
enableDensityToggle: false,
positionToolbarAlertBanner: 'none',
renderTopToolbarCustomActions: () => (
<Flex mb={8} p={8}>
<Flex mb={8} p={8} gap="md" align="center">
<Group gap="xs">
<Text size="sm" fw={500}>Order</Text>
<SegmentedControl
size="sm"
value={taskOrder}
onChange={(value) => setTaskOrder(value as ReplayTaskOrder)}
data={[
{ value: 'sequence', label: 'Sequence' },
{ value: 'answer-time', label: 'Answer time' },
]}
/>
</Group>
<ParticipantRejectModal selectedParticipants={selectedParticipants} refresh={handleRefresh} />
</Flex>
),
Expand Down
34 changes: 31 additions & 3 deletions src/analysis/individualStudy/table/tests/TableView.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => <div>MantineReactTable</div>,
useMantineReactTable: (opts: { columns: MrtColumn[] }) => { capturedTableOptions = opts; return opts; },
useMantineReactTable: (opts: CapturedTableOptions) => { capturedTableOptions = opts; return opts; },
}));

vi.mock('react-router', () => ({
Expand All @@ -41,6 +47,7 @@ vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>,
Spoiler: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Box: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SegmentedControl: ({ data }: { data: { value: string; label: string }[] }) => <div>{data.map(({ value, label }) => <span key={value}>{label}</span>)}</div>,
}));

vi.mock('@tabler/icons-react', () => ({
Expand All @@ -51,7 +58,7 @@ vi.mock('@tabler/icons-react', () => ({
}));

vi.mock('../../replay/AllTasksTimeline', () => ({
AllTasksTimeline: () => <div>AllTasksTimeline</div>,
AllTasksTimeline: ({ taskOrder }: { taskOrder: string }) => <div data-task-order={taskOrder}>AllTasksTimeline</div>,
}));

vi.mock('../../ParticipantRejectModal', () => ({
Expand Down Expand Up @@ -126,6 +133,27 @@ describe('TableView', () => {
expect(capturedTableOptions).not.toBeNull();
});

test('offers sequence and answer-time ordering beside the timeline controls', () => {
renderToStaticMarkup(
<TableView {...defaultProps} visibleParticipants={[makeParticipant()]} />,
);

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(
<TableView {...defaultProps} visibleParticipants={[participant]} />,
);

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', () => {
Expand Down
1 change: 1 addition & 0 deletions src/components/audioAnalysis/Timer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export function Timer({

return (
<svg
data-testid="replay-timer"
onClick={clickOnSvg}
style={{
width, height, position: 'absolute', zIndex: 10000,
Expand Down
2 changes: 1 addition & 1 deletion tests/demo-image.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ test('Test image component', async ({ page }) => {
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
Expand Down
Loading
Loading