From f76534cbe74a234a076e1a74aa9f7674af15d468 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 10 Dec 2025 21:09:40 -0700 Subject: [PATCH 01/12] Update the StepsPanel code to render iteratively instead of recursively --- .../individualStudy/stats/StatsView.tsx | 15 +- src/components/interface/AppAside.tsx | 21 +- src/components/interface/StepsPanel.tsx | 836 +++++++++--------- src/store/types.ts | 7 +- src/utils/getSequenceFlatMap.ts | 9 +- src/utils/handleRandomSequences.tsx | 4 +- tests/test-reviewer-mode.spec.ts | 8 +- 7 files changed, 461 insertions(+), 439 deletions(-) diff --git a/src/analysis/individualStudy/stats/StatsView.tsx b/src/analysis/individualStudy/stats/StatsView.tsx index 0ff52ceb3a..150b854986 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..93563e7b49 100644 --- a/src/components/interface/AppAside.tsx +++ b/src/components/interface/AppAside.tsx @@ -15,7 +15,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 +23,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 +35,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 +48,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]); @@ -152,18 +145,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..d9e189d982 100644 --- a/src/components/interface/StepsPanel.tsx +++ b/src/components/interface/StepsPanel.tsx @@ -1,251 +1,416 @@ import { - Badge, Box, NavLink, HoverCard, Text, Tooltip, Code, Flex, Button, + useCallback, useEffect, useMemo, useState, +} from 'react'; +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); - } - - // 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; - } + const findMatchingSequence = (node: string | Sequence) => { + if (typeof node === 'string') { + return; } - } + if (node.id === sequence.id) { + 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)[] = []; +type StepItem = { + label: string; + indentLevel: number; - // 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; - }); + // Component Attributes + href?: string; + isInterruption?: boolean; + isLibraryImport?: boolean; + component?: StudyConfig['components'][string]; + componentAnswer?: StoredAnswer; - if (configSequenceIndex !== -1) { - newComponents.push(configSequence[configSequenceIndex]); - configSequence.splice(configSequenceIndex, 1); - } - - if (configSequenceIndex === -1 && typeof sequenceComponent === 'string') { - newComponents.push(sequenceComponent); - } - }); - - if (configSequence) { - newComponents.push(...configSequence); - } - - return newComponents; -} - -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; - }); -} + // Block Attributes + order?: Sequence['order']; + numInterruptions?: number; + numComponentsInSequence?: number; + numComponentsInStudySequence?: number; +}; -function StepItem({ - step, - disabled, - fullSequence, - startIndex, - interruption, - participantView, +export function StepsPanel({ + participantSequence, + participantAnswers, studyConfig, - subSequence, - analysisNavigation, - parentBlock, - parentActive, - answers, }: { - 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']; }) { + const INITIAL_CLAMP = 6; + // Per-row clamp state, keyed by idx + const [correctAnswerClampMap, setCorrectAnswerClampMap] = useState>({}); + const [responseClampMap, setResponseClampMap] = useState>({}); + const [fullFlatTree, setFullFlatTree] = useState([]); + const [renderedFlatTree, setRenderedFlatTree] = useState([]); + const studyId = useStudyId(); const navigate = useNavigate(); - const currentStep = useCurrentStep(); - const task = studyConfig.components[step] && studyComponentToIndividualComponent(studyConfig.components[step], studyConfig); + const fullOrder = useMemo(() => { + let r = structuredClone(studyConfig.sequence) as Sequence; + r = addPathToComponentBlock(r, 'root') as Sequence; + r.components.push('end'); + return r; + }, [studyConfig.sequence]); + + 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, + href: `/${studyId}/reviewer-${key}`, + isLibraryImport: coOrComponents !== false, + }; + }); + } else { + // Participant view + + // 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, 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}`; + + newFlatTree.push({ + label: coOrComponents ? node.split(coOrComponents).at(-1)! : node, + indentLevel, + 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], + }); + + if (dynamic) { + dynamicIdx += 1; + } else { + idx += 1; + } + + // Return, this is the recursive base case + return; + } + + const blockInterruptions = (node.interruptions || []).flatMap((intr) => intr.components); + const matchingStudySequence = findMatchingComponentInFullOrder(node, fullOrder); + + // Push the block itself + newFlatTree.push({ + label: node.id ?? node.order, + indentLevel, + + // Block Attributes + order: node.order, + numInterruptions: node.components.filter((comp) => typeof comp === 'string' && blockInterruptions.includes(comp)).length, + numComponentsInSequence: countComponentsInSequence(node, participantAnswers), + numComponentsInStudySequence: countComponentsInSequence(matchingStudySequence || node, participantAnswers)!, + }); + + // 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, node.order === 'dynamic'); + }); + } + }; + + traverse(participantSequence, 0, participantSequence); + } - const stepIndex = subSequence && subSequence.components.slice(startIndex).includes(step) ? findTaskIndexInSequence(fullSequence, step, startIndex, subSequence.orderPath) : -1; + // 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)); - 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(); + // Set full and rendered flat tree + setFullFlatTree(newFlatTree); + setRenderedFlatTree(newFlatTree); + }, [fullOrder, participantAnswers, participantSequence, studyConfig.components, studyId]); - 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 collapseBlock = useCallback((startIndex: number, startItem: StepItem) => { + const startIndentLevel = startItem.indentLevel; - 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}` : ''}`); + // Find the index of the next block at the same or less indent level so we can remove the sub-items + let endIndex = renderedFlatTree.findIndex((item, idx) => idx > startIndex && item.indentLevel <= startIndentLevel); - 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; + // If no next block, collapse to the end of the list + if (endIndex === -1) { + endIndex = renderedFlatTree.length; + } - 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; + // Create new array without the items between startIndex and endIndex + const newFlatTree = [ + ...renderedFlatTree.slice(0, startIndex + 1), + ...renderedFlatTree.slice(endIndex), + ]; + setRenderedFlatTree(newFlatTree); + }, [renderedFlatTree]); - const correctAnswer = taskAnswer && taskAnswer.correctAnswer.length > 0 && Object.keys(taskAnswer.answer).length > 0 && taskAnswer.correctAnswer; - const correct = correctAnswer && taskAnswer && componentAnswersAreCorrect(taskAnswer.answer, correctAnswer); + const expandBlock = useCallback((startIndex: number, startItem: StepItem) => { + const startIndentLevel = startItem.indentLevel; - const correctIncorrectIcon = taskAnswer && correctAnswer ? ( - correct - ? - : - ) : null; + const fullFlatStartIndex = fullFlatTree.findIndex((item) => item === startItem); - const INITIAL_CLAMP = 6; - const responseJSONText = task && JSON.stringify(task.response, null, 2); - const [responseClamp, setResponseClamp] = useState(INITIAL_CLAMP); + // Find all items in fullFlatTree that are children of the block being expanded + const itemsToInsert: StepItem[] = []; + for (let i = fullFlatStartIndex + 1; i < fullFlatTree.length; i += 1) { + const item = fullFlatTree[i]; + if (item.indentLevel <= startIndentLevel) { + break; + } + itemsToInsert.push(item); + } - const correctAnswerJSONText = taskAnswer && taskAnswer.correctAnswer.length > 0 - ? JSON.stringify(taskAnswer.correctAnswer, null, 2) - : task && task.correctAnswer - ? JSON.stringify(task.correctAnswer, null, 2) + // Create new array with the items inserted after startIndex + const newFlatTree = [ + ...renderedFlatTree.slice(0, startIndex + 1), + ...itemsToInsert, + ...renderedFlatTree.slice(startIndex + 1), + ]; + setRenderedFlatTree(newFlatTree); + }, [fullFlatTree, renderedFlatTree]); + + return renderedFlatTree.length > 0 && renderedFlatTree.map(({ + label, + indentLevel, + isLibraryImport, + + // Component Attributes + href, + isInterruption, + component, + componentAnswer, + + // Block Attributes + order, + numInterruptions, + numComponentsInSequence, + numComponentsInStudySequence, + }, idx) => { + const isComponent = order === undefined; + + // Determine correct answer from componentAnswer or component + const correctAnswer = componentAnswer?.correctAnswer?.length + ? componentAnswer.correctAnswer + : component?.correctAnswer; + + // Check if the answer is correct + const correct = correctAnswer + && componentAnswer + && Object.keys(componentAnswer.answer).length > 0 + && componentAnswersAreCorrect(componentAnswer.answer, correctAnswer); + + // Icon for correct/incorrect answer + const correctIncorrectIcon = correctAnswer && componentAnswer && componentAnswer?.endTime > -1 + ? (correct + ? + : + ) + : null; + + // JSON text for correct answer + const correctAnswerJSONText = correctAnswer + ? JSON.stringify(correctAnswer, null, 2) : undefined; - const [correctAnswerClamp, setCorrectAnswerClamp] = useState(INITIAL_CLAMP); - - return ( - - - {interruption && ( - - - - )} - {step !== cleanedStep && ( - - - - )} - {task?.responseOrder === 'random' && ( - - - - )} - {(task?.response && hasRandomization(task.response)) && ( - - - - )} - {correctIncorrectIcon} - - {cleanedStep} - - {cleanedStep !== 'end' && ( + + 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 ( + + { + if (isComponent && href) { + navigate(href); + } else if (blockIsCollapsed) { + expandBlock(idx, renderedFlatTree[idx]); + } else { + collapseBlock(idx, renderedFlatTree[idx]); + } + }} + active={window.location.pathname === href} + rightSection={ + isComponent + ? undefined + : + } + label={( + + {isInterruption && ( + + + + )} + {isLibraryImport && ( + + + + )} + {component?.responseOrder === 'random' && ( + + + + )} + {(component?.response && hasRandomization(component.response)) && ( + + + + )} + {correctIncorrectIcon} + + {label} + + {isComponent && label !== 'end' && ( - )} - + )} + {order === 'random' || order === 'latinSquare' ? ( + + + + ) : null} + {!isComponent && ( + + {numComponentsInSequence} + / + {numComponentsInStudySequence} + + )} + {numInterruptions !== undefined && numInterruptions > 0 && ( + + {numInterruptions} + + )} + )} - onClick={navigateTo} - disabled={disabled && parentBlock.order !== 'dynamic'} - /> - {task && ( + /> + {isComponent && ( @@ -254,236 +419,107 @@ function StepItem({ {' '} - {cleanedStep} + {label} - {task.description && ( + {component && component.description && ( Description: {' '} - {task.description} + {component.description} )} - {('parameters' in task || taskAnswer) && ( - - - Parameters: - - {' '} - {taskAnswer && JSON.stringify(Object.keys(taskAnswer).length > 0 ? taskAnswer.parameters : ('parameters' in task ? task.parameters : {}), null, 2)} - + {parameters && ( + + + Parameters: + + {' '} + {JSON.stringify(parameters, null, 2)} + )} - {taskAnswer && Object.keys(taskAnswer.answer).length > 0 && ( - - - {correctIncorrectIcon} - Participant Answer: - - {' '} - {JSON.stringify(taskAnswer.answer, 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 && ( - - {(correctAnswerClamp === undefined || correctAnswerJSONText.split('\n').length > correctAnswerClamp) && ( - - )} - + + + Correct Answer: + + {' '} + + {correctAnswerJSONText} + {correctAnswerJSONText.split('\n').length > INITIAL_CLAMP && ( + + {(correctAnswerClampMap[href!] === undefined || correctAnswerJSONText.split('\n').length > (correctAnswerClampMap[href!] || -1)) && ( + )} - - + + )} + + )} + {component && responseJSONText && ( Response: {' '} - {responseJSONText} + {JSON.stringify(component.response, null, 2)} {responseJSONText.split('\n').length > INITIAL_CLAMP && ( - - {(responseClamp === undefined || responseJSONText.split('\n').length > responseClamp) && ( - - )} - + )} + )} - {task.meta && ( + )} + {component && component.meta && ( Task Meta: - {JSON.stringify(task.meta, null, 2)} + {JSON.stringify(component.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..29b4f2558b 100644 --- a/src/utils/handleRandomSequences.tsx +++ b/src/utils/handleRandomSequences.tsx @@ -32,6 +32,7 @@ function _componentBlockToSequence( order: order.order, components: [], skip: [], + interruptions: [], }; } @@ -110,7 +111,8 @@ function _componentBlockToSequence( orderPath: path, order: order.order, components: computedComponents.flat() as Sequence['components'], - skip: order.skip, + skip: order.skip || [], + interruptions: order.interruptions || [], }; } diff --git a/tests/test-reviewer-mode.spec.ts b/tests/test-reviewer-mode.spec.ts index 48181baedf..10ad18d556 100644 --- a/tests/test-reviewer-mode.spec.ts +++ b/tests/test-reviewer-mode.spec.ts @@ -13,17 +13,17 @@ 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.getByLabel('Browse Components').locator('a').filter({ hasText: 'end' }).click(); const endText = await page.getByText('Please wait'); await expect(endText).toBeVisible(); }); From 48e2bff51bbb8c5cb943df71a08fb0c45ca13e1b Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 10 Dec 2025 21:23:13 -0700 Subject: [PATCH 02/12] Add virtualized rendering to the steps panel using @tanstack/virtual --- package.json | 1 + src/components/interface/StepsPanel.tsx | 512 ++++++++++++------------ yarn.lock | 12 + 3 files changed, 277 insertions(+), 248 deletions(-) diff --git a/package.json b/package.json index 60d1c5cddd..6cfcb6d934 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@reduxjs/toolkit": "^2.9.0", "@supabase/supabase-js": "^2.57.4", "@tabler/icons-react": "^3.35.0", + "@tanstack/react-virtual": "^3.13.13", "@trrack/core": "^1.3.0", "@types/crypto-js": "^4.2.2", "@types/hjson": "^2.4.6", diff --git a/src/components/interface/StepsPanel.tsx b/src/components/interface/StepsPanel.tsx index d9e189d982..f29483569c 100644 --- a/src/components/interface/StepsPanel.tsx +++ b/src/components/interface/StepsPanel.tsx @@ -1,6 +1,7 @@ import { - useCallback, useEffect, useMemo, useState, + useCallback, useEffect, useMemo, useState, useRef, } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { Badge, Box, @@ -272,254 +273,269 @@ export function StepsPanel({ setRenderedFlatTree(newFlatTree); }, [fullFlatTree, renderedFlatTree]); - return renderedFlatTree.length > 0 && renderedFlatTree.map(({ - label, - indentLevel, - isLibraryImport, - - // Component Attributes - href, - isInterruption, - component, - componentAnswer, - - // Block Attributes - order, - numInterruptions, - numComponentsInSequence, - numComponentsInStudySequence, - }, idx) => { - const isComponent = order === undefined; - - // Determine correct answer from componentAnswer or component - const correctAnswer = componentAnswer?.correctAnswer?.length - ? componentAnswer.correctAnswer - : component?.correctAnswer; - - // Check if the answer is correct - const correct = correctAnswer - && componentAnswer - && Object.keys(componentAnswer.answer).length > 0 - && componentAnswersAreCorrect(componentAnswer.answer, correctAnswer); - - // Icon for correct/incorrect answer - const correctIncorrectIcon = correctAnswer && componentAnswer && componentAnswer?.endTime > -1 - ? (correct - ? - : - ) - : null; - - // JSON text for correct answer - 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 ( - - { - if (isComponent && href) { - navigate(href); - } else if (blockIsCollapsed) { - expandBlock(idx, renderedFlatTree[idx]); - } else { - collapseBlock(idx, renderedFlatTree[idx]); - } - }} - active={window.location.pathname === href} - rightSection={ - isComponent - ? undefined - : - } - label={( - - {isInterruption && ( - - - - )} - {isLibraryImport && ( - - - - )} - {component?.responseOrder === 'random' && ( - - - - )} - {(component?.response && hasRandomization(component.response)) && ( - - - - )} - {correctIncorrectIcon} - - {label} - - {isComponent && label !== 'end' && ( - - - - )} - {order === 'random' || order === 'latinSquare' ? ( - - - - ) : null} - {!isComponent && ( - - {numComponentsInSequence} - / - {numComponentsInStudySequence} - - )} - {numInterruptions !== undefined && numInterruptions > 0 && ( - - {numInterruptions} - - )} - - )} - /> - {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)) && ( - + // Virtualizer setup + const parentRef = useRef(null); + const rowHeight = 32; // px, fixed height for each row + const rowCount = renderedFlatTree.length; + const virtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => parentRef.current, + estimateSize: () => rowHeight, + overscan: 6, + }); + + return ( + + + {virtualizer.getVirtualItems().map((virtualRow) => { + const idx = virtualRow.index; + const { + label, + indentLevel, + isLibraryImport, + href, + isInterruption, + component, + componentAnswer, + order, + numInterruptions, + numComponentsInSequence, + numComponentsInStudySequence, + } = 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 ( + + + { + if (isComponent && href) { + navigate(href); + } else if (blockIsCollapsed) { + expandBlock(idx, renderedFlatTree[idx]); + } else { + collapseBlock(idx, renderedFlatTree[idx]); + } + }} + active={window.location.pathname === href} + rightSection={ + isComponent + ? undefined + : + } + label={( + + {isInterruption && ( + + + + )} + {isLibraryImport && ( + + + + )} + {component?.responseOrder === 'random' && ( + + + + )} + {(component?.response && hasRandomization(component.response)) && ( + + + + )} + {correctIncorrectIcon} + + {label} + + {isComponent && label !== 'end' && ( + + + + )} + {order === 'random' || order === 'latinSquare' ? ( + + + + ) : null} + {!isComponent && ( + + {numComponentsInSequence} + / + {numComponentsInStudySequence} + + )} + {numInterruptions !== undefined && numInterruptions > 0 && ( + + {numInterruptions} + + )} + )} - + /> + {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)} + + )} + + )} - - - )} - {component && component.meta && ( - - Task Meta: - {JSON.stringify(component.meta, null, 2)} + - )} - - - )} - - ); - }); + ); + })} + + + ); } diff --git a/yarn.lock b/yarn.lock index 0334cc0a3d..874d09d4a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1995,6 +1995,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" @@ -2005,6 +2012,11 @@ resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz#00409e743ac4eea9afe5b7708594d5fcebb00212" integrity sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw== +"@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", "@trrack/core@^1.3.0-beta.1": version "1.3.0" resolved "https://registry.yarnpkg.com/@trrack/core/-/core-1.3.0.tgz#e432036548a5ba598ceacefab339d078b63214d8" From e6c8428b23c80d739175337f78ecf34d1b54c0b5 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 7 Jan 2026 19:20:18 -0700 Subject: [PATCH 03/12] Optimize the new steps panel --- src/components/interface/StepsPanel.tsx | 129 ++++++++++++++---------- 1 file changed, 77 insertions(+), 52 deletions(-) diff --git a/src/components/interface/StepsPanel.tsx b/src/components/interface/StepsPanel.tsx index f29483569c..21639c000c 100644 --- a/src/components/interface/StepsPanel.tsx +++ b/src/components/interface/StepsPanel.tsx @@ -86,6 +86,7 @@ function countComponentsInSequence(sequence: Sequence, participantAnswers: Parti type StepItem = { label: string; indentLevel: number; + path: string; // Unique path from root for stable keying // Component Attributes href?: string; @@ -93,12 +94,14 @@ type StepItem = { isLibraryImport?: boolean; component?: StudyConfig['components'][string]; componentAnswer?: StoredAnswer; + componentName?: string; // Full component name (e.g., package.co.ComponentName) // Block Attributes order?: Sequence['order']; numInterruptions?: number; numComponentsInSequence?: number; numComponentsInStudySequence?: number; + childrenRange?: { start: number; end: number }; // Pre-computed indices of children in fullFlatTree }; export function StepsPanel({ @@ -110,7 +113,13 @@ export function StepsPanel({ participantAnswers: ParticipantData['answers']; studyConfig: 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 = 6; // number of items to render outside viewport + // Per-row clamp state, keyed by idx const [correctAnswerClampMap, setCorrectAnswerClampMap] = useState>({}); const [responseClampMap, setResponseClampMap] = useState>({}); @@ -127,6 +136,17 @@ export function StepsPanel({ 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) { @@ -139,8 +159,10 @@ export function StepsPanel({ return { label: coOrComponents ? key.split(coOrComponents).at(-1)! : key, indentLevel: 0, + path: `browse.${key}`, href: `/${studyId}/reviewer-${key}`, isLibraryImport: coOrComponents !== false, + componentName: key, }; }); } else { @@ -150,7 +172,7 @@ export function StepsPanel({ let idx = 0; let dynamicIdx = 0; - const traverse = (node: string | Sequence, indentLevel: number, parentNode: Sequence, dynamic = false) => { + 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.') @@ -159,10 +181,12 @@ export function StepsPanel({ // 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 @@ -170,6 +194,7 @@ export function StepsPanel({ isInterruption: (parentNode.interruptions || []).flatMap((intr) => intr.components).includes(node), component: studyConfig.components[node], componentAnswer: participantAnswers[componentIdentifier], + componentName: node, }); if (dynamic) { @@ -184,17 +209,19 @@ export function StepsPanel({ const blockInterruptions = (node.interruptions || []).flatMap((intr) => intr.components); const matchingStudySequence = findMatchingComponentInFullOrder(node, fullOrder); + const blockPath = `${parentPath}.${node.id ?? node.order}`; // Push the block itself newFlatTree.push({ label: node.id ?? node.order, indentLevel, + path: blockPath, // Block Attributes order: node.order, numInterruptions: node.components.filter((comp) => typeof comp === 'string' && blockInterruptions.includes(comp)).length, numComponentsInSequence: countComponentsInSequence(node, participantAnswers), - numComponentsInStudySequence: countComponentsInSequence(matchingStudySequence || node, participantAnswers)!, + numComponentsInStudySequence: countComponentsInSequence(matchingStudySequence || node, participantAnswers), }); // Reset dynamicIdx when entering a new dynamic block @@ -207,12 +234,25 @@ export function StepsPanel({ const blockComponents = [...node.components, ...dynamicComponents]; if (blockComponents.length > 0) { blockComponents.forEach((child) => { - traverse(child, indentLevel + 1, node, node.order === 'dynamic'); + traverse(child, indentLevel + 1, node, blockPath, node.order === 'dynamic'); }); } }; - traverse(participantSequence, 0, participantSequence); + traverse(participantSequence, 0, participantSequence, 'root'); + } + + // 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 }; + } } // Map over tree and set correctAnswerClampMap and responseClampMap @@ -231,61 +271,45 @@ export function StepsPanel({ }, [fullOrder, participantAnswers, participantSequence, studyConfig.components, studyId]); const collapseBlock = useCallback((startIndex: number, startItem: StepItem) => { - const startIndentLevel = startItem.indentLevel; - - // Find the index of the next block at the same or less indent level so we can remove the sub-items - let endIndex = renderedFlatTree.findIndex((item, idx) => idx > startIndex && item.indentLevel <= startIndentLevel); - - // If no next block, collapse to the end of the list - if (endIndex === -1) { - endIndex = renderedFlatTree.length; - } - - // Create new array without the items between startIndex and endIndex - const newFlatTree = [ - ...renderedFlatTree.slice(0, startIndex + 1), - ...renderedFlatTree.slice(endIndex), - ]; - setRenderedFlatTree(newFlatTree); - }, [renderedFlatTree]); + setRenderedFlatTree((prevRenderedFlatTree) => { + // Use pre-computed childrenRange for O(1) operation + const numChildren = (startItem.childrenRange?.end ?? startIndex + 1) - (startItem.childrenRange?.start ?? startIndex + 1); + + // Remove all children + return [ + ...prevRenderedFlatTree.slice(0, startIndex + 1), + ...prevRenderedFlatTree.slice(startIndex + 1 + numChildren), + ]; + }); + }, []); const expandBlock = useCallback((startIndex: number, startItem: StepItem) => { - const startIndentLevel = startItem.indentLevel; - - const fullFlatStartIndex = fullFlatTree.findIndex((item) => item === startItem); - - // Find all items in fullFlatTree that are children of the block being expanded - const itemsToInsert: StepItem[] = []; - for (let i = fullFlatStartIndex + 1; i < fullFlatTree.length; i += 1) { - const item = fullFlatTree[i]; - if (item.indentLevel <= startIndentLevel) { - break; - } - itemsToInsert.push(item); - } - - // Create new array with the items inserted after startIndex - const newFlatTree = [ - ...renderedFlatTree.slice(0, startIndex + 1), - ...itemsToInsert, - ...renderedFlatTree.slice(startIndex + 1), - ]; - setRenderedFlatTree(newFlatTree); - }, [fullFlatTree, renderedFlatTree]); + setRenderedFlatTree((prevRenderedFlatTree) => { + // Use pre-computed childrenRange for O(1) lookup + const { start, end } = startItem.childrenRange ?? { start: 0, end: 0 }; + const itemsToInsert = fullFlatTree.slice(start, end); + + // Create new array with the items inserted after startIndex + return [ + ...prevRenderedFlatTree.slice(0, startIndex + 1), + ...itemsToInsert, + ...prevRenderedFlatTree.slice(startIndex + 1), + ]; + }); + }, [fullFlatTree]); // Virtualizer setup const parentRef = useRef(null); - const rowHeight = 32; // px, fixed height for each row const rowCount = renderedFlatTree.length; const virtualizer = useVirtualizer({ count: rowCount, getScrollElement: () => parentRef.current, - estimateSize: () => rowHeight, - overscan: 6, + estimateSize: () => ROW_HEIGHT, + overscan: VIRTUALIZER_OVERSCAN, }); return ( - + {virtualizer.getVirtualItems().map((virtualRow) => { const idx = virtualRow.index; @@ -297,6 +321,7 @@ export function StepsPanel({ isInterruption, component, componentAnswer, + componentName, order, numInterruptions, numComponentsInSequence, @@ -331,20 +356,20 @@ export function StepsPanel({ ); return ( { if (isComponent && href) { navigate(href); @@ -377,7 +402,7 @@ export function StepsPanel({ )} - {(component?.response && hasRandomization(component.response)) && ( + {(componentName && componentHasRandomization.get(componentName)) && ( From 31a9bff505803d6b9d4bcaab80c4496b13515dfd Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 7 Jan 2026 19:43:55 -0700 Subject: [PATCH 04/12] Add in the blocks that were excluded through randomization --- src/components/interface/StepsPanel.tsx | 169 ++++++++++++++++++++++-- 1 file changed, 157 insertions(+), 12 deletions(-) diff --git a/src/components/interface/StepsPanel.tsx b/src/components/interface/StepsPanel.tsx index 21639c000c..4f1acc3563 100644 --- a/src/components/interface/StepsPanel.tsx +++ b/src/components/interface/StepsPanel.tsx @@ -102,8 +102,36 @@ type StepItem = { 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); + } + }); + + // 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); + } + }); + + return excludedBlocks; +} + export function StepsPanel({ participantSequence, participantAnswers, @@ -237,6 +265,68 @@ export function StepsPanel({ traverse(child, indentLevel + 1, node, blockPath, node.order === 'dynamic'); }); } + + // After processing all children, check for excluded blocks from the study sequence + const matchingStudyBlock = findMatchingComponentInFullOrder(node, fullOrder); + if (matchingStudyBlock) { + const excludedBlocks = findExcludedBlocks(matchingStudyBlock, 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, + 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, + 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'); @@ -272,8 +362,16 @@ export function StepsPanel({ const collapseBlock = useCallback((startIndex: number, startItem: StepItem) => { setRenderedFlatTree((prevRenderedFlatTree) => { - // Use pre-computed childrenRange for O(1) operation - const numChildren = (startItem.childrenRange?.end ?? startIndex + 1) - (startItem.childrenRange?.start ?? startIndex + 1); + // 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 numChildren = endIndex - (startIndex + 1); // Remove all children return [ @@ -285,9 +383,42 @@ export function StepsPanel({ const expandBlock = useCallback((startIndex: number, startItem: StepItem) => { setRenderedFlatTree((prevRenderedFlatTree) => { - // Use pre-computed childrenRange for O(1) lookup + // Find the items to insert from fullFlatTree based on the block's childrenRange const { start, end } = startItem.childrenRange ?? { start: 0, end: 0 }; - const itemsToInsert = fullFlatTree.slice(start, end); + + // 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); + } + } + } // Create new array with the items inserted after startIndex return [ @@ -326,6 +457,7 @@ export function StepsPanel({ numInterruptions, numComponentsInSequence, numComponentsInStudySequence, + isExcluded, } = renderedFlatTree[idx]; const isComponent = order === undefined; const correctAnswer = componentAnswer?.correctAnswer?.length @@ -356,7 +488,7 @@ export function StepsPanel({ ); return ( { - if (isComponent && href) { + if (isComponent && href && !isExcluded) { navigate(href); - } else if (blockIsCollapsed) { - expandBlock(idx, renderedFlatTree[idx]); - } else { - collapseBlock(idx, renderedFlatTree[idx]); + } else if (!isComponent) { + // Both included and excluded blocks can be collapsed/expanded + if (blockIsCollapsed) { + expandBlock(idx, renderedFlatTree[idx]); + } else { + collapseBlock(idx, renderedFlatTree[idx]); + } } }} - active={window.location.pathname === href} + active={!isExcluded && window.location.pathname === href} + disabled={isExcluded && isComponent} + style={{ + opacity: isExcluded ? 0.5 : 1, + cursor: isExcluded && isComponent ? 'not-allowed' : 'pointer', + }} rightSection={ isComponent ? undefined @@ -432,13 +572,18 @@ export function StepsPanel({ ) : null} - {!isComponent && ( + {!isComponent && !isExcluded && ( {numComponentsInSequence} / {numComponentsInStudySequence} )} + {!isComponent && isExcluded && ( + + Excluded + + )} {numInterruptions !== undefined && numInterruptions > 0 && ( {numInterruptions} From e96ccd70bb5e982b7969870e65c74939650256c3 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 7 Jan 2026 20:34:59 -0700 Subject: [PATCH 05/12] Handle excluded components, don't count interruptions --- src/components/interface/StepsPanel.tsx | 64 +++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/src/components/interface/StepsPanel.tsx b/src/components/interface/StepsPanel.tsx index 4f1acc3563..cef7316387 100644 --- a/src/components/interface/StepsPanel.tsx +++ b/src/components/interface/StepsPanel.tsx @@ -49,7 +49,9 @@ function findMatchingComponentInFullOrder( if (typeof node === 'string') { return; } - if (node.id === sequence.id) { + + // Match by orderPath if available + if (node.orderPath && sequence.orderPath && node.orderPath === sequence.orderPath) { studySequence = node; return; } @@ -98,6 +100,7 @@ type StepItem = { // Block Attributes order?: Sequence['order']; + orderPath?: string; // Order path for blocks numInterruptions?: number; numComponentsInSequence?: number; numComponentsInStudySequence?: number; @@ -132,6 +135,33 @@ function findExcludedBlocks( return excludedBlocks; } +/** + * 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); + } + }); + + // 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 excludedComponents; +} + export function StepsPanel({ participantSequence, participantAnswers, @@ -236,8 +266,8 @@ export function StepsPanel({ } const blockInterruptions = (node.interruptions || []).flatMap((intr) => intr.components); - const matchingStudySequence = findMatchingComponentInFullOrder(node, fullOrder); const blockPath = `${parentPath}.${node.id ?? node.order}`; + const matchingStudySequence = findMatchingComponentInFullOrder(node, fullOrder); // Push the block itself newFlatTree.push({ @@ -247,6 +277,7 @@ export function StepsPanel({ // Block Attributes order: node.order, + orderPath: node.orderPath, numInterruptions: node.components.filter((comp) => typeof comp === 'string' && blockInterruptions.includes(comp)).length, numComponentsInSequence: countComponentsInSequence(node, participantAnswers), numComponentsInStudySequence: countComponentsInSequence(matchingStudySequence || node, participantAnswers), @@ -266,9 +297,29 @@ export function StepsPanel({ }); } - // After processing all children, check for excluded blocks from the study sequence + // After processing all children, check for excluded blocks and components from the study sequence const matchingStudyBlock = findMatchingComponentInFullOrder(node, fullOrder); if (matchingStudyBlock) { + // First, add excluded components (strings) + const excludedComponents = findExcludedComponents(matchingStudyBlock, 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(matchingStudyBlock, node); excludedBlocks.forEach((excludedBlock) => { const excludedBlockPath = `${blockPath}.${excludedBlock.id ?? excludedBlock.order}_excluded`; @@ -281,6 +332,7 @@ export function StepsPanel({ // Block Attributes order: excludedBlock.order, + orderPath: excludedBlock.orderPath, numInterruptions: 0, numComponentsInSequence: 0, numComponentsInStudySequence: countComponentsInSequence(excludedBlock, participantAnswers), @@ -313,6 +365,7 @@ export function StepsPanel({ indentLevel: excludedIndentLevel, path: childBlockPath, order: child.order, + orderPath: child.orderPath, numInterruptions: 0, numComponentsInSequence: 0, numComponentsInStudySequence: countComponentsInSequence(child, participantAnswers), @@ -454,6 +507,7 @@ export function StepsPanel({ componentAnswer, componentName, order, + orderPath, numInterruptions, numComponentsInSequence, numComponentsInStudySequence, @@ -550,7 +604,7 @@ export function StepsPanel({ {correctIncorrectIcon} - {numComponentsInSequence} + {(numComponentsInSequence || 0) - (numInterruptions || 0)} / {numComponentsInStudySequence} From 42e51c206e847dd285059791ade9716f237c465a Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 7 Jan 2026 20:47:39 -0700 Subject: [PATCH 06/12] Handle generating larger latin squares --- src/utils/handleRandomSequences.tsx | 149 +++++++++++++++++++++++----- 1 file changed, 126 insertions(+), 23 deletions(-) diff --git a/src/utils/handleRandomSequences.tsx b/src/utils/handleRandomSequences.tsx index 29b4f2558b..f0decd2d24 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 { @@ -45,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)]; } @@ -56,11 +87,27 @@ function _componentBlockToSequence( computedComponents = computedComponents.slice(0, order.numSamples); + // Track how many times we've seen each component to handle duplicates correctly + const seenIndices = 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 all matching indices in the original array + const matchingIndices: number[] = []; + for (let j = 0; j < order.components.length; j += 1) { + if (isEqual(order.components[j], curr)) { + matchingIndices.push(j); + } + } + + // Determine which occurrence this is + const firstMatchIndex = matchingIndices[0]; + const occurrenceCount = seenIndices.get(firstMatchIndex) || 0; + const actualIndex = matchingIndices[occurrenceCount] ?? firstMatchIndex; + seenIndices.set(firstMatchIndex, occurrenceCount + 1); + + computedComponents[i] = _componentBlockToSequence(curr, latinSquareObject, `${path}-${actualIndex}`, config) as unknown as ComponentBlock; } } @@ -119,10 +166,11 @@ function _componentBlockToSequence( 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) { @@ -149,31 +197,81 @@ 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 + // Track how many times we've seen each component to handle duplicates correctly (same as _componentBlockToSequence) + const seenIndices = 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 all matching indices in the original array + const matchingIndices: number[] = []; + for (let j = 0; j < order.components.length; j += 1) { + if (JSON.stringify(order.components[j]) === JSON.stringify(curr)) { + matchingIndices.push(j); + } } - locationInSequence = (locationInSequence as ComponentBlock).components[+p]; + + // Determine which occurrence this is + const firstMatchIndex = matchingIndices[0]; + const occurrenceCount = seenIndices.get(firstMatchIndex) || 0; + const actualIndex = matchingIndices[occurrenceCount] ?? firstMatchIndex; + seenIndices.set(firstMatchIndex, occurrenceCount + 1); + + _countPathUsage(curr, pathCounts, `${path}-${actualIndex}`); } - }); + } +} - 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 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; @@ -181,16 +279,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; } }); }); From 6ec431360b71441d6bbc2a4ca40805d9ed5313ca Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 7 Jan 2026 20:53:18 -0700 Subject: [PATCH 07/12] Speed up latin square generation --- src/utils/handleRandomSequences.tsx | 104 +++++++++++++++++++++------- 1 file changed, 78 insertions(+), 26 deletions(-) diff --git a/src/utils/handleRandomSequences.tsx b/src/utils/handleRandomSequences.tsx index f0decd2d24..343945475f 100644 --- a/src/utils/handleRandomSequences.tsx +++ b/src/utils/handleRandomSequences.tsx @@ -87,27 +87,54 @@ function _componentBlockToSequence( computedComponents = computedComponents.slice(0, order.numSamples); - // Track how many times we've seen each component to handle duplicates correctly - const seenIndices = new Map(); + // 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)) { - // Find all matching indices in the original array - const matchingIndices: number[] = []; - for (let j = 0; j < order.components.length; j += 1) { - if (isEqual(order.components[j], curr)) { - matchingIndices.push(j); + // Find the matching unique component + let matchedUnique = null; + for (const unique of uniqueComponents) { + if (isEqual(unique.component, curr)) { + matchedUnique = unique; + break; } } - // Determine which occurrence this is - const firstMatchIndex = matchingIndices[0]; - const occurrenceCount = seenIndices.get(firstMatchIndex) || 0; - const actualIndex = matchingIndices[occurrenceCount] ?? firstMatchIndex; - seenIndices.set(firstMatchIndex, occurrenceCount + 1); + 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; + 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; + } } } @@ -225,27 +252,52 @@ function _countPathUsage( } // Count recursively for nested blocks - // Track how many times we've seen each component to handle duplicates correctly (same as _componentBlockToSequence) - const seenIndices = new Map(); + // 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] }); + } + } + } + + // 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 all matching indices in the original array - const matchingIndices: number[] = []; - for (let j = 0; j < order.components.length; j += 1) { - if (JSON.stringify(order.components[j]) === JSON.stringify(curr)) { - matchingIndices.push(j); + // Find the matching unique component + let matchedUnique = null; + for (const unique of uniqueComponents) { + if (isEqual(unique.component, curr)) { + matchedUnique = unique; + break; } } - // Determine which occurrence this is - const firstMatchIndex = matchingIndices[0]; - const occurrenceCount = seenIndices.get(firstMatchIndex) || 0; - const actualIndex = matchingIndices[occurrenceCount] ?? firstMatchIndex; - seenIndices.set(firstMatchIndex, occurrenceCount + 1); + 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}`); + _countPathUsage(curr, pathCounts, `${path}-${actualIndex}`); + } else { + // Fallback: shouldn't happen, but handle it + _countPathUsage(curr, pathCounts, `${path}-0`); + } } } } From a4fecdc866c5d1dee097d7c367bd98282a019717 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 7 Jan 2026 21:12:41 -0700 Subject: [PATCH 08/12] Fix bug with example full VLAT --- public/example-VLAT-full-randomized/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/example-VLAT-full-randomized/config.json b/public/example-VLAT-full-randomized/config.json index 9d8ad948ed..d6ea4f762c 100644 --- a/public/example-VLAT-full-randomized/config.json +++ b/public/example-VLAT-full-randomized/config.json @@ -55,7 +55,7 @@ }, "survey": { "type": "markdown", - "path": "VLAT-mini-randomized/survey.md", + "path": "example-VLAT-mini-randomized/assets/survey.md", "response": [ { "id": "q1", From 556cfb0b503f068974274eb09c45b149da3db480 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 7 Jan 2026 21:16:31 -0700 Subject: [PATCH 09/12] Fix performance and virtualization issues in the steps panel --- src/components/interface/AppAside.tsx | 16 +++++--- src/components/interface/StepsPanel.tsx | 54 ++++++++++++++++++++----- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/src/components/interface/AppAside.tsx b/src/components/interface/AppAside.tsx index 93563e7b49..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, @@ -128,16 +127,23 @@ export function AppAside() { - + @@ -152,10 +158,10 @@ export function AppAside() { - + - + diff --git a/src/components/interface/StepsPanel.tsx b/src/components/interface/StepsPanel.tsx index cef7316387..77e53409c4 100644 --- a/src/components/interface/StepsPanel.tsx +++ b/src/components/interface/StepsPanel.tsx @@ -226,6 +226,10 @@ export function StepsPanel({ } 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; @@ -267,7 +271,29 @@ export function StepsPanel({ const blockInterruptions = (node.interruptions || []).flatMap((intr) => intr.components); const blockPath = `${parentPath}.${node.id ?? node.order}`; - const matchingStudySequence = findMatchingComponentInFullOrder(node, fullOrder); + + // 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({ @@ -279,8 +305,8 @@ export function StepsPanel({ order: node.order, orderPath: node.orderPath, numInterruptions: node.components.filter((comp) => typeof comp === 'string' && blockInterruptions.includes(comp)).length, - numComponentsInSequence: countComponentsInSequence(node, participantAnswers), - numComponentsInStudySequence: countComponentsInSequence(matchingStudySequence || node, participantAnswers), + numComponentsInSequence, + numComponentsInStudySequence, }); // Reset dynamicIdx when entering a new dynamic block @@ -298,10 +324,10 @@ export function StepsPanel({ } // After processing all children, check for excluded blocks and components from the study sequence - const matchingStudyBlock = findMatchingComponentInFullOrder(node, fullOrder); - if (matchingStudyBlock) { + // Reuse the cached matchingStudySequence from above + if (matchingStudySequence) { // First, add excluded components (strings) - const excludedComponents = findExcludedComponents(matchingStudyBlock, node); + const excludedComponents = findExcludedComponents(matchingStudySequence, node); excludedComponents.forEach((excludedComponent) => { const coOrComponents = excludedComponent.includes('.co.') ? '.co.' @@ -320,7 +346,7 @@ export function StepsPanel({ }); // Then, add excluded blocks - const excludedBlocks = findExcludedBlocks(matchingStudyBlock, node); + const excludedBlocks = findExcludedBlocks(matchingStudySequence, node); excludedBlocks.forEach((excludedBlock) => { const excludedBlockPath = `${blockPath}.${excludedBlock.id ?? excludedBlock.order}_excluded`; @@ -485,6 +511,7 @@ export function StepsPanel({ // Virtualizer setup const parentRef = useRef(null); const rowCount = renderedFlatTree.length; + const virtualizer = useVirtualizer({ count: rowCount, getScrollElement: () => parentRef.current, @@ -493,8 +520,15 @@ export function StepsPanel({ }); return ( - - + + {virtualizer.getVirtualItems().map((virtualRow) => { const idx = virtualRow.index; const { @@ -568,7 +602,7 @@ export function StepsPanel({ } } }} - active={!isExcluded && window.location.pathname === href} + active={!isExcluded && href === window.location.pathname} disabled={isExcluded && isComponent} style={{ opacity: isExcluded ? 0.5 : 1, From d004384a23da157cd15e13afd0b0f621575f9984 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 7 Jan 2026 21:27:36 -0700 Subject: [PATCH 10/12] Fix navigation in the analysis platform --- src/analysis/individualStudy/stats/StatsView.tsx | 2 +- src/components/interface/StepsPanel.tsx | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/analysis/individualStudy/stats/StatsView.tsx b/src/analysis/individualStudy/stats/StatsView.tsx index 150b854986..8f6340bd87 100644 --- a/src/analysis/individualStudy/stats/StatsView.tsx +++ b/src/analysis/individualStudy/stats/StatsView.tsx @@ -31,7 +31,7 @@ export function StatsView( {/* Trial selection sidebar */} - + diff --git a/src/components/interface/StepsPanel.tsx b/src/components/interface/StepsPanel.tsx index 77e53409c4..f52eed1bb6 100644 --- a/src/components/interface/StepsPanel.tsx +++ b/src/components/interface/StepsPanel.tsx @@ -166,10 +166,12 @@ export function StepsPanel({ participantSequence, participantAnswers, studyConfig, + isAnalysis, }: { participantSequence?: Sequence; participantAnswers: ParticipantData['answers']; studyConfig: StudyConfig; + isAnalysis?: boolean; }) { // Constants const INITIAL_CLAMP = 6; @@ -592,7 +594,11 @@ export function StepsPanel({ pl={indentLevel * INDENT_SIZE + BASE_PADDING} onClick={() => { if (isComponent && href && !isExcluded) { - navigate(href); + 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) { From 07c657aaf129c19217fa8b5a7c8f77b6086482c7 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 7 Jan 2026 21:34:43 -0700 Subject: [PATCH 11/12] Set overscan to an optimal value --- src/components/interface/StepsPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/interface/StepsPanel.tsx b/src/components/interface/StepsPanel.tsx index f52eed1bb6..971108a6ea 100644 --- a/src/components/interface/StepsPanel.tsx +++ b/src/components/interface/StepsPanel.tsx @@ -178,7 +178,7 @@ export function StepsPanel({ 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 = 6; // number of items to render outside viewport + const VIRTUALIZER_OVERSCAN = 10; // number of items to render outside viewport // Per-row clamp state, keyed by idx const [correctAnswerClampMap, setCorrectAnswerClampMap] = useState>({}); From 03886d0d50d2a93970b14b113652ebb8573e035f Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Thu, 8 Jan 2026 08:36:55 -0700 Subject: [PATCH 12/12] Fix test --- tests/test-reviewer-mode.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test-reviewer-mode.spec.ts b/tests/test-reviewer-mode.spec.ts index 10ad18d556..94b6a3bdc7 100644 --- a/tests/test-reviewer-mode.spec.ts +++ b/tests/test-reviewer-mode.spec.ts @@ -23,7 +23,8 @@ test('test', async ({ browser }) => { const introText = await page.getByText('Welcome to our study. This is'); await expect(introText).toBeVisible(); - await page.getByLabel('Browse Components').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(); });