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==