diff --git a/package.json b/package.json
index 6f498e56a0..d247903664 100644
--- a/package.json
+++ b/package.json
@@ -40,7 +40,8 @@
"@reduxjs/toolkit": "^2.9.0",
"@supabase/supabase-js": "^2.57.4",
"@tabler/icons-react": "^3.35.0",
- "@trrack/core": "^1.3.0-beta.1",
+ "@tanstack/react-virtual": "^3.13.13",
+ "@trrack/core": "^1.3.0",
"@trrack/vis-react": "^1.3.0",
"@types/crypto-js": "^4.2.2",
"@types/hjson": "^2.4.6",
diff --git a/src/analysis/individualStudy/stats/StatsView.tsx b/src/analysis/individualStudy/stats/StatsView.tsx
index 0ff52ceb3a..8f6340bd87 100644
--- a/src/analysis/individualStudy/stats/StatsView.tsx
+++ b/src/analysis/individualStudy/stats/StatsView.tsx
@@ -1,15 +1,11 @@
import {
Box, Divider, Flex, Paper, Text,
} from '@mantine/core';
-import {
- useMemo,
-} from 'react';
import { useParams } from 'react-router';
import { ParticipantData } from '../../../storage/types';
import { StudyConfig } from '../../../parser/types';
import { TrialVisualization } from './TrialVisualization';
-import { ComponentBlockWithOrderPath, StepsPanel } from '../../../components/interface/StepsPanel';
-import { addPathToComponentBlock } from '../../../utils/getSequenceFlatMap';
+import { StepsPanel } from '../../../components/interface/StepsPanel';
export function StatsView(
{
@@ -20,13 +16,6 @@ export function StatsView(
visibleParticipants: ParticipantData[];
},
) {
- const fullOrder = useMemo(() => {
- let r = structuredClone(studyConfig.sequence) as ComponentBlockWithOrderPath;
- r = addPathToComponentBlock(r, 'root') as ComponentBlockWithOrderPath;
- r.components.push('end');
- return r;
- }, [studyConfig.sequence]);
-
const { trialId } = useParams();
return (
@@ -42,7 +31,7 @@ export function StatsView(
{/* Trial selection sidebar */}
-
+
diff --git a/src/components/interface/AppAside.tsx b/src/components/interface/AppAside.tsx
index 4088f91e6c..c4feac2fa1 100644
--- a/src/components/interface/AppAside.tsx
+++ b/src/components/interface/AppAside.tsx
@@ -3,7 +3,6 @@ import {
Button,
CloseButton,
Flex,
- ScrollArea,
Tabs,
Text,
AppShell,
@@ -15,7 +14,7 @@ import {
IconBrandFirebase, IconBrandSupabase, IconDatabase, IconGraph, IconGraphOff, IconInfoCircle, IconSettingsShare, IconUserPlus,
} from '@tabler/icons-react';
import { useHref } from 'react-router';
-import { ComponentBlockWithOrderPath, StepsPanel } from './StepsPanel';
+import { StepsPanel } from './StepsPanel';
import { useStudyConfig } from '../../store/hooks/useStudyConfig';
import {
useStoreActions, useStoreDispatch, useStoreSelector,
@@ -23,7 +22,6 @@ import {
import { useStudyId } from '../../routes/utils';
import { getNewParticipant } from '../../utils/nextParticipant';
import { useStorageEngine } from '../../storage/storageEngineHooks';
-import { addPathToComponentBlock } from '../../utils/getSequenceFlatMap';
import { useIsAnalysis } from '../../store/hooks/useIsAnalysis';
function InfoHover({ text }: { text: string }) {
@@ -36,6 +34,7 @@ function InfoHover({ text }: { text: string }) {
export function AppAside() {
const sequence = useStoreSelector((state) => state.sequence);
+ const answers = useStoreSelector((state) => state.answers);
const { toggleStudyBrowser } = useStoreActions();
const studyConfig = useStudyConfig();
@@ -48,13 +47,6 @@ export function AppAside() {
const isAnalysis = useIsAnalysis();
- const fullOrder = useMemo(() => {
- let r = structuredClone(studyConfig.sequence) as ComponentBlockWithOrderPath;
- r = addPathToComponentBlock(r, 'root') as ComponentBlockWithOrderPath;
- r.components.push('end');
- return r;
- }, [studyConfig.sequence]);
-
const [activeTab, setActiveTab] = useState('participant');
const nextParticipantDisabled = useMemo(() => activeTab === 'allTrials' || isAnalysis, [activeTab, isAnalysis]);
@@ -135,16 +127,23 @@ export function AppAside() {
-
+
@@ -152,18 +151,18 @@ export function AppAside() {
Participant View
-
- All Trials View
-
+
+ Browse Components
+
-
-
+
+
-
-
+
+
diff --git a/src/components/interface/StepsPanel.tsx b/src/components/interface/StepsPanel.tsx
index e4f680e580..971108a6ea 100644
--- a/src/components/interface/StepsPanel.tsx
+++ b/src/components/interface/StepsPanel.tsx
@@ -1,489 +1,805 @@
import {
- Badge, Box, NavLink, HoverCard, Text, Tooltip, Code, Flex, Button,
+ useCallback, useEffect, useMemo, useState, useRef,
+} from 'react';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import {
+ Badge,
+ Box,
+ Code,
+ Flex,
+ HoverCard,
+ NavLink,
+ Text,
+ Tooltip,
+ Button,
} from '@mantine/core';
-import { useNavigate, useParams, useSearchParams } from 'react-router';
import {
- IconArrowsShuffle, IconBrain, IconCheck, IconPackageImport, IconX, IconDice3, IconDice5, IconInfoCircle,
+ IconArrowsShuffle, IconBrain, IconCheck, IconChevronUp, IconDice3, IconDice5, IconInfoCircle,
+ IconPackageImport,
+ IconX,
} from '@tabler/icons-react';
-import { useMemo, useState } from 'react';
-import {
- ComponentBlock, DynamicBlock, ParticipantData, StudyConfig, Response,
-} from '../../parser/types';
+import { useNavigate } from 'react-router';
+import { ParticipantData, Response, StudyConfig } from '../../parser/types';
import { Sequence, StoredAnswer } from '../../store/types';
-import { useCurrentStep, useStudyId } from '../../routes/utils';
-import { getSequenceFlatMap } from '../../utils/getSequenceFlatMap';
-import { decryptIndex, encryptIndex } from '../../utils/encryptDecryptIndex';
-import { useFlatSequence, useStoreSelector } from '../../store/store';
-import { studyComponentToIndividualComponent } from '../../utils/handleComponentInheritance';
+import { addPathToComponentBlock } from '../../utils/getSequenceFlatMap';
+import { useStudyId } from '../../routes/utils';
+import { encryptIndex } from '../../utils/encryptDecryptIndex';
+import { isDynamicBlock } from '../../parser/utils';
import { componentAnswersAreCorrect } from '../../utils/correctAnswer';
-export type ComponentBlockWithOrderPath =
- Omit & { orderPath: string; components: (ComponentBlockWithOrderPath | string)[]; interruptions?: { components: string[] }[] }
- | (DynamicBlock & { orderPath: string; interruptions?: { components: string[] }[]; components: (ComponentBlockWithOrderPath | string)[]; });
+function hasRandomization(responses: Response[]) {
+ return responses.some((response) => {
+ if (response.type === 'radio' || response.type === 'checkbox' || response.type === 'buttons') {
+ return response.optionOrder === 'random';
+ }
+ if (response.type === 'matrix-radio' || response.type === 'matrix-checkbox') {
+ return response.questionOrder === 'random';
+ }
+ return false;
+ });
+}
-function findTaskIndexInSequence(sequence: Sequence, step: string, startIndex: number, requestedPath: string): number {
- let index = 0;
+function findMatchingComponentInFullOrder(
+ sequence: Sequence,
+ fullOrder: Sequence,
+): Sequence | null {
+ let studySequence: Sequence | null = null;
- // Loop through the sequence components and find the index of the task if it's in this block
- for (let i = 0; i < sequence.components.length; i += 1) {
- const component = sequence.components[i];
- if (typeof component === 'string') {
- if (requestedPath === sequence.orderPath && component === step && i >= startIndex) {
- break;
- }
- index += 1;
- } else {
- if (component.order === 'dynamic') {
- index += 1;
- } else {
- // See if the task is in the nested sequence
- index += findTaskIndexInSequence(component, step, startIndex, requestedPath);
- }
+ const findMatchingSequence = (node: string | Sequence) => {
+ if (typeof node === 'string') {
+ return;
+ }
- // If the task is in the nested sequence, break the loop. We need includes, because we need to break the loop if the task is in the nested sequence
- if (requestedPath.includes(component.orderPath)) {
- break;
- }
+ // Match by orderPath if available
+ if (node.orderPath && sequence.orderPath && node.orderPath === sequence.orderPath) {
+ studySequence = node;
+ return;
}
- }
+ node.components.forEach((child) => {
+ if (studySequence === null) {
+ findMatchingSequence(child);
+ }
+ });
+ };
- return index;
+ findMatchingSequence(fullOrder);
+ return studySequence;
}
-function countInterruptionsRecursively(configSequence: ComponentBlockWithOrderPath, participantSequence: Sequence) {
+function countComponentsInSequence(sequence: Sequence, participantAnswers: ParticipantData['answers']) {
let count = 0;
- // Loop through the participant sequence and count the interruptions that are defined in the configSequence
- participantSequence.components.forEach((component) => {
- if (typeof component === 'string' && configSequence.interruptions?.flatMap((i) => i.components).includes(component)) {
+ // TODO: Handle dynamic blocks properly
+ if (isDynamicBlock(sequence)) {
+ return Object.entries(participantAnswers).filter(([key, _]) => key.startsWith(`${sequence.id}_`)).length;
+ }
+
+ sequence.components.forEach((component) => {
+ if (typeof component === 'string') {
count += 1;
- } else if (typeof component !== 'string') {
- // If the component is a sequence, find the corresponding sequence in the configSequence and count the interruptions
- const configSubSequence = configSequence.components.find((c) => typeof c !== 'string' && c.orderPath === component.orderPath) as ComponentBlockWithOrderPath;
- count += countInterruptionsRecursively(configSubSequence, component);
+ } else {
+ count += countComponentsInSequence(component, participantAnswers);
}
});
return count;
}
-function reorderComponents(configSequence: ComponentBlockWithOrderPath['components'], participantSequence: Sequence['components']) {
- const newComponents: (string | ComponentBlockWithOrderPath)[] = [];
-
- // Iterate through the sequence components and reorder the orderComponents
- participantSequence.forEach((sequenceComponent) => {
- // Find the index of the sequenceComponent in the configSequence
- const configSequenceIndex = configSequence.findIndex((c) => {
- if (typeof c === 'string') {
- return c === sequenceComponent;
- }
- return typeof sequenceComponent !== 'string' && c.orderPath === sequenceComponent.orderPath;
- });
-
- if (configSequenceIndex !== -1) {
- newComponents.push(configSequence[configSequenceIndex]);
- configSequence.splice(configSequenceIndex, 1);
+type StepItem = {
+ label: string;
+ indentLevel: number;
+ path: string; // Unique path from root for stable keying
+
+ // Component Attributes
+ href?: string;
+ isInterruption?: boolean;
+ isLibraryImport?: boolean;
+ component?: StudyConfig['components'][string];
+ componentAnswer?: StoredAnswer;
+ componentName?: string; // Full component name (e.g., package.co.ComponentName)
+
+ // Block Attributes
+ order?: Sequence['order'];
+ orderPath?: string; // Order path for blocks
+ numInterruptions?: number;
+ numComponentsInSequence?: number;
+ numComponentsInStudySequence?: number;
+ childrenRange?: { start: number; end: number }; // Pre-computed indices of children in fullFlatTree
+ isExcluded?: boolean; // Block was excluded from participant sequence
+};
+
+/**
+ * Find blocks in sequenceBlock that are missing from participantNode by comparing orderPath
+ */
+function findExcludedBlocks(
+ sequenceBlock: Sequence,
+ participantNode: Sequence,
+): Sequence[] {
+ const excludedBlocks: Sequence[] = [];
+
+ // Get all block orderPaths from participant sequence
+ const participantBlockOrderPaths = new Set();
+ participantNode.components.forEach((comp) => {
+ if (typeof comp !== 'string' && comp.orderPath) {
+ participantBlockOrderPaths.add(comp.orderPath);
}
+ });
- if (configSequenceIndex === -1 && typeof sequenceComponent === 'string') {
- newComponents.push(sequenceComponent);
+ // Find blocks in study sequence that aren't in participant sequence based on orderPath
+ sequenceBlock.components.forEach((comp) => {
+ if (typeof comp !== 'string' && comp.orderPath && !participantBlockOrderPaths.has(comp.orderPath)) {
+ excludedBlocks.push(comp);
}
});
- if (configSequence) {
- newComponents.push(...configSequence);
- }
-
- return newComponents;
+ return excludedBlocks;
}
-function hasRandomization(responses: Response[]) {
- return responses.some((response) => {
- if (response.type === 'radio' || response.type === 'checkbox' || response.type === 'buttons') {
- return response.optionOrder === 'random';
+/**
+ * Find components (strings) in sequenceBlock that are missing from participantNode
+ */
+function findExcludedComponents(
+ sequenceBlock: Sequence,
+ participantNode: Sequence,
+): string[] {
+ const excludedComponents: string[] = [];
+
+ // Get all component names from participant sequence
+ const participantComponents = new Set();
+ participantNode.components.forEach((comp) => {
+ if (typeof comp === 'string') {
+ participantComponents.add(comp);
}
- if (response.type === 'matrix-radio' || response.type === 'matrix-checkbox') {
- return response.questionOrder === 'random';
+ });
+
+ // Find components in study sequence that aren't in participant sequence
+ sequenceBlock.components.forEach((comp) => {
+ if (typeof comp === 'string' && !participantComponents.has(comp)) {
+ excludedComponents.push(comp);
}
- return false;
});
+
+ return excludedComponents;
}
-function StepItem({
- step,
- disabled,
- fullSequence,
- startIndex,
- interruption,
- participantView,
+export function StepsPanel({
+ participantSequence,
+ participantAnswers,
studyConfig,
- subSequence,
- analysisNavigation,
- parentBlock,
- parentActive,
- answers,
+ isAnalysis,
}: {
- step: string;
- disabled: boolean;
- fullSequence: Sequence;
- startIndex: number;
- interruption: boolean;
- participantView: boolean;
+ participantSequence?: Sequence;
+ participantAnswers: ParticipantData['answers'];
studyConfig: StudyConfig;
- subSequence?: Sequence;
- analysisNavigation?: boolean;
- parentBlock: Sequence;
- parentActive: boolean;
- answers: ParticipantData['answers'];
+ isAnalysis?: boolean;
}) {
- const studyId = useStudyId();
- const navigate = useNavigate();
- const currentStep = useCurrentStep();
-
- const task = studyConfig.components[step] && studyComponentToIndividualComponent(studyConfig.components[step], studyConfig);
+ // Constants
+ const INITIAL_CLAMP = 6;
+ const ROW_HEIGHT = 32; // px, fixed height for each row
+ const INDENT_SIZE = 24; // px, indentation per level
+ const BASE_PADDING = 12; // px, base left padding
+ const VIRTUALIZER_OVERSCAN = 10; // number of items to render outside viewport
- const stepIndex = subSequence && subSequence.components.slice(startIndex).includes(step) ? findTaskIndexInSequence(fullSequence, step, startIndex, subSequence.orderPath) : -1;
+ // Per-row clamp state, keyed by idx
+ const [correctAnswerClampMap, setCorrectAnswerClampMap] = useState>({});
+ const [responseClampMap, setResponseClampMap] = useState>({});
+ const [fullFlatTree, setFullFlatTree] = useState([]);
+ const [renderedFlatTree, setRenderedFlatTree] = useState([]);
- const { trialId, funcIndex, analysisTab } = useParams();
- const [searchParams] = useSearchParams();
- const participantId = useMemo(() => searchParams.get('participantId'), [searchParams]);
- // eslint-disable-next-line react-hooks/rules-of-hooks
- const flatSequence = analysisTab ? [] : useFlatSequence();
+ const studyId = useStudyId();
+ const navigate = useNavigate();
- const analysisActive = trialId === step;
- const dynamicActive = parentActive && parentBlock && funcIndex ? startIndex === decryptIndex(funcIndex) : false;
- const studyActive = participantView ? (currentStep === stepIndex || dynamicActive) : currentStep === `reviewer-${step}`;
- const active = analysisNavigation ? analysisActive : studyActive;
+ const fullOrder = useMemo(() => {
+ let r = structuredClone(studyConfig.sequence) as Sequence;
+ r = addPathToComponentBlock(r, 'root') as Sequence;
+ r.components.push('end');
+ return r;
+ }, [studyConfig.sequence]);
+
+ // Memoize hasRandomization checks for all components
+ const componentHasRandomization = useMemo(() => {
+ const map = new Map();
+ Object.entries(studyConfig.components).forEach(([key, component]) => {
+ if (component.response) {
+ map.set(key, hasRandomization(component.response));
+ }
+ });
+ return map;
+ }, [studyConfig.components]);
+
+ useEffect(() => {
+ let newFlatTree: StepItem[] = [];
+ if (participantSequence === undefined) {
+ // Browse Components
+ newFlatTree = Object.keys(studyConfig.components).map((key) => {
+ const coOrComponents = key.includes('.co.')
+ ? '.co.'
+ : (key.includes('.components.') ? '.components.' : false);
+
+ return {
+ label: coOrComponents ? key.split(coOrComponents).at(-1)! : key,
+ indentLevel: 0,
+ path: `browse.${key}`,
+ href: `/${studyId}/reviewer-${key}`,
+ isLibraryImport: coOrComponents !== false,
+ componentName: key,
+ };
+ });
+ } else {
+ // Participant view
+
+ // Pre-compute expensive lookups to avoid O(n²) complexity
+ const matchingStudySequenceCache = new Map();
+ const componentCountCache = new Map();
+
+ // Indices to keep track of component positions, used for navigation hrefs
+ let idx = 0;
+ let dynamicIdx = 0;
+
+ const traverse = (node: string | Sequence, indentLevel: number, parentNode: Sequence, parentPath: string, dynamic = false) => {
+ if (typeof node === 'string') {
+ // Check to see if the component is from imported library
+ const coOrComponents = node.includes('.co.')
+ ? '.co.'
+ : (node.includes('.components.') ? '.components.' : false);
+
+ // Generate component identifier for participantAnswers lookup
+ const componentIdentifier = dynamic ? `${parentNode.id}_${idx}_${node}_${dynamicIdx}` : `${node}_${idx}`;
+ const componentPath = `${parentPath}.${node}_${dynamic ? dynamicIdx : idx}`;
+
+ newFlatTree.push({
+ label: coOrComponents ? node.split(coOrComponents).at(-1)! : node,
+ indentLevel,
+ path: componentPath,
+ isLibraryImport: coOrComponents !== false,
+
+ // Component Attributes
+ href: dynamic ? `/${studyId}/${encryptIndex(idx)}/${encryptIndex(dynamicIdx)}` : `/${studyId}/${encryptIndex(idx)}`,
+ isInterruption: (parentNode.interruptions || []).flatMap((intr) => intr.components).includes(node),
+ component: studyConfig.components[node],
+ componentAnswer: participantAnswers[componentIdentifier],
+ componentName: node,
+ });
+
+ if (dynamic) {
+ dynamicIdx += 1;
+ } else {
+ idx += 1;
+ }
+
+ // Return, this is the recursive base case
+ return;
+ }
+
+ const blockInterruptions = (node.interruptions || []).flatMap((intr) => intr.components);
+ const blockPath = `${parentPath}.${node.id ?? node.order}`;
+
+ // Use cache for expensive lookups
+ const cacheKey = node.orderPath || blockPath;
+ let matchingStudySequence = matchingStudySequenceCache.get(cacheKey);
+ if (matchingStudySequence === undefined) {
+ matchingStudySequence = findMatchingComponentInFullOrder(node, fullOrder);
+ matchingStudySequenceCache.set(cacheKey, matchingStudySequence);
+ }
+
+ // Cache component counts
+ let numComponentsInSequence = componentCountCache.get(node);
+ if (numComponentsInSequence === undefined) {
+ numComponentsInSequence = countComponentsInSequence(node, participantAnswers);
+ componentCountCache.set(node, numComponentsInSequence);
+ }
+
+ let numComponentsInStudySequence = componentCountCache.get(matchingStudySequence || node);
+ if (numComponentsInStudySequence === undefined) {
+ numComponentsInStudySequence = countComponentsInSequence(matchingStudySequence || node, participantAnswers);
+ if (matchingStudySequence) {
+ componentCountCache.set(matchingStudySequence, numComponentsInStudySequence);
+ }
+ }
+
+ // Push the block itself
+ newFlatTree.push({
+ label: node.id ?? node.order,
+ indentLevel,
+ path: blockPath,
+
+ // Block Attributes
+ order: node.order,
+ orderPath: node.orderPath,
+ numInterruptions: node.components.filter((comp) => typeof comp === 'string' && blockInterruptions.includes(comp)).length,
+ numComponentsInSequence,
+ numComponentsInStudySequence,
+ });
+
+ // Reset dynamicIdx when entering a new dynamic block
+ if (node.order === 'dynamic') {
+ dynamicIdx = 0;
+ }
+
+ // Loop through components, including any dynamic components added via participantAnswers
+ const dynamicComponents = Object.entries(participantAnswers).filter(([key, _]) => key.startsWith(`${node.id}_${idx}`)).map(([_, value]) => value.componentName);
+ const blockComponents = [...node.components, ...dynamicComponents];
+ if (blockComponents.length > 0) {
+ blockComponents.forEach((child) => {
+ traverse(child, indentLevel + 1, node, blockPath, node.order === 'dynamic');
+ });
+ }
+
+ // After processing all children, check for excluded blocks and components from the study sequence
+ // Reuse the cached matchingStudySequence from above
+ if (matchingStudySequence) {
+ // First, add excluded components (strings)
+ const excludedComponents = findExcludedComponents(matchingStudySequence, node);
+ excludedComponents.forEach((excludedComponent) => {
+ const coOrComponents = excludedComponent.includes('.co.')
+ ? '.co.'
+ : (excludedComponent.includes('.components.') ? '.components.' : false);
+ const excludedComponentPath = `${blockPath}.${excludedComponent}_excluded`;
+
+ newFlatTree.push({
+ label: coOrComponents ? excludedComponent.split(coOrComponents).at(-1)! : excludedComponent,
+ indentLevel: indentLevel + 1,
+ path: excludedComponentPath,
+ isLibraryImport: coOrComponents !== false,
+ component: studyConfig.components[excludedComponent],
+ componentName: excludedComponent,
+ isExcluded: true,
+ });
+ });
+
+ // Then, add excluded blocks
+ const excludedBlocks = findExcludedBlocks(matchingStudySequence, node);
+ excludedBlocks.forEach((excludedBlock) => {
+ const excludedBlockPath = `${blockPath}.${excludedBlock.id ?? excludedBlock.order}_excluded`;
+
+ // Add the excluded block
+ newFlatTree.push({
+ label: excludedBlock.id ?? excludedBlock.order,
+ indentLevel: indentLevel + 1,
+ path: excludedBlockPath,
+
+ // Block Attributes
+ order: excludedBlock.order,
+ orderPath: excludedBlock.orderPath,
+ numInterruptions: 0,
+ numComponentsInSequence: 0,
+ numComponentsInStudySequence: countComponentsInSequence(excludedBlock, participantAnswers),
+ isExcluded: true,
+ });
+
+ // Recursively add excluded block's children as excluded
+ const traverseExcluded = (excludedNode: Sequence, excludedIndentLevel: number, excludedParentPath: string) => {
+ excludedNode.components.forEach((child) => {
+ if (typeof child === 'string') {
+ const coOrComponents = child.includes('.co.')
+ ? '.co.'
+ : (child.includes('.components.') ? '.components.' : false);
+ const childPath = `${excludedParentPath}.${child}_excluded`;
+
+ newFlatTree.push({
+ label: coOrComponents ? child.split(coOrComponents).at(-1)! : child,
+ indentLevel: excludedIndentLevel,
+ path: childPath,
+ isLibraryImport: coOrComponents !== false,
+ component: studyConfig.components[child],
+ componentName: child,
+ isExcluded: true,
+ });
+ } else {
+ const childBlockPath = `${excludedParentPath}.${child.id ?? child.order}_excluded`;
+
+ newFlatTree.push({
+ label: child.id ?? child.order,
+ indentLevel: excludedIndentLevel,
+ path: childBlockPath,
+ order: child.order,
+ orderPath: child.orderPath,
+ numInterruptions: 0,
+ numComponentsInSequence: 0,
+ numComponentsInStudySequence: countComponentsInSequence(child, participantAnswers),
+ isExcluded: true,
+ });
+
+ traverseExcluded(child, excludedIndentLevel + 1, childBlockPath);
+ }
+ });
+ };
+
+ traverseExcluded(excludedBlock, indentLevel + 2, excludedBlockPath);
+ });
+ }
+ };
+
+ traverse(participantSequence, 0, participantSequence, 'root');
+ }
- const analysisNavigateTo = trialId ? `./../${step}` : `./${step}`;
- const dynamicNavigateTo = parentBlock.order === 'dynamic' ? `/${studyId}/${encryptIndex(flatSequence.indexOf(parentBlock.id!))}/${encryptIndex(startIndex)}` : '';
- const studyNavigateTo = participantView ? (parentBlock.order === 'dynamic' ? dynamicNavigateTo : `/${studyId}/${encryptIndex(stepIndex)}`) : `/${studyId}/reviewer-${step}`;
- const navigateTo = analysisNavigation ? () => navigate(analysisNavigateTo) : () => navigate(`${studyNavigateTo}${participantId ? `?participantId=${participantId}` : ''}`);
+ // Pre-compute children ranges for blocks for O(1) collapse/expand
+ for (let i = 0; i < newFlatTree.length; i += 1) {
+ const item = newFlatTree[i];
+ if (item.order !== undefined) { // It's a block
+ const startIndentLevel = item.indentLevel;
+ let endIndex = i + 1;
+ while (endIndex < newFlatTree.length && newFlatTree[endIndex].indentLevel > startIndentLevel) {
+ endIndex += 1;
+ }
+ item.childrenRange = { start: i + 1, end: endIndex };
+ }
+ }
- const coOrComponents = step.includes('.co.')
- ? '.co.'
- : (step.includes('.components.') ? '.components.' : false);
- const cleanedStep = step.includes('$') && coOrComponents && step.includes(coOrComponents) ? step.split(coOrComponents).at(-1) : step;
+ // Map over tree and set correctAnswerClampMap and responseClampMap
+ const clampMap = Object.fromEntries(newFlatTree.map((item) => {
+ if (item.component) {
+ return [item.href!, INITIAL_CLAMP];
+ }
+ return null;
+ }).filter((item): item is [string, number] => item !== null));
+ setCorrectAnswerClampMap(structuredClone(clampMap));
+ setResponseClampMap(structuredClone(clampMap));
+
+ // Set full and rendered flat tree
+ setFullFlatTree(newFlatTree);
+ setRenderedFlatTree(newFlatTree);
+ }, [fullOrder, participantAnswers, participantSequence, studyConfig.components, studyId]);
+
+ const collapseBlock = useCallback((startIndex: number, startItem: StepItem) => {
+ setRenderedFlatTree((prevRenderedFlatTree) => {
+ // Dynamically calculate children based on indent level in renderedFlatTree
+ const startIndentLevel = startItem.indentLevel;
+ let endIndex = startIndex + 1;
+
+ // Find all children (items with greater indent level)
+ while (endIndex < prevRenderedFlatTree.length && prevRenderedFlatTree[endIndex].indentLevel > startIndentLevel) {
+ endIndex += 1;
+ }
- const matchingAnswer = parentBlock.order === 'dynamic' ? Object.entries(answers).find(([key, _]) => key === `${parentBlock.id}_${flatSequence.indexOf(parentBlock.id!)}_${cleanedStep}_${startIndex}`) : Object.entries(answers).find(([key, _]) => key === `${cleanedStep}_${stepIndex}` || key === `${step}_${stepIndex}`);
- const taskAnswer: StoredAnswer | null = matchingAnswer ? matchingAnswer[1] : null;
+ const numChildren = endIndex - (startIndex + 1);
- const correctAnswer = taskAnswer && taskAnswer.correctAnswer.length > 0 && Object.keys(taskAnswer.answer).length > 0 && taskAnswer.correctAnswer;
- const correct = correctAnswer && taskAnswer && componentAnswersAreCorrect(taskAnswer.answer, correctAnswer);
+ // Remove all children
+ return [
+ ...prevRenderedFlatTree.slice(0, startIndex + 1),
+ ...prevRenderedFlatTree.slice(startIndex + 1 + numChildren),
+ ];
+ });
+ }, []);
+
+ const expandBlock = useCallback((startIndex: number, startItem: StepItem) => {
+ setRenderedFlatTree((prevRenderedFlatTree) => {
+ // Find the items to insert from fullFlatTree based on the block's childrenRange
+ const { start, end } = startItem.childrenRange ?? { start: 0, end: 0 };
+
+ // Only insert direct children (depth = startItem.indentLevel + 1)
+ // We need to filter out children of collapsed blocks within the range
+ const itemsToInsert: StepItem[] = [];
+ const startIndentLevel = startItem.indentLevel;
+
+ for (let i = start; i < end; i += 1) {
+ const item = fullFlatTree[i];
+
+ // Only add items that are direct children
+ if (item.indentLevel === startIndentLevel + 1) {
+ itemsToInsert.push(item);
+ } else if (item.indentLevel > startIndentLevel + 1) {
+ // This is a nested child - check if its parent block is in itemsToInsert
+ // Find the most recent block at the parent level
+ let shouldInclude = false;
+ for (let j = itemsToInsert.length - 1; j >= 0; j -= 1) {
+ const potentialParent = itemsToInsert[j];
+ if (potentialParent.indentLevel < item.indentLevel
+ && potentialParent.indentLevel === item.indentLevel - 1
+ && potentialParent.order !== undefined) {
+ // This item's parent is in the list, so include it
+ shouldInclude = true;
+ break;
+ }
+ if (potentialParent.indentLevel < item.indentLevel - 1) {
+ break;
+ }
+ }
+ if (shouldInclude) {
+ itemsToInsert.push(item);
+ }
+ }
+ }
- const correctIncorrectIcon = taskAnswer && correctAnswer ? (
- correct
- ?
- :
- ) : null;
+ // Create new array with the items inserted after startIndex
+ return [
+ ...prevRenderedFlatTree.slice(0, startIndex + 1),
+ ...itemsToInsert,
+ ...prevRenderedFlatTree.slice(startIndex + 1),
+ ];
+ });
+ }, [fullFlatTree]);
- const INITIAL_CLAMP = 6;
- const responseJSONText = task && JSON.stringify(task.response, null, 2);
- const [responseClamp, setResponseClamp] = useState(INITIAL_CLAMP);
+ // Virtualizer setup
+ const parentRef = useRef(null);
+ const rowCount = renderedFlatTree.length;
- const correctAnswerJSONText = taskAnswer && taskAnswer.correctAnswer.length > 0
- ? JSON.stringify(taskAnswer.correctAnswer, null, 2)
- : task && task.correctAnswer
- ? JSON.stringify(task.correctAnswer, null, 2)
- : undefined;
- const [correctAnswerClamp, setCorrectAnswerClamp] = useState(INITIAL_CLAMP);
+ const virtualizer = useVirtualizer({
+ count: rowCount,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => ROW_HEIGHT,
+ overscan: VIRTUALIZER_OVERSCAN,
+ });
return (
-
-
- {interruption && (
-
-
-
- )}
- {step !== cleanedStep && (
-
-
-
- )}
- {task?.responseOrder === 'random' && (
-
-
-
- )}
- {(task?.response && hasRandomization(task.response)) && (
-
-
-
- )}
- {correctIncorrectIcon}
-
+
+ {virtualizer.getVirtualItems().map((virtualRow) => {
+ const idx = virtualRow.index;
+ const {
+ label,
+ indentLevel,
+ isLibraryImport,
+ href,
+ isInterruption,
+ component,
+ componentAnswer,
+ componentName,
+ order,
+ orderPath,
+ numInterruptions,
+ numComponentsInSequence,
+ numComponentsInStudySequence,
+ isExcluded,
+ } = renderedFlatTree[idx];
+ const isComponent = order === undefined;
+ const correctAnswer = componentAnswer?.correctAnswer?.length
+ ? componentAnswer.correctAnswer
+ : component?.correctAnswer;
+ const correct = correctAnswer
+ && componentAnswer
+ && Object.keys(componentAnswer.answer).length > 0
+ && componentAnswersAreCorrect(componentAnswer.answer, correctAnswer);
+ const correctIncorrectIcon = correctAnswer && componentAnswer && componentAnswer?.endTime > -1
+ ? (correct
+ ?
+ :
+ )
+ : null;
+ const correctAnswerJSONText = correctAnswer
+ ? JSON.stringify(correctAnswer, null, 2)
+ : undefined;
+ const responseJSONText = component && JSON.stringify(component.response, null, 2);
+ const parameters = componentAnswer && 'parameters' in componentAnswer
+ ? componentAnswer.parameters
+ : component && 'parameters' in component
+ ? component.parameters
+ : undefined;
+ const blockIsCollapsed = !isComponent && (
+ renderedFlatTree[idx + 1]?.indentLevel === undefined
+ || renderedFlatTree[idx + 1]?.indentLevel <= indentLevel
+ );
+ return (
+
- {cleanedStep}
-
- {cleanedStep !== 'end' && (
-
-
-
- )}
-
- )}
- onClick={navigateTo}
- disabled={disabled && parentBlock.order !== 'dynamic'}
- />
- {task && (
-
-
-
-
- Name:
-
- {' '}
-
- {cleanedStep}
-
-
- {task.description && (
-
-
- Description:
-
- {' '}
-
- {task.description}
-
-
- )}
- {('parameters' in task || taskAnswer) && (
-
-
- Parameters:
-
- {' '}
- {taskAnswer && JSON.stringify(Object.keys(taskAnswer).length > 0 ? taskAnswer.parameters : ('parameters' in task ? task.parameters : {}), null, 2)}
-
- )}
- {taskAnswer && Object.keys(taskAnswer.answer).length > 0 && (
-
-
- {correctIncorrectIcon}
- Participant Answer:
-
- {' '}
- {JSON.stringify(taskAnswer.answer, null, 2)}
-
- )}
- {correctAnswerJSONText && (
-
-
- Correct Answer:
-
- {' '}
-
- {correctAnswerJSONText}
- {correctAnswerJSONText.split('\n').length > INITIAL_CLAMP && (
-
- {(correctAnswerClamp === undefined || correctAnswerJSONText.split('\n').length > correctAnswerClamp) && (
-
+
+ {
+ if (isComponent && href && !isExcluded) {
+ if (isAnalysis) {
+ navigate(`/analysis/stats/${studyId}/stats/${encodeURIComponent(String(componentName))}`);
+ } else {
+ navigate(href);
+ }
+ } else if (!isComponent) {
+ // Both included and excluded blocks can be collapsed/expanded
+ if (blockIsCollapsed) {
+ expandBlock(idx, renderedFlatTree[idx]);
+ } else {
+ collapseBlock(idx, renderedFlatTree[idx]);
+ }
+ }
+ }}
+ active={!isExcluded && href === window.location.pathname}
+ disabled={isExcluded && isComponent}
+ style={{
+ opacity: isExcluded ? 0.5 : 1,
+ cursor: isExcluded && isComponent ? 'not-allowed' : 'pointer',
+ }}
+ rightSection={
+ isComponent
+ ? undefined
+ :
+ }
+ label={(
+
+ {isInterruption && (
+
+
+
+ )}
+ {isLibraryImport && (
+
+
+
+ )}
+ {component?.responseOrder === 'random' && (
+
+
+
+ )}
+ {(componentName && componentHasRandomization.get(componentName)) && (
+
+
+
+ )}
+ {correctIncorrectIcon}
+
+ {label}
+
+ {isComponent && label !== 'end' && (
+
+
+
+ )}
+ {order === 'random' || order === 'latinSquare' ? (
+
+
+
+ ) : null}
+ {!isComponent && !isExcluded && (
+
+ {(numComponentsInSequence || 0) - (numInterruptions || 0)}
+ /
+ {numComponentsInStudySequence}
+
+ )}
+ {!isComponent && isExcluded && (
+
+ Excluded
+
+ )}
+ {numInterruptions !== undefined && numInterruptions > 0 && (
+
+ {numInterruptions}
+
)}
)}
-
-
- )}
-
-
- Response:
-
- {' '}
-
- {responseJSONText}
- {responseJSONText.split('\n').length > INITIAL_CLAMP && (
-
- {(responseClamp === undefined || responseJSONText.split('\n').length > responseClamp) && (
-
- )}
-
+ />
+ {isComponent && (
+
+
+
+
+ Name:
+
+ {' '}
+
+ {label}
+
+
+ {component && component.description && (
+
+
+ Description:
+
+ {' '}
+
+ {component.description}
+
+
+ )}
+ {parameters && (
+
+
+ Parameters:
+
+ {' '}
+ {JSON.stringify(parameters, null, 2)}
+
+ )}
+ {componentAnswer && Object.keys(componentAnswer.answer).length > 0 && (
+
+
+ {correctIncorrectIcon}
+ Participant Answer:
+
+ {' '}
+ {JSON.stringify(componentAnswer.answer, null, 2)}
+
+ )}
+ {correctAnswerJSONText && (
+
+
+ Correct Answer:
+
+ {' '}
+
+ {correctAnswerJSONText}
+ {correctAnswerJSONText.split('\n').length > INITIAL_CLAMP && (
+
+ {(correctAnswerClampMap[href!] === undefined || correctAnswerJSONText.split('\n').length > (correctAnswerClampMap[href!] || -1)) && (
+
+ )}
+
+ )}
+
+
+ )}
+ {component && responseJSONText && (
+
+
+ Response:
+
+ {' '}
+
+ {JSON.stringify(component.response, null, 2)}
+ {responseJSONText.split('\n').length > INITIAL_CLAMP && (
+
+ {(responseClampMap[href!] === undefined || responseJSONText.split('\n').length > (responseClampMap[href!] || -1)) && (
+
+ )}
+
+ )}
+
+
+ )}
+ {component && component.meta && (
+
+ Task Meta:
+ {JSON.stringify(component.meta, null, 2)}
+
+ )}
+
+
)}
-
-
- {task.meta && (
-
- Task Meta:
- {JSON.stringify(task.meta, null, 2)}
+
- )}
-
-
- )}
-
- );
-}
-
-export function StepsPanel({
- configSequence,
- fullSequence,
- participantSequence,
- participantView,
- studyConfig,
- analysisNavigation,
-}: {
- configSequence: ComponentBlockWithOrderPath;
- fullSequence: Sequence;
- participantSequence?: Sequence;
- participantView: boolean;
- studyConfig: StudyConfig;
- analysisNavigation?: boolean;
-}) {
- // If the participantSequence is provided, reorder the components
- let components = structuredClone(configSequence.components);
- if (participantSequence && participantView) {
- const reorderedComponents = reorderComponents(structuredClone(configSequence.components), structuredClone(participantSequence.components));
- components = reorderedComponents;
- }
-
- // Hacky. This call is not conditional, it either always happens or never happens. Not ideal.
- const { analysisTab } = useParams();
- let answers: ParticipantData['answers'] = {};
- if (!analysisTab) {
- // eslint-disable-next-line react-hooks/rules-of-hooks
- answers = useStoreSelector((state) => state.answers);
- }
-
- if (!participantView) {
- // Add interruptions to the sequence
- components = [
- ...(configSequence.interruptions?.flatMap((interruption) => interruption.components) || []),
- ...(components || []),
- ];
- }
-
- // Count tasks - interruptions
- const sequenceStepsLength = useMemo(() => (participantSequence ? getSequenceFlatMap(participantSequence).length - countInterruptionsRecursively(configSequence, participantSequence) : 0), [configSequence, participantSequence]);
- const orderSteps = useMemo(() => getSequenceFlatMap(configSequence), [configSequence]);
-
- const [isPanelOpened, setIsPanelOpened] = useState(sequenceStepsLength > 0);
-
- const [searchParams] = useSearchParams();
- const participantId = useMemo(() => searchParams.get('participantId'), [searchParams]);
- const currentStep = useCurrentStep();
- // eslint-disable-next-line react-hooks/rules-of-hooks
- const flatSequence = analysisTab ? [] : useFlatSequence();
- const dynamicBlockActive = typeof currentStep === 'number' && configSequence.order === 'dynamic' && flatSequence[currentStep] === configSequence.id;
- const indexofDynamicBlock = (configSequence.id && flatSequence.indexOf(configSequence.id)) || -1;
-
- const studyId = useStudyId();
- const navigate = useNavigate();
- const navigateTo = () => navigate(`/${studyId}/${encryptIndex(indexofDynamicBlock)}/${encryptIndex(0)}${participantId ? `?participantId=${participantId}` : ''}`);
-
- const toLoopOver = [
- ...components,
- ...Object.entries(answers).filter(([key, _]) => key.startsWith(`${configSequence.id}_${indexofDynamicBlock}_`)).map(([_, value]) => value.componentName),
- ];
-
- return (
- 0 ? 1 : 0.5 }}>
-
- {configSequence.id ? configSequence.id : configSequence.order}
-
- {configSequence.order === 'random' || configSequence.order === 'latinSquare' ? (
-
-
-
- ) : null}
- {participantView && (
-
- {configSequence.order === 'dynamic' ? `${Object.keys(answers).filter((keys) => keys.startsWith(`${configSequence.id}_`)).length} / ?` : `${sequenceStepsLength}/${orderSteps.length}`}
-
- )}
- {participantView && configSequence.interruptions && (
-
- {participantSequence?.components.filter((s) => typeof s === 'string' && configSequence.interruptions?.flatMap((i) => i.components).includes(s)).length || 0}
-
- )}
-
- )}
- opened={isPanelOpened}
- onClick={() => (configSequence.order === 'dynamic' && !analysisNavigation && participantView ? navigateTo() : setIsPanelOpened(!isPanelOpened))}
- childrenOffset={32}
- style={{
- lineHeight: '32px',
- height: '32px',
- }}
- >
- {isPanelOpened ? (
-
- {toLoopOver.map((step, idx) => {
- if (typeof step === 'string') {
- return (
- i.components.includes(step)) > -1)) || false}
- participantView={participantView}
- studyConfig={studyConfig}
- subSequence={participantSequence}
- analysisNavigation={analysisNavigation}
- parentBlock={configSequence}
- parentActive={dynamicBlockActive}
- answers={answers}
- />
- );
- }
-
- const newSequence = participantSequence?.components.find((s) => typeof s !== 'string' && s.orderPath === step.orderPath) as Sequence | undefined;
-
- return (
-
- );
- })}
-
- ) : null }
-
+ );
+ })}
+
+
);
}
diff --git a/src/store/types.ts b/src/store/types.ts
index cc03b2150c..9d7953a8ab 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ProvenanceGraph } from '@trrack/core/graph/graph-slice';
import type {
- Answer, ConfigResponseBlockLocation, ParticipantData, ResponseBlockLocation, SkipConditions, StringOption, StudyConfig, ValueOf,
+ Answer, ComponentBlock, ConfigResponseBlockLocation, InterruptionBlock, ParticipantData, ResponseBlockLocation, SkipConditions, StringOption, StudyConfig, ValueOf,
} from '../parser/types';
import { type REVISIT_MODE } from '../storage/engines/types';
@@ -151,9 +151,10 @@ export interface StimulusParams {
export interface Sequence {
id?: string;
orderPath: string;
- order: string;
+ order: ComponentBlock['order'] | 'dynamic';
components: (string | Sequence)[];
- skip?: SkipConditions;
+ skip: SkipConditions;
+ interruptions?: InterruptionBlock[];
}
export type FormElementProvenance = { form: StoredAnswer['answer'] };
diff --git a/src/utils/getSequenceFlatMap.ts b/src/utils/getSequenceFlatMap.ts
index 2123cc56c0..d03d53f7fc 100644
--- a/src/utils/getSequenceFlatMap.ts
+++ b/src/utils/getSequenceFlatMap.ts
@@ -1,4 +1,3 @@
-import type { ComponentBlockWithOrderPath } from '../components/interface/StepsPanel';
import { DynamicBlock, StudyConfig } from '../parser/types';
import { isDynamicBlock } from '../parser/utils';
import { Sequence } from '../store/types';
@@ -96,14 +95,16 @@ export function findIndexOfBlock(sequence: Sequence, to: string): number {
return toReturn.found ? toReturn.distance : -1;
}
-export function addPathToComponentBlock(order: StudyConfig['sequence'] | ComponentBlockWithOrderPath | string, orderPath: string): ComponentBlockWithOrderPath | string {
+export function addPathToComponentBlock(order: StudyConfig['sequence'] | Sequence | string, orderPath: string): Sequence | string {
if (typeof order === 'string') {
return order;
}
if (isDynamicBlock(order)) {
- return { ...order, orderPath, components: [] };
+ return {
+ ...order, orderPath, components: [], skip: [],
+ };
}
return {
- ...order, orderPath, order: order.order, components: order.components.map((o, i) => addPathToComponentBlock(o, `${orderPath}-${i}`)),
+ ...order, orderPath, order: order.order, components: order.components.map((o, i) => addPathToComponentBlock(o, `${orderPath}-${i}`)), skip: order.skip || [],
};
}
diff --git a/src/utils/handleRandomSequences.tsx b/src/utils/handleRandomSequences.tsx
index 10b379cacd..343945475f 100644
--- a/src/utils/handleRandomSequences.tsx
+++ b/src/utils/handleRandomSequences.tsx
@@ -20,10 +20,32 @@ function shuffle(array: (string | ComponentBlock | DynamicBlock)[]) {
}
}
+function generateLatinSquare(config: StudyConfig, path: string) {
+ const pathArr = path.split('-');
+
+ let locationInSequence: Partial | Partial | string = {};
+ pathArr.forEach((p) => {
+ if (p === 'root') {
+ locationInSequence = config.sequence;
+ } else {
+ if (isDynamicBlock(locationInSequence as StudyConfig['sequence'])) {
+ return;
+ }
+ locationInSequence = (locationInSequence as ComponentBlock).components[+p];
+ }
+ });
+
+ const options = (locationInSequence as ComponentBlock).components.map((c: unknown, i: number) => (typeof c === 'string' ? c : `_componentBlock${i}`));
+ shuffle(options);
+ const newSquare: string[][] = latinSquare(options, true);
+ return newSquare;
+}
+
function _componentBlockToSequence(
order: StudyConfig['sequence'],
latinSquareObject: Record,
path: string,
+ config: StudyConfig,
): Sequence {
if (isDynamicBlock(order)) {
return {
@@ -32,6 +54,7 @@ function _componentBlockToSequence(
order: order.order,
components: [],
skip: [],
+ interruptions: [],
};
}
@@ -44,7 +67,16 @@ function _componentBlockToSequence(
computedComponents = randomArr;
} else if (order.order === 'latinSquare' && latinSquareObject) {
- computedComponents = latinSquareObject[path].pop()!.map((o) => {
+ const latinSquareRow = latinSquareObject[path]?.pop();
+
+ if (!latinSquareRow) {
+ throw new Error(
+ `Latin square exhausted for path: ${path}. `
+ + 'This should not happen as we pre-generate enough rows. Please report this issue.',
+ );
+ }
+
+ computedComponents = latinSquareRow.map((o) => {
if (o.startsWith('_componentBlock')) {
return order.components[+o.slice('_componentBlock'.length)];
}
@@ -55,11 +87,54 @@ function _componentBlockToSequence(
computedComponents = computedComponents.slice(0, order.numSamples);
+ // Pre-build a list of unique components with their indices to avoid O(n²) isEqual comparisons
+ // Since structuredClone breaks reference equality, we need to use value equality
+ const uniqueComponents: Array<{ component: ComponentBlock | DynamicBlock; indices: number[] }> = [];
+
+ for (let j = 0; j < order.components.length; j += 1) {
+ const comp = order.components[j];
+ if (typeof comp !== 'string' && !Array.isArray(comp)) {
+ // Find if we've already seen this component (by value)
+ let found = false;
+ for (const unique of uniqueComponents) {
+ if (isEqual(unique.component, comp)) {
+ unique.indices.push(j);
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ uniqueComponents.push({ component: comp, indices: [j] });
+ }
+ }
+ }
+
+ // Track how many times we've seen each unique component
+ const seenCounts = new Map();
+
for (let i = 0; i < computedComponents.length; i += 1) {
const curr = computedComponents[i];
if (typeof curr !== 'string' && !Array.isArray(curr)) {
- const index = order.components.findIndex((c) => isEqual(c, curr));
- computedComponents[i] = _componentBlockToSequence(curr, latinSquareObject, `${path}-${index}`) as unknown as ComponentBlock;
+ // Find the matching unique component
+ let matchedUnique = null;
+ for (const unique of uniqueComponents) {
+ if (isEqual(unique.component, curr)) {
+ matchedUnique = unique;
+ break;
+ }
+ }
+
+ if (matchedUnique) {
+ const seenCount = seenCounts.get(matchedUnique.component) || 0;
+ const actualIndex = matchedUnique.indices[seenCount] ?? matchedUnique.indices[0];
+ seenCounts.set(matchedUnique.component, seenCount + 1);
+
+ computedComponents[i] = _componentBlockToSequence(curr, latinSquareObject, `${path}-${actualIndex}`, config) as unknown as ComponentBlock;
+ } else {
+ // Fallback: shouldn't happen, but handle it
+ const index = order.components.findIndex((c) => isEqual(c, curr));
+ computedComponents[i] = _componentBlockToSequence(curr, latinSquareObject, `${path}-${index}`, config) as unknown as ComponentBlock;
+ }
}
}
@@ -110,17 +185,19 @@ function _componentBlockToSequence(
orderPath: path,
order: order.order,
components: computedComponents.flat() as Sequence['components'],
- skip: order.skip,
+ skip: order.skip || [],
+ interruptions: order.interruptions || [],
};
}
function componentBlockToSequence(
order: StudyConfig['sequence'],
latinSquareObject: Record,
+ config: StudyConfig,
): Sequence {
const orderCopy = structuredClone(order);
- return _componentBlockToSequence(orderCopy, latinSquareObject, 'root');
+ return _componentBlockToSequence(orderCopy, latinSquareObject, 'root', config);
}
function _createRandomOrders(order: StudyConfig['sequence'], paths: string[], path: string, index: number) {
@@ -147,31 +224,106 @@ function createRandomOrders(order: StudyConfig['sequence']) {
return paths;
}
-function generateLatinSquare(config: StudyConfig, path: string) {
- const pathArr = path.split('-');
+/**
+ * Count how many times each latin square path will be accessed during a single sequence generation.
+ * This is needed to pre-generate enough latin square rows to avoid refilling mid-sequence.
+ *
+ * This mirrors the logic in _componentBlockToSequence to ensure accurate counting.
+ */
+function _countPathUsage(
+ order: StudyConfig['sequence'],
+ pathCounts: Record,
+ path: string,
+): void {
+ if (isDynamicBlock(order)) {
+ return;
+ }
- let locationInSequence: Partial | Partial | string = {};
- pathArr.forEach((p) => {
- if (p === 'root') {
- locationInSequence = config.sequence;
- } else {
- if (isDynamicBlock(locationInSequence as StudyConfig['sequence'])) {
- return;
+ if (order.order === 'latinSquare') {
+ pathCounts[path] = (pathCounts[path] || 0) + 1;
+ }
+
+ // Get the components that will actually be processed
+ let computedComponents = order.components;
+
+ // Apply numSamples if present
+ if (order.numSamples !== undefined) {
+ computedComponents = computedComponents.slice(0, order.numSamples);
+ }
+
+ // Count recursively for nested blocks
+ // Pre-build a list of unique components with their indices (same approach as _componentBlockToSequence)
+ const uniqueComponents: Array<{ component: ComponentBlock | DynamicBlock; indices: number[] }> = [];
+
+ for (let j = 0; j < order.components.length; j += 1) {
+ const comp = order.components[j];
+ if (typeof comp !== 'string' && !Array.isArray(comp) && !isDynamicBlock(comp)) {
+ // Find if we've already seen this component (by value)
+ let found = false;
+ for (const unique of uniqueComponents) {
+ if (isEqual(unique.component, comp)) {
+ unique.indices.push(j);
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ uniqueComponents.push({ component: comp, indices: [j] });
}
- locationInSequence = (locationInSequence as ComponentBlock).components[+p];
}
- });
+ }
- const options = (locationInSequence as ComponentBlock).components.map((c: unknown, i: number) => (typeof c === 'string' ? c : `_componentBlock${i}`));
- shuffle(options);
- const newSquare: string[][] = latinSquare(options, true);
- return newSquare;
+ // Track how many times we've seen each unique component
+ const seenCounts = new Map();
+
+ for (let i = 0; i < computedComponents.length; i += 1) {
+ const curr = computedComponents[i];
+ if (typeof curr !== 'string' && !Array.isArray(curr) && !isDynamicBlock(curr)) {
+ // Find the matching unique component
+ let matchedUnique = null;
+ for (const unique of uniqueComponents) {
+ if (isEqual(unique.component, curr)) {
+ matchedUnique = unique;
+ break;
+ }
+ }
+
+ if (matchedUnique) {
+ const seenCount = seenCounts.get(matchedUnique.component) || 0;
+ const actualIndex = matchedUnique.indices[seenCount] ?? matchedUnique.indices[0];
+ seenCounts.set(matchedUnique.component, seenCount + 1);
+
+ _countPathUsage(curr, pathCounts, `${path}-${actualIndex}`);
+ } else {
+ // Fallback: shouldn't happen, but handle it
+ _countPathUsage(curr, pathCounts, `${path}-0`);
+ }
+ }
+ }
+}
+
+function countPathUsage(order: StudyConfig['sequence']): Record {
+ const pathCounts: Record = {};
+ _countPathUsage(order, pathCounts, 'root');
+ return pathCounts;
}
export function generateSequenceArray(config: StudyConfig): Sequence[] {
const paths = createRandomOrders(config.sequence);
+ const pathUsageCounts = countPathUsage(config.sequence);
+
+ // Pre-generate enough latin square rows for each path based on usage count
+ // We generate enough rows to cover the maximum usage in a single sequence
const latinSquareObject: Record = paths
- .map((p) => ({ [p]: generateLatinSquare(config, p) }))
+ .map((p) => {
+ const usageCount = pathUsageCounts[p] || 1;
+ // Generate multiple latin squares if needed and concatenate them
+ const rows: string[][] = [];
+ for (let i = 0; i < usageCount; i += 1) {
+ rows.push(...generateLatinSquare(config, p));
+ }
+ return { [p]: rows };
+ })
.reduce((acc, curr) => ({ ...acc, ...curr }), {});
const numSequences = config.uiConfig.numSequences || 1000;
@@ -179,16 +331,21 @@ export function generateSequenceArray(config: StudyConfig): Sequence[] {
const sequenceArray: Sequence[] = [];
Array.from({ length: numSequences }).forEach(() => {
// Generate a sequence
- const sequence = componentBlockToSequence(config.sequence, latinSquareObject);
+ const sequence = componentBlockToSequence(config.sequence, latinSquareObject, config);
sequence.components.push('end');
// Add the sequence to the array
sequenceArray.push(sequence);
- // Refill the latin square if it is empty
+ // Refill latin square arrays that are empty
Object.entries(latinSquareObject).forEach(([key, value]) => {
if (value.length === 0) {
- latinSquareObject[key] = generateLatinSquare(config, key);
+ const usageCount = pathUsageCounts[key] || 1;
+ const rows: string[][] = [];
+ for (let i = 0; i < usageCount; i += 1) {
+ rows.push(...generateLatinSquare(config, key));
+ }
+ latinSquareObject[key] = rows;
}
});
});
diff --git a/tests/test-reviewer-mode.spec.ts b/tests/test-reviewer-mode.spec.ts
index 48181baedf..94b6a3bdc7 100644
--- a/tests/test-reviewer-mode.spec.ts
+++ b/tests/test-reviewer-mode.spec.ts
@@ -13,17 +13,18 @@ test('test', async ({ browser }) => {
.nth(0)
.getByText('Go to Study')
.click();
- await page.getByRole('tab', { name: 'All Trials View' }).click();
+ await page.getByRole('tab', { name: 'Browse Components' }).click();
- await page.getByLabel('All Trials View').locator('a').filter({ hasText: 'barChart' }).click();
+ await page.getByLabel('Browse Components').locator('a').filter({ hasText: 'barChart' }).click();
const iframe = await page.frameLocator('iframe').getByRole('img');
await expect(iframe).toBeVisible();
- await page.getByLabel('All Trials View').locator('a').filter({ hasText: 'introduction' }).click();
+ await page.getByLabel('Browse Components').locator('a').filter({ hasText: 'introduction' }).click();
const introText = await page.getByText('Welcome to our study. This is');
await expect(introText).toBeVisible();
- await page.getByLabel('All Trials View').locator('a').filter({ hasText: 'end' }).click();
+ await page.getByRole('tab', { name: 'Participant View' }).click();
+ await page.getByLabel('Participant View').locator('a').filter({ hasText: 'end' }).click();
const endText = await page.getByText('Please wait');
await expect(endText).toBeVisible();
});
diff --git a/yarn.lock b/yarn.lock
index 81e6e5e3cc..02984d4841 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1832,6 +1832,13 @@
dependencies:
"@tanstack/virtual-core" "3.11.2"
+"@tanstack/react-virtual@^3.13.13":
+ version "3.13.13"
+ resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.13.13.tgz#1e32e1e64fb4f60d8e9dbdbde6b17e79d82696f8"
+ integrity sha512-4o6oPMDvQv+9gMi8rE6gWmsOjtUZUYIJHv7EB+GblyYdi8U6OqLl8rhHWIUZSL1dUU2dPwTdTgybCKf9EjIrQg==
+ dependencies:
+ "@tanstack/virtual-core" "3.13.13"
+
"@tanstack/table-core@8.20.5":
version "8.20.5"
resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.20.5.tgz#3974f0b090bed11243d4107283824167a395cf1d"
@@ -1842,7 +1849,12 @@
resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz#00409e743ac4eea9afe5b7708594d5fcebb00212"
integrity sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==
-"@trrack/core@^1.0.0", "@trrack/core@^1.3.0-beta.1":
+"@tanstack/virtual-core@3.13.13":
+ version "3.13.13"
+ resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.13.13.tgz#1e55efe82730e60f4d68b1e2bc956bd3d94f307b"
+ integrity sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==
+
+"@trrack/core@^1.0.0", "@trrack/core@^1.3.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@trrack/core/-/core-1.3.0.tgz#e432036548a5ba598ceacefab339d078b63214d8"
integrity sha512-uOWRwCvvvNURZLXoI4lIAs0wXc98EhiCn3YGkXbDdpRCsuxyaz5+baatyPENxocCLFoWBY7X9w11ceZUO7KCqQ==