From a97ef2eb971e8577ce52a10a6bfe9b45ed12aaa0 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Thu, 19 Mar 2026 19:16:24 -0600 Subject: [PATCH 01/41] Refine startup error handling --- src/components/Shell.tsx | 34 +++++++++++++++--- src/components/Shell.utils.spec.ts | 55 ++++++++++++++++++++++++++++++ src/components/Shell.utils.ts | 44 ++++++++++++++++++++++++ src/store/store.tsx | 9 ++--- src/store/types.ts | 3 +- 5 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 src/components/Shell.utils.spec.ts create mode 100644 src/components/Shell.utils.ts diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index b88dfd16ca..4a882ff757 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -32,11 +32,17 @@ import { ResourceNotFound } from '../ResourceNotFound'; import { encryptIndex } from '../utils/encryptDecryptIndex'; import { parseStudyConfig } from '../parser/parser'; import { hash } from '../storage/engines/utils'; +import { REVISIT_MODE } from '../storage/engines/types'; import { filterSequenceByCondition, parseConditionParam, resolveParticipantConditions, } from '../utils/handleConditionLogic'; +import { + getInitialStartupAlert, + getScreenOrientationType, + isStorageStartupFailure, +} from './Shell.utils'; export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { // Pull study config @@ -93,6 +99,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { // Check that we have a storage engine and active config (studyId is set for config, but typescript complains) if (!storageEngine || !activeConfig || !canonicalStudyId) return; + let modes: Record | null = null; try { // Make sure that we have a study database and that the study database has a sequence array await storageEngine.initializeStudyDb(canonicalStudyId); @@ -105,7 +112,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { ); } - const modes = await storageEngine.getModes(canonicalStudyId); + modes = await storageEngine.getModes(canonicalStudyId); // Get or generate participant session const urlParticipantId = activeConfig.uiConfig.urlParticipantIdParam @@ -131,7 +138,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { availHeight: window.screen.availHeight, availWidth: window.screen.availWidth, colorDepth: window.screen.colorDepth, - orientation: window.screen.orientation.type, + orientation: getScreenOrientationType(window.screen), pixelDepth: window.screen.pixelDepth, }, ip: ip.ip, @@ -188,6 +195,23 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { setStore(newStore); } catch (error) { console.error('Error initializing user store routing:', error); + const isStorageFailure = isStorageStartupFailure(storageEngine, import.meta.env.VITE_STORAGE_ENGINE); + const resolvedModes = modes ?? await storageEngine.getModes(canonicalStudyId).catch(() => null); + const developmentModeEnabledForAlert = resolvedModes?.developmentModeEnabled ?? false; + const fallbackModes = { + developmentModeEnabled: resolvedModes?.developmentModeEnabled ?? true, + dataSharingEnabled: resolvedModes?.dataSharingEnabled ?? true, + dataCollectionEnabled: false, + }; + const urlParticipantId = activeConfig.uiConfig.urlParticipantIdParam + ? searchParams.get(activeConfig.uiConfig.urlParticipantIdParam) + || undefined + : undefined; + const resumeParticipantId = participantId || urlParticipantId; + const initialAlertModal = !isStorageFailure + ? getInitialStartupAlert(error, developmentModeEnabledForAlert, resumeParticipantId) + : undefined; + // Fallback: initialize the store with empty data const generatedSequences = generateSequenceArray(activeConfig); const matchingSequence = generatedSequences[0]; @@ -215,10 +239,12 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { ip: '', }, {}, - { developmentModeEnabled: true, dataSharingEnabled: true, dataCollectionEnabled: false }, + fallbackModes, '', false, - true, + isStorageFailure, + false, + initialAlertModal, ); setStore(emptyStore); } diff --git a/src/components/Shell.utils.spec.ts b/src/components/Shell.utils.spec.ts new file mode 100644 index 0000000000..23f500b097 --- /dev/null +++ b/src/components/Shell.utils.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'vitest'; +import { + getInitialStartupAlert, + getScreenOrientationType, + getStartupErrorMessage, + isStorageStartupFailure, +} from './Shell.utils'; + +describe('Shell utilities', () => { + test('returns an empty orientation when screen.orientation is unavailable', () => { + const mockScreen = { + orientation: undefined, + } as unknown as Screen; + + expect(getScreenOrientationType(mockScreen)).toBe(''); + }); + + test('detects storage startup failures from connectivity', () => { + const mockStorageEngine = { + getEngine: () => 'supabase' as const, + isConnected: () => false, + }; + + expect(isStorageStartupFailure(mockStorageEngine, 'supabase')).toBe(true); + }); + + test('detects storage startup failures from engine mismatch', () => { + const mockStorageEngine = { + getEngine: () => 'localStorage' as const, + isConnected: () => true, + }; + + expect(isStorageStartupFailure(mockStorageEngine, 'supabase')).toBe(true); + }); + + test('uses the caught error message in development mode', () => { + expect(getInitialStartupAlert(new Error('Bad startup state'), true, null)).toEqual({ + show: true, + title: 'Problem loading the study', + message: 'Bad startup state', + }); + }); + + test('uses resume copy outside development mode when resuming a participant', () => { + expect(getInitialStartupAlert(new Error('ignored'), false, 'abc123')).toEqual({ + show: true, + title: 'Problem loading the study', + message: 'This study session could not be resumed.', + }); + }); + + test('falls back to the generic startup message for empty errors', () => { + expect(getStartupErrorMessage('')).toBe('There was a problem loading the study.'); + }); +}); diff --git a/src/components/Shell.utils.ts b/src/components/Shell.utils.ts new file mode 100644 index 0000000000..885503e246 --- /dev/null +++ b/src/components/Shell.utils.ts @@ -0,0 +1,44 @@ +import type { StorageEngine } from '../storage/engines/types'; +import type { AlertModalState } from '../store/types'; + +type StartupStorageStatus = Pick; + +const GENERIC_STARTUP_ERROR = 'There was a problem loading the study.'; +const RESUME_STARTUP_ERROR = 'This study session could not be resumed.'; + +export function getScreenOrientationType(screen: Screen) { + return screen.orientation?.type ?? ''; +} + +export function isStorageStartupFailure( + storageEngine: StartupStorageStatus, + configuredEngine: string, +) { + return !storageEngine.isConnected() || storageEngine.getEngine() !== configuredEngine; +} + +export function getStartupErrorMessage(error: unknown) { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + if (typeof error === 'string' && error.trim().length > 0) { + return error; + } + + return GENERIC_STARTUP_ERROR; +} + +export function getInitialStartupAlert( + error: unknown, + developmentModeEnabled: boolean, + resumeParticipantId?: string | null, +): AlertModalState { + return { + show: true, + title: 'Problem loading the study', + message: developmentModeEnabled + ? getStartupErrorMessage(error) + : (resumeParticipantId ? RESUME_STARTUP_ERROR : GENERIC_STARTUP_ERROR), + }; +} diff --git a/src/store/store.tsx b/src/store/store.tsx index 4466853900..36e4a52c29 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -6,8 +6,8 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import { ParsedStringOption, ResponseBlockLocation, StudyConfig, ValueOf, Answer, ParticipantData, } from '../parser/types'; -import { - StoredAnswer, TrialValidation, TrrackedProvenance, StoreState, Sequence, ParticipantMetadata, +import type { + AlertModalState, StoredAnswer, TrialValidation, TrrackedProvenance, StoreState, Sequence, ParticipantMetadata, } from './types'; import { getSequenceFlatMap } from '../utils/getSequenceFlatMap'; import { REVISIT_MODE } from '../storage/engines/types'; @@ -25,6 +25,7 @@ export async function studyStoreCreator( completed: boolean, storageEngineFailedToConnect: boolean, isStalledConfig: boolean = false, + initialAlertModal?: AlertModalState, ) { const flatSequence = getSequenceFlatMap(sequence); @@ -111,7 +112,7 @@ export async function studyStoreCreator( config, showStudyBrowser: true, showHelpText: false, - alertModal: { show: false, message: '', title: '' }, + alertModal: initialAlertModal ?? { show: false, message: '', title: '' }, trialValidation: Object.keys(answers).length > 0 ? allValid : emptyValidation, reactiveAnswers: {}, metadata, @@ -203,7 +204,7 @@ export async function studyStoreCreator( toggleShowHelpText: (state) => { state.showHelpText = !state.showHelpText; }, - setAlertModal: (state, action: PayloadAction<{ show: boolean; message: string; title: string }>) => { + setAlertModal: (state, action: PayloadAction) => { state.alertModal = action.payload; }, setReactiveAnswers: (state, action: PayloadAction>>) => { diff --git a/src/store/types.ts b/src/store/types.ts index 0f047bcd32..b954a3e839 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -161,6 +161,7 @@ export interface Sequence { } export type FormElementProvenance = { form: StoredAnswer['answer'] }; +export type AlertModalState = { show: boolean, message: string, title: string }; export interface StoreState { studyId: string; participantId: string; @@ -169,7 +170,7 @@ export interface StoreState { config: StudyConfig; showStudyBrowser: boolean; showHelpText: boolean; - alertModal: { show: boolean, message: string, title: string }; + alertModal: AlertModalState; trialValidation: TrialValidation; reactiveAnswers: Record>; metadata: ParticipantMetadata; From 1d18108304a370fab3d11a90becd355c3ac6c211 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Sun, 29 Mar 2026 22:17:15 -0600 Subject: [PATCH 02/41] Handle startup storage fallback and resume alerts --- src/components/Shell.tsx | 102 +++++++++++++++------------- src/components/Shell.utils.spec.ts | 18 +++++ src/components/Shell.utils.ts | 7 +- src/storage/engines/types.ts | 11 +++ src/storage/tests/highLevel.spec.ts | 8 +++ 5 files changed, 97 insertions(+), 49 deletions(-) diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 4a882ff757..f49fb7be93 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -100,27 +100,13 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { if (!storageEngine || !activeConfig || !canonicalStudyId) return; let modes: Record | null = null; + let storageOperationFailed = false; + const urlParticipantId = activeConfig.uiConfig.urlParticipantIdParam + ? searchParams.get(activeConfig.uiConfig.urlParticipantIdParam) + || undefined + : undefined; try { - // Make sure that we have a study database and that the study database has a sequence array - await storageEngine.initializeStudyDb(canonicalStudyId); - await storageEngine.saveConfig(activeConfig); - - const sequenceArray = await storageEngine.getSequenceArray(); - if (!sequenceArray) { - await storageEngine.setSequenceArray( - await generateSequenceArray(activeConfig), - ); - } - - modes = await storageEngine.getModes(canonicalStudyId); - - // Get or generate participant session - const urlParticipantId = activeConfig.uiConfig.urlParticipantIdParam - ? searchParams.get(activeConfig.uiConfig.urlParticipantIdParam) - || undefined - : undefined; const searchParamsObject = Object.fromEntries(searchParams.entries()); - const ipTimeoutController = new AbortController(); const ipTimeoutId = window.setTimeout(() => ipTimeoutController.abort(), 1200); const ipRes = await fetch('https://api.ipify.org?format=json', { @@ -144,32 +130,50 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { ip: ip.ip, }; - let participantSession = await storageEngine.initializeParticipantSession( - searchParamsObject, - activeConfig, - metadata, - participantId || urlParticipantId, - ); - - if (studyCondition.length > 0 && modes.developmentModeEnabled) { - const updatedSearchParams = { - ...participantSession.searchParams, - condition: studyCondition.join(','), - }; - await storageEngine.updateParticipantSearchParams(updatedSearchParams); - await storageEngine.updateStudyCondition(studyCondition); - participantSession = { - ...participantSession, - searchParams: updatedSearchParams, - conditions: studyCondition, - }; - } const activeHash = await hash(JSON.stringify(activeConfig)); - + let participantSession!: Awaited>; let participantConfig = activeConfig; + try { + // Make sure that we have a study database and that the study database has a sequence array + await storageEngine.initializeStudyDb(canonicalStudyId); + await storageEngine.saveConfig(activeConfig); - if (participantSession.participantConfigHash !== activeHash) { - participantConfig = (await storageEngine.getAllConfigsFromHash([participantSession.participantConfigHash], canonicalStudyId))[participantSession.participantConfigHash] as ParsedConfig; + const sequenceArray = await storageEngine.getSequenceArray(); + if (!sequenceArray) { + await storageEngine.setSequenceArray(generateSequenceArray(activeConfig)); + } + + modes = await storageEngine.getModes(canonicalStudyId); + participantSession = await storageEngine.initializeParticipantSession( + searchParamsObject, + activeConfig, + metadata, + participantId || urlParticipantId, + ); + + if (studyCondition.length > 0 && modes.developmentModeEnabled) { + const updatedSearchParams = { + ...participantSession.searchParams, + condition: studyCondition.join(','), + }; + await storageEngine.updateParticipantSearchParams(updatedSearchParams); + await storageEngine.updateStudyCondition(studyCondition); + participantSession = { + ...participantSession, + searchParams: updatedSearchParams, + conditions: studyCondition, + }; + } + + if (participantSession.participantConfigHash !== activeHash) { + participantConfig = (await storageEngine.getAllConfigsFromHash( + [participantSession.participantConfigHash], + canonicalStudyId, + ))[participantSession.participantConfigHash] as ParsedConfig; + } + } catch (storageError) { + storageOperationFailed = true; + throw storageError; } const effectiveStudyCondition = resolveParticipantConditions({ @@ -195,7 +199,11 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { setStore(newStore); } catch (error) { console.error('Error initializing user store routing:', error); - const isStorageFailure = isStorageStartupFailure(storageEngine, import.meta.env.VITE_STORAGE_ENGINE); + const isStorageFailure = isStorageStartupFailure( + storageEngine, + import.meta.env.VITE_STORAGE_ENGINE, + storageOperationFailed, + ); const resolvedModes = modes ?? await storageEngine.getModes(canonicalStudyId).catch(() => null); const developmentModeEnabledForAlert = resolvedModes?.developmentModeEnabled ?? false; const fallbackModes = { @@ -203,11 +211,9 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { dataSharingEnabled: resolvedModes?.dataSharingEnabled ?? true, dataCollectionEnabled: false, }; - const urlParticipantId = activeConfig.uiConfig.urlParticipantIdParam - ? searchParams.get(activeConfig.uiConfig.urlParticipantIdParam) - || undefined - : undefined; - const resumeParticipantId = participantId || urlParticipantId; + const resumeParticipantId = participantId + || urlParticipantId + || await storageEngine.peekCurrentParticipantId(canonicalStudyId); const initialAlertModal = !isStorageFailure ? getInitialStartupAlert(error, developmentModeEnabledForAlert, resumeParticipantId) : undefined; diff --git a/src/components/Shell.utils.spec.ts b/src/components/Shell.utils.spec.ts index 23f500b097..95c5feafd0 100644 --- a/src/components/Shell.utils.spec.ts +++ b/src/components/Shell.utils.spec.ts @@ -33,6 +33,24 @@ describe('Shell utilities', () => { expect(isStorageStartupFailure(mockStorageEngine, 'supabase')).toBe(true); }); + test('detects cloud storage startup failures from thrown storage operations', () => { + const mockStorageEngine = { + getEngine: () => 'supabase' as const, + isConnected: () => true, + }; + + expect(isStorageStartupFailure(mockStorageEngine, 'supabase', true)).toBe(true); + }); + + test('does not treat local storage startup errors as connectivity failures', () => { + const mockStorageEngine = { + getEngine: () => 'localStorage' as const, + isConnected: () => true, + }; + + expect(isStorageStartupFailure(mockStorageEngine, 'localStorage', true)).toBe(false); + }); + test('uses the caught error message in development mode', () => { expect(getInitialStartupAlert(new Error('Bad startup state'), true, null)).toEqual({ show: true, diff --git a/src/components/Shell.utils.ts b/src/components/Shell.utils.ts index 885503e246..ae0755abff 100644 --- a/src/components/Shell.utils.ts +++ b/src/components/Shell.utils.ts @@ -13,8 +13,13 @@ export function getScreenOrientationType(screen: Screen) { export function isStorageStartupFailure( storageEngine: StartupStorageStatus, configuredEngine: string, + storageOperationFailed: boolean = false, ) { - return !storageEngine.isConnected() || storageEngine.getEngine() !== configuredEngine; + if (!storageEngine.isConnected() || storageEngine.getEngine() !== configuredEngine) { + return true; + } + + return storageOperationFailed && configuredEngine !== 'localStorage'; } export function getStartupErrorMessage(error: unknown) { diff --git a/src/storage/engines/types.ts b/src/storage/engines/types.ts index 11043cf4ba..db9de5cb59 100644 --- a/src/storage/engines/types.ts +++ b/src/storage/engines/types.ts @@ -453,6 +453,17 @@ export abstract class StorageEngine { return this.currentParticipantId; } + // Returns the current participant ID if it already exists in memory or persistence without creating a new one. + async peekCurrentParticipantId(studyId?: string) { + const studyIdToUse = studyId || this.studyId; + if (studyIdToUse) { + const storageKey = `${this.collectionPrefix}${studyIdToUse}/currentParticipantId`; + return await this.participantStore.getItem(storageKey) || undefined; + } + + return this.currentParticipantId; + } + // Clears the current participant ID from persistence and resets the currentParticipantId property. async clearCurrentParticipantId() { this.currentParticipantId = undefined; diff --git a/src/storage/tests/highLevel.spec.ts b/src/storage/tests/highLevel.spec.ts index e332563122..fac5483786 100644 --- a/src/storage/tests/highLevel.spec.ts +++ b/src/storage/tests/highLevel.spec.ts @@ -147,6 +147,14 @@ describe.each([ expect(currentParticipantId).toBeDefined(); }); + test('peekCurrentParticipantId returns persisted IDs without creating a new one', async () => { + expect(await storageEngine.peekCurrentParticipantId(studyId)).toBeUndefined(); + + const participantSession = await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata); + + expect(await storageEngine.peekCurrentParticipantId(studyId)).toBe(participantSession.participantId); + }); + // clearCurrentParticipantId tested in rejectParticipant test // _getSequence tested in rejectParticipant test From 72156142fc5ecd36f2645bb36231af49e2fa691c Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Sun, 29 Mar 2026 22:24:48 -0600 Subject: [PATCH 03/41] Inline Shell startup helpers --- src/components/Shell.tsx | 55 +++++++++++++++++++--- src/components/Shell.utils.spec.ts | 73 ------------------------------ src/components/Shell.utils.ts | 49 -------------------- 3 files changed, 49 insertions(+), 128 deletions(-) delete mode 100644 src/components/Shell.utils.spec.ts delete mode 100644 src/components/Shell.utils.ts diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index f49fb7be93..b17c063e51 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -26,23 +26,66 @@ import { StepRenderer } from './StepRenderer'; import { useStorageEngine } from '../storage/storageEngineHooks'; import { generateSequenceArray } from '../utils/handleRandomSequences'; import { getStudyConfig, resolveConfigKey } from '../utils/fetchConfig'; -import { ParticipantMetadata } from '../store/types'; +import type { AlertModalState, ParticipantMetadata } from '../store/types'; import { ErrorLoadingConfig } from './ErrorLoadingConfig'; import { ResourceNotFound } from '../ResourceNotFound'; import { encryptIndex } from '../utils/encryptDecryptIndex'; import { parseStudyConfig } from '../parser/parser'; import { hash } from '../storage/engines/utils'; +import type { StorageEngine } from '../storage/engines/types'; import { REVISIT_MODE } from '../storage/engines/types'; import { filterSequenceByCondition, parseConditionParam, resolveParticipantConditions, } from '../utils/handleConditionLogic'; -import { - getInitialStartupAlert, - getScreenOrientationType, - isStorageStartupFailure, -} from './Shell.utils'; + +type StartupStorageStatus = Pick; + +const GENERIC_STARTUP_ERROR = 'There was a problem loading the study.'; +const RESUME_STARTUP_ERROR = 'This study session could not be resumed.'; + +export function getScreenOrientationType(screen: Screen) { + return screen.orientation?.type ?? ''; +} + +export function isStorageStartupFailure( + storageEngine: StartupStorageStatus, + configuredEngine: string, + storageOperationFailed: boolean = false, +) { + if (!storageEngine.isConnected() || storageEngine.getEngine() !== configuredEngine) { + return true; + } + + return storageOperationFailed && configuredEngine !== 'localStorage'; +} + +export function getStartupErrorMessage(error: unknown) { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + if (typeof error === 'string' && error.trim().length > 0) { + return error; + } + + return GENERIC_STARTUP_ERROR; +} + +export function getInitialStartupAlert( + error: unknown, + developmentModeEnabled: boolean, + resumeParticipantId?: string | null, +): AlertModalState { + return { + show: true, + title: 'Problem loading the study', + message: developmentModeEnabled + ? getStartupErrorMessage(error) + : (resumeParticipantId ? RESUME_STARTUP_ERROR : GENERIC_STARTUP_ERROR), + }; +} export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { // Pull study config diff --git a/src/components/Shell.utils.spec.ts b/src/components/Shell.utils.spec.ts deleted file mode 100644 index 95c5feafd0..0000000000 --- a/src/components/Shell.utils.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { - getInitialStartupAlert, - getScreenOrientationType, - getStartupErrorMessage, - isStorageStartupFailure, -} from './Shell.utils'; - -describe('Shell utilities', () => { - test('returns an empty orientation when screen.orientation is unavailable', () => { - const mockScreen = { - orientation: undefined, - } as unknown as Screen; - - expect(getScreenOrientationType(mockScreen)).toBe(''); - }); - - test('detects storage startup failures from connectivity', () => { - const mockStorageEngine = { - getEngine: () => 'supabase' as const, - isConnected: () => false, - }; - - expect(isStorageStartupFailure(mockStorageEngine, 'supabase')).toBe(true); - }); - - test('detects storage startup failures from engine mismatch', () => { - const mockStorageEngine = { - getEngine: () => 'localStorage' as const, - isConnected: () => true, - }; - - expect(isStorageStartupFailure(mockStorageEngine, 'supabase')).toBe(true); - }); - - test('detects cloud storage startup failures from thrown storage operations', () => { - const mockStorageEngine = { - getEngine: () => 'supabase' as const, - isConnected: () => true, - }; - - expect(isStorageStartupFailure(mockStorageEngine, 'supabase', true)).toBe(true); - }); - - test('does not treat local storage startup errors as connectivity failures', () => { - const mockStorageEngine = { - getEngine: () => 'localStorage' as const, - isConnected: () => true, - }; - - expect(isStorageStartupFailure(mockStorageEngine, 'localStorage', true)).toBe(false); - }); - - test('uses the caught error message in development mode', () => { - expect(getInitialStartupAlert(new Error('Bad startup state'), true, null)).toEqual({ - show: true, - title: 'Problem loading the study', - message: 'Bad startup state', - }); - }); - - test('uses resume copy outside development mode when resuming a participant', () => { - expect(getInitialStartupAlert(new Error('ignored'), false, 'abc123')).toEqual({ - show: true, - title: 'Problem loading the study', - message: 'This study session could not be resumed.', - }); - }); - - test('falls back to the generic startup message for empty errors', () => { - expect(getStartupErrorMessage('')).toBe('There was a problem loading the study.'); - }); -}); diff --git a/src/components/Shell.utils.ts b/src/components/Shell.utils.ts deleted file mode 100644 index ae0755abff..0000000000 --- a/src/components/Shell.utils.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { StorageEngine } from '../storage/engines/types'; -import type { AlertModalState } from '../store/types'; - -type StartupStorageStatus = Pick; - -const GENERIC_STARTUP_ERROR = 'There was a problem loading the study.'; -const RESUME_STARTUP_ERROR = 'This study session could not be resumed.'; - -export function getScreenOrientationType(screen: Screen) { - return screen.orientation?.type ?? ''; -} - -export function isStorageStartupFailure( - storageEngine: StartupStorageStatus, - configuredEngine: string, - storageOperationFailed: boolean = false, -) { - if (!storageEngine.isConnected() || storageEngine.getEngine() !== configuredEngine) { - return true; - } - - return storageOperationFailed && configuredEngine !== 'localStorage'; -} - -export function getStartupErrorMessage(error: unknown) { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - - if (typeof error === 'string' && error.trim().length > 0) { - return error; - } - - return GENERIC_STARTUP_ERROR; -} - -export function getInitialStartupAlert( - error: unknown, - developmentModeEnabled: boolean, - resumeParticipantId?: string | null, -): AlertModalState { - return { - show: true, - title: 'Problem loading the study', - message: developmentModeEnabled - ? getStartupErrorMessage(error) - : (resumeParticipantId ? RESUME_STARTUP_ERROR : GENERIC_STARTUP_ERROR), - }; -} From 0e0510eda2521594c3b47a75dc36cafd4f2a0d01 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Sun, 29 Mar 2026 23:22:03 -0600 Subject: [PATCH 04/41] Guard Shell startup participant lookup fallback --- src/components/Shell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index b17c063e51..9d798dfbd5 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -256,7 +256,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { }; const resumeParticipantId = participantId || urlParticipantId - || await storageEngine.peekCurrentParticipantId(canonicalStudyId); + || await storageEngine.peekCurrentParticipantId(canonicalStudyId).catch(() => undefined); const initialAlertModal = !isStorageFailure ? getInitialStartupAlert(error, developmentModeEnabledForAlert, resumeParticipantId) : undefined; From 5040df61237d357cf08dd47f4de7db1f7dafa559 Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:32:21 -0600 Subject: [PATCH 05/41] Fix typo in library calvi question description --- public/libraries/calvi/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/libraries/calvi/config.json b/public/libraries/calvi/config.json index 41238f02ec..16da03eef5 100644 --- a/public/libraries/calvi/config.json +++ b/public/libraries/calvi/config.json @@ -5,7 +5,7 @@ "reference": "Lily W. Ge, Yuan Cui, and Matthew Kay. 2023. CALVI: Critical Thinking Assessment for Literacy in Visualizations. In Proceedings of the 2023 CHI Conference on Human Factors in Computing Systems (CHI '23). Association for Computing Machinery, New York, NY, USA, Article 815, 1-18.", "components": { "N1": { - "description": "Audio test", + "description": "Visitors at Movie Theaters in 2001", "type": "image", "path": "libraries/calvi/assets/questions/normal/N1.jpg", "nextButtonLocation": "sidebar", From ba350aad5471c64660e3d73ae53eba4928b11178 Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:59:01 -0600 Subject: [PATCH 06/41] Fix UI break when user rejects audio recording --- public/libraries/mic-check/config.json | 1 + public/libraries/screen-recording/config.json | 18 +++++++++-------- public/library-screen-recording/config.json | 3 +-- src/components/interface/AppHeader.tsx | 20 ++++++++++++------- src/store/hooks/useRecording.ts | 12 ++++++++--- 5 files changed, 34 insertions(+), 20 deletions(-) diff --git a/public/libraries/mic-check/config.json b/public/libraries/mic-check/config.json index da5c17ec3b..399e1a5426 100644 --- a/public/libraries/mic-check/config.json +++ b/public/libraries/mic-check/config.json @@ -8,6 +8,7 @@ "path": "libraries/mic-check/assets/AudioTest.tsx", "nextButtonLocation": "belowStimulus", "nextButtonText": "Continue", + "recordAudio": true, "response": [ { "id": "audioTest", diff --git a/public/libraries/screen-recording/config.json b/public/libraries/screen-recording/config.json index a7070604b2..1fe724d0cb 100644 --- a/public/libraries/screen-recording/config.json +++ b/public/libraries/screen-recording/config.json @@ -8,14 +8,16 @@ "path": "libraries/screen-recording/assets/ScreenRecording.tsx", "nextButtonLocation": "belowStimulus", "nextButtonText": "Continue", - "recordAudio": false, - "response": [{ - "hidden": true, - "type": "reactive", - "id": "screenRecordingPermission", - "prompt": "Screen recording enabled" - }] + "recordScreen": true, + "response": [ + { + "hidden": true, + "type": "reactive", + "id": "screenRecordingPermission", + "prompt": "Screen recording enabled" + } + ] } }, "sequences": {} -} +} \ No newline at end of file diff --git a/public/library-screen-recording/config.json b/public/library-screen-recording/config.json index 9a162a5512..fb8ae28186 100644 --- a/public/library-screen-recording/config.json +++ b/public/library-screen-recording/config.json @@ -17,8 +17,7 @@ "contactEmail": "", "logoPath": "revisitAssets/revisitLogoSquare.svg", "withProgressBar": true, - "withSidebar": false, - "recordScreen": true + "withSidebar": false }, "importedLibraries": [ "screen-recording" diff --git a/src/components/interface/AppHeader.tsx b/src/components/interface/AppHeader.tsx index acbc3d638b..95688567ad 100644 --- a/src/components/interface/AppHeader.tsx +++ b/src/components/interface/AppHeader.tsx @@ -90,7 +90,7 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d const lastProgressRef = useRef(0); const { - isScreenRecording, isAudioRecording, setIsMuted, isMuted, clickToRecord, isSpeakingWhileMuted, showMutedWarning, + isScreenRecording, isAudioRecording, setIsMuted, isMuted, clickToRecord, isSpeakingWhileMuted, showMutedWarning, audioRecordingError, } = useRecordingContext(); const { isBrowserAllowed, @@ -199,13 +199,19 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d - {((isAudioRecording && !isMuted) || (isScreenRecording)) && 'Recording'} + {((isAudioRecording && !isMuted && !audioRecordingError) || (isScreenRecording)) && 'Recording'} {isScreenRecording && ' screen'} - {isScreenRecording && isAudioRecording && !isMuted && ' and'} - {isAudioRecording && !isMuted && ' audio'} + {isScreenRecording && isAudioRecording && !isMuted && !audioRecordingError && ' and'} + {isAudioRecording && !isMuted && !audioRecordingError && ' audio'} - {isAudioRecording && !isMuted && } - {clickToRecord ? ( + {isAudioRecording && !isMuted && !audioRecordingError && } + {isAudioRecording && (audioRecordingError ? ( + + + + + + ) : clickToRecord ? ( setIsMuted(false)} onMouseUp={() => setIsMuted(true)} onTouchStart={() => setIsMuted(false)} onTouchEnd={() => setIsMuted(true)}> {isMuted ? : } @@ -217,7 +223,7 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d {isMuted ? : } - )} + ))} )} {storageEngineFailedToConnect && Storage Disconnected} diff --git a/src/store/hooks/useRecording.ts b/src/store/hooks/useRecording.ts index 97c99a41f7..71ac94c6c6 100644 --- a/src/store/hooks/useRecording.ts +++ b/src/store/hooks/useRecording.ts @@ -26,6 +26,7 @@ export function useRecording() { const recordVideoRef = useRef(null); const [screenRecordingError, setRecordingError] = useState(null); + const [audioRecordingError, setAudioRecordingError] = useState(null); const [isScreenRecording, setIsScreenRecording] = useState(false); const [isAudioRecording, setIsAudioRecording] = useState(false); const [screenWithAudioRecording, setScreenWithAudioRecording] = useState(false); @@ -152,7 +153,7 @@ export function useRecording() { const audioRecorder = (currentComponentHasAudioRecording && audioMediaStream.current) ? new MediaRecorder(audioMediaStream.current) : null; audioMediaRecorder.current = audioRecorder; - let chunks : Blob[] = []; + let chunks: Blob[] = []; mediaRecorder.addEventListener('dataavailable', (event: BlobEvent) => { if (event.data && event.data.size > 0) { chunks.push(event.data); @@ -266,7 +267,7 @@ export function useRecording() { const recorder = new MediaRecorder(s); audioMediaRecorder.current = recorder; - let chunks : Blob[] = []; + let chunks: Blob[] = []; recorder.addEventListener('start', () => { chunks = []; @@ -287,6 +288,10 @@ export function useRecording() { }); recorder.start(); + setAudioRecordingError(null); + }).catch((err) => { + console.error('Error accessing microphone:', err); + setAudioRecordingError('Microphone permission denied or not supported.'); }); setIsAudioRecording(true); @@ -315,7 +320,7 @@ export function useRecording() { startAudioRecording(identifier); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentComponent, identifier, currentComponentHasAudioRecording]); // For study with screen recording @@ -491,6 +496,7 @@ export function useRecording() { startScreenRecording, stopScreenRecording, screenRecordingError, + audioRecordingError, isScreenRecording, isAudioRecording, isScreenCapturing, From 42fabb06c6d44946c407feb784f29ca3e20acbe7 Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:23:42 -0600 Subject: [PATCH 07/41] Add aria-disabled and tab index -1 to mic error icon --- src/components/interface/AppHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/interface/AppHeader.tsx b/src/components/interface/AppHeader.tsx index 95688567ad..46f3615327 100644 --- a/src/components/interface/AppHeader.tsx +++ b/src/components/interface/AppHeader.tsx @@ -207,7 +207,7 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d {isAudioRecording && !isMuted && !audioRecordingError && } {isAudioRecording && (audioRecordingError ? ( - + From 9be9e0bf29c3f14243ce108dc0fd7684f394b704 Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:30:05 -0600 Subject: [PATCH 08/41] Revert record screen field in library-screen-recording --- public/library-screen-recording/config.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/library-screen-recording/config.json b/public/library-screen-recording/config.json index fb8ae28186..9a162a5512 100644 --- a/public/library-screen-recording/config.json +++ b/public/library-screen-recording/config.json @@ -17,7 +17,8 @@ "contactEmail": "", "logoPath": "revisitAssets/revisitLogoSquare.svg", "withProgressBar": true, - "withSidebar": false + "withSidebar": false, + "recordScreen": true }, "importedLibraries": [ "screen-recording" From 4854c4f170c0fa0e341c5164953e11ac08767f0c Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:32:42 -0600 Subject: [PATCH 09/41] Add screen recording icon to timeline --- .../individualStudy/replay/AllTasksTimeline.tsx | 3 ++- src/analysis/individualStudy/replay/SingleTask.tsx | 12 ++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx index 846cd13572..bde12d1ce2 100644 --- a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx +++ b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx @@ -119,6 +119,7 @@ export function AllTasksTimeline({ const isCorrect = componentAnswersAreCorrect(answer.answer, answer.correctAnswer, resolvedComponent?.response); const hasCorrect = !!((resolvedComponent && resolvedComponent.correctAnswer) || answer.correctAnswer.length > 0); const hasAudio = resolvedComponent?.recordAudio ?? studyConfig?.uiConfig?.recordAudio ?? false; + const hasScreenRecording = resolvedComponent?.recordScreen ?? studyConfig?.uiConfig?.recordScreen ?? false; return { line: , @@ -147,7 +148,7 @@ export function AllTasksTimeline({ )} > - + ), }; diff --git a/src/analysis/individualStudy/replay/SingleTask.tsx b/src/analysis/individualStudy/replay/SingleTask.tsx index 9a7158d49e..7fa6a2c976 100644 --- a/src/analysis/individualStudy/replay/SingleTask.tsx +++ b/src/analysis/individualStudy/replay/SingleTask.tsx @@ -4,7 +4,7 @@ import * as d3 from 'd3'; import { useResizeObserver } from '@mantine/hooks'; import { - IconCheck, IconMicrophone, IconProgress, IconX, + IconCheck, IconDeviceDesktop, IconMicrophone, IconProgress, IconX, } from '@tabler/icons-react'; import { useNavigateToTrial } from '../../../utils/useNavigateToTrial'; @@ -24,6 +24,7 @@ export function SingleTask({ isCorrect, hasCorrect, hasAudio, + hasScreenRecording, scaleStart, scaleEnd, incomplete, @@ -39,6 +40,7 @@ export function SingleTask({ isCorrect: boolean, hasCorrect: boolean, hasAudio: boolean, + hasScreenRecording: boolean, scaleStart: number, scaleEnd: number, incomplete: boolean, @@ -52,7 +54,7 @@ export function SingleTask({ const [ref, { width: labelWidth }] = useResizeObserver(); const navigateToTrial = useNavigateToTrial(); - const iconCount = (incomplete || hasCorrect ? 1 : 0) + (hasAudio ? 1 : 0); + const iconCount = (incomplete || hasCorrect ? 1 : 0) + (hasAudio ? 1 : 0) + (hasScreenRecording ? 1 : 0); const iconsWidth = iconCount * (ICON_SIZE + ICON_GAP); return ( @@ -93,6 +95,12 @@ export function SingleTask({ {name} + {hasScreenRecording && ( + + )} {hasAudio && ( Date: Fri, 10 Apr 2026 14:09:12 -0600 Subject: [PATCH 10/41] Address PR comment --- src/store/hooks/useRecording.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/store/hooks/useRecording.ts b/src/store/hooks/useRecording.ts index 71ac94c6c6..d2d5cc84ca 100644 --- a/src/store/hooks/useRecording.ts +++ b/src/store/hooks/useRecording.ts @@ -289,12 +289,12 @@ export function useRecording() { recorder.start(); setAudioRecordingError(null); + setIsAudioRecording(true); }).catch((err) => { console.error('Error accessing microphone:', err); setAudioRecordingError('Microphone permission denied or not supported.'); + setIsAudioRecording(false); }); - - setIsAudioRecording(true); }, [storageEngine, isMuted]); // For study with just audio recording From 503a9c7b7ab2fc160b8d81ccdc43caa71e5e3437 Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:52:48 -0600 Subject: [PATCH 11/41] Fix mic icon bug if mic permission is disabled --- src/components/interface/AppHeader.spec.tsx | 187 ++++++++++++++++++ src/components/interface/AppHeader.tsx | 37 +++- .../assets/ScreenRecording.tsx | 16 +- src/store/hooks/useRecording.ts | 9 + 4 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 src/components/interface/AppHeader.spec.tsx diff --git a/src/components/interface/AppHeader.spec.tsx b/src/components/interface/AppHeader.spec.tsx new file mode 100644 index 0000000000..c459f3f9c0 --- /dev/null +++ b/src/components/interface/AppHeader.spec.tsx @@ -0,0 +1,187 @@ +import { ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + beforeEach, describe, expect, test, vi, +} from 'vitest'; +import { AppHeader } from './AppHeader'; + +let mockedRecordingContext = { + isScreenRecording: false, + isAudioRecording: false, + setIsMuted: vi.fn(), + isMuted: false, + clickToRecord: false, + isSpeakingWhileMuted: false, + showMutedWarning: false, + audioRecordingError: null as string | null, + currentComponentHasAudioRecording: false, + audioStatus: 'idle' as 'idle' | 'pending' | 'recording' | 'denied', +}; + +const mockedStudyConfig = { + studyMetadata: { title: 'Test Study' }, + uiConfig: { + logoPath: 'logo.png', + withProgressBar: false, + showTitle: true, + }, + components: {}, + studyRules: undefined, +}; + +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, ...props }: { children: ReactNode }) => , + AppShell: { Header: ({ children }: { children: ReactNode }) =>
{children}
}, + Badge: ({ children }: { children: ReactNode }) => {children}, + Button: ({ children, ...props }: { children: ReactNode }) => , + Flex: ({ children }: { children: ReactNode }) =>
{children}
, + Grid: Object.assign( + ({ children }: { children: ReactNode }) =>
{children}
, + { Col: ({ children }: { children: ReactNode }) =>
{children}
}, + ), + Group: ({ children }: { children: ReactNode }) =>
{children}
, + Image: ({ alt }: { alt: string }) => {alt}, + Menu: Object.assign( + ({ children }: { children: ReactNode }) =>
{children}
, + { + Target: ({ children }: { children: ReactNode }) =>
{children}
, + Dropdown: ({ children }: { children: ReactNode }) =>
{children}
, + Item: ({ children }: { children: ReactNode }) =>
{children}
, + }, + ), + Progress: () =>
progress
, + Space: () =>
, + Title: ({ children }: { children: ReactNode }) =>

{children}

, + Tooltip: ({ children, label }: { children: ReactNode; label?: ReactNode }) => ( +
+ {children} + {label ? {label} : null} +
+ ), + Text: ({ children }: { children: ReactNode }) => {children}, +})); + +vi.mock('@tabler/icons-react', () => ({ + IconChartHistogram: () => chart, + IconDotsVertical: () => dots, + IconMail: () => mail, + IconMicrophone: () => mic, + IconMicrophoneOff: () => mic-off, + IconSchema: () => schema, + IconUserPlus: () => user-plus, +})); + +vi.mock('react-router', () => ({ + useHref: () => '/test-study', + useParams: () => ({}), +})); + +vi.mock('../../routes/utils', () => ({ + useCurrentComponent: () => 'componentA', + useCurrentStep: () => 0, + useStudyId: () => 'test-study', +})); + +vi.mock('../../store/store', () => ({ + useStoreDispatch: () => vi.fn(), + useStoreSelector: (selector: (state: { + config: typeof mockedStudyConfig; + answers: Record; + storageEngineFailedToConnect: boolean; + }) => unknown) => selector({ + config: mockedStudyConfig, + answers: {}, + storageEngineFailedToConnect: false, + }), + useStoreActions: () => ({ + toggleShowHelpText: vi.fn(), + toggleStudyBrowser: vi.fn(), + incrementHelpCounter: vi.fn(), + setAlertModal: vi.fn(), + }), + useFlatSequence: () => [], +})); + +vi.mock('../../storage/storageEngineHooks', () => ({ + useStorageEngine: () => ({ + storageEngine: { + updateProgressData: vi.fn().mockResolvedValue(undefined), + }, + }), +})); + +vi.mock('../../utils/handleComponentInheritance', () => ({ + studyComponentToIndividualComponent: () => ({}), +})); + +vi.mock('../../store/hooks/useRecording', () => ({ + useRecordingContext: () => mockedRecordingContext, +})); + +vi.mock('../../utils/notifications', () => ({ + hideNotification: vi.fn(), + showNotification: vi.fn(() => 'notification-id'), +})); + +vi.mock('../../utils/recordingWarnings', () => ({ + getMutedInstruction: () => 'Muted warning', +})); + +vi.mock('../../utils/useDeviceRules', () => ({ + useDeviceRules: () => ({ + isBrowserAllowed: true, + isDeviceAllowed: true, + isInputAllowed: true, + isDisplayAllowed: true, + }), +})); + +vi.mock('./RecordingAudioWaveform', () => ({ + RecordingAudioWaveform: () =>
waveform
, +})); + +describe('AppHeader', () => { + beforeEach(() => { + mockedRecordingContext = { + isScreenRecording: false, + isAudioRecording: false, + setIsMuted: vi.fn(), + isMuted: false, + clickToRecord: false, + isSpeakingWhileMuted: false, + showMutedWarning: false, + audioRecordingError: null, + currentComponentHasAudioRecording: false, + audioStatus: 'idle', + }; + }); + + test('shows disabled mic state when audio permission is denied before recording starts', () => { + mockedRecordingContext = { + ...mockedRecordingContext, + currentComponentHasAudioRecording: true, + audioRecordingError: 'Microphone permission denied or not supported.', + audioStatus: 'denied', + }; + + const html = renderToStaticMarkup(); + + expect(html).toContain('Microphone error'); + expect(html).toContain('Microphone permission denied or not supported.'); + expect(html).toContain('mic-off'); + }); + + test('shows pending mic state before audio permission is granted', () => { + mockedRecordingContext = { + ...mockedRecordingContext, + currentComponentHasAudioRecording: true, + audioStatus: 'pending', + }; + + const html = renderToStaticMarkup(); + + expect(html).toContain('Microphone pending'); + expect(html).toContain('Microphone not enabled yet'); + expect(html).toContain('mic-off'); + }); +}); diff --git a/src/components/interface/AppHeader.tsx b/src/components/interface/AppHeader.tsx index a7a8475a1e..10aaca3f5c 100644 --- a/src/components/interface/AppHeader.tsx +++ b/src/components/interface/AppHeader.tsx @@ -90,7 +90,16 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d const lastProgressRef = useRef(0); const { - isScreenRecording, isAudioRecording, setIsMuted, isMuted, clickToRecord, isSpeakingWhileMuted, showMutedWarning, audioRecordingError, + isScreenRecording, + isAudioRecording, + setIsMuted, + isMuted, + clickToRecord, + isSpeakingWhileMuted, + showMutedWarning, + audioRecordingError, + currentComponentHasAudioRecording, + audioStatus, } = useRecordingContext(); const { isBrowserAllowed, @@ -100,6 +109,8 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d } = useDeviceRules(studyConfig.studyRules); const hasUnmetDeviceRequirement = developmentModeEnabled && (!isBrowserAllowed || !isDeviceAllowed || !isInputAllowed || !isDisplayAllowed); + const showAudioStatus = currentComponentHasAudioRecording || isAudioRecording || audioStatus !== 'idle'; + const showRecordingStatus = showAudioStatus || isScreenRecording; useEffect(() => { if (!(isMuted && isSpeakingWhileMuted)) return undefined; @@ -195,31 +206,37 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d - {(isAudioRecording || isScreenRecording) && ( + {showRecordingStatus && ( - {((isAudioRecording && !isMuted && !audioRecordingError) || (isScreenRecording)) && 'Recording'} + {((audioStatus === 'recording' && !isMuted) || (isScreenRecording)) && 'Recording'} {isScreenRecording && ' screen'} - {isScreenRecording && isAudioRecording && !isMuted && !audioRecordingError && ' and'} - {isAudioRecording && !isMuted && !audioRecordingError && ' audio'} + {isScreenRecording && audioStatus === 'recording' && !isMuted && ' and'} + {audioStatus === 'recording' && !isMuted && ' audio'} - {isAudioRecording && !isMuted && !audioRecordingError && } - {isAudioRecording && (audioRecordingError ? ( + {audioStatus === 'recording' && !isMuted && } + {showAudioStatus && (audioStatus === 'denied' ? ( - + + + + + ) : audioStatus === 'pending' ? ( + + ) : clickToRecord ? ( - setIsMuted(false)} onMouseUp={() => setIsMuted(true)} onTouchStart={() => setIsMuted(false)} onTouchEnd={() => setIsMuted(true)}> + setIsMuted(false)} onMouseUp={() => setIsMuted(true)} onTouchStart={() => setIsMuted(false)} onTouchEnd={() => setIsMuted(true)}> {isMuted ? : } ) : ( - setIsMuted(!isMuted)}> + setIsMuted(!isMuted)}> {isMuted ? : } diff --git a/src/public/libraries/screen-recording/assets/ScreenRecording.tsx b/src/public/libraries/screen-recording/assets/ScreenRecording.tsx index ce56172910..5a49251201 100644 --- a/src/public/libraries/screen-recording/assets/ScreenRecording.tsx +++ b/src/public/libraries/screen-recording/assets/ScreenRecording.tsx @@ -8,12 +8,14 @@ import { RecordingAudioWaveform } from '../../../../components/interface/Recordi function ScreenRecordingPermission({ setAnswer }: StimulusParams) { const { - recordAudio, + studyHasAudioRecording, recordVideoRef, startScreenCapture: startCapture, stopScreenCapture: stopCapture, isScreenCapturing: screenCapturing, + isAudioCapturing: audioCapturing, screenRecordingError: error, + audioRecordingError, audioMediaStream, } = useRecordingContext(); @@ -22,13 +24,13 @@ function ScreenRecordingPermission({ setAnswer }: StimulusParams) { useEffect(() => { setAnswer({ - status: screenCapturing && (recordAudio ? audioCapturingSuccess : true), + status: screenCapturing && (studyHasAudioRecording ? audioCapturingSuccess : true), provenanceGraph: undefined, answers: { screenRecordingPermission: screenCapturing, }, }); - }, [screenCapturing, audioCapturingSuccess, setAnswer, recordAudio]); + }, [screenCapturing, audioCapturingSuccess, setAnswer, studyHasAudioRecording]); useEffect(() => { if (!screenCapturing) { @@ -78,12 +80,12 @@ function ScreenRecordingPermission({ setAnswer }: StimulusParams) { Screen - {recordAudio && ' and Audio'} + {studyHasAudioRecording && ' and Audio'} {' '} Recording Permission - {recordAudio ? ( + {studyHasAudioRecording ? ( <> {/* Record both screen and audio */}

@@ -113,7 +115,7 @@ function ScreenRecordingPermission({ setAnswer }: StimulusParams) { muted style={{ width: '400px', border: '1px solid #ccc', marginTop: '1rem' }} /> - {error &&

{error}

} + {(error || audioRecordingError) &&

{error || audioRecordingError}

}

Note: Please make sure you are recording the correct tab or window. Otherwise, stop and re-share the correct one.

@@ -121,7 +123,7 @@ function ScreenRecordingPermission({ setAnswer }: StimulusParams) { Speak {' '} into your microphone to check if audio is working. - {(recordAudio && screenCapturing) ? : } + {audioCapturing ? : } Note: diff --git a/src/store/hooks/useRecording.ts b/src/store/hooks/useRecording.ts index d2d5cc84ca..4dff93fcfc 100644 --- a/src/store/hooks/useRecording.ts +++ b/src/store/hooks/useRecording.ts @@ -488,6 +488,8 @@ export function useRecording() { return { recordVideoRef, studyHasScreenRecording, + studyHasAudioRecording, + currentComponentHasAudioRecording, isMuted, setIsMuted, recordAudio, @@ -509,6 +511,13 @@ export function useRecording() { isRejected, isSpeakingWhileMuted, showMutedWarning, + audioStatus: audioRecordingError + ? 'denied' + : isAudioRecording + ? 'recording' + : currentComponentHasAudioRecording + ? 'pending' + : 'idle', }; } From 96c1761786cdda0a466bfdd9e4e4f188285e79d2 Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:45:37 -0600 Subject: [PATCH 12/41] Fix back button enabled in the study replay --- src/store/hooks/usePreviousStep.spec.tsx | 105 +++++++++++++++++++++++ src/store/hooks/usePreviousStep.ts | 10 ++- src/utils/useDisableBrowserBack.spec.tsx | 82 ++++++++++++++++++ src/utils/useDisableBrowserBack.tsx | 12 ++- 4 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 src/store/hooks/usePreviousStep.spec.tsx create mode 100644 src/utils/useDisableBrowserBack.spec.tsx diff --git a/src/store/hooks/usePreviousStep.spec.tsx b/src/store/hooks/usePreviousStep.spec.tsx new file mode 100644 index 0000000000..b83b3348d6 --- /dev/null +++ b/src/store/hooks/usePreviousStep.spec.tsx @@ -0,0 +1,105 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { + beforeEach, + describe, + expect, + test, + vi, +} from 'vitest'; +import { usePreviousStep } from './usePreviousStep'; + +const mockNavigate = vi.fn(); +const mockDeleteDynamicBlockAnswers = vi.fn((payload) => ({ type: 'deleteDynamicBlockAnswers', payload })); +const mockDispatch = vi.fn(); + +let mockCurrentStep = 1; +let mockFuncIndex: string | undefined; +let mockIsAnalysis = false; +let capturedHook: ReturnType | undefined; + +vi.mock('react-router', () => ({ + useNavigate: () => mockNavigate, + useParams: () => ({ funcIndex: mockFuncIndex }), +})); + +vi.mock('../../routes/utils', () => ({ + useCurrentStep: () => mockCurrentStep, + useStudyId: () => 'study-1', +})); + +vi.mock('./useIsAnalysis', () => ({ + useIsAnalysis: () => mockIsAnalysis, +})); + +vi.mock('./useStudyConfig', () => ({ + useStudyConfig: () => ({ + sequence: { + order: 'fixed', + components: ['intro', 'dynamic-block', 'end'], + }, + }), +})); + +vi.mock('../../utils/getSequenceFlatMap', () => ({ + getSequenceFlatMap: () => ['intro', 'dynamic-block', 'end'], + findFuncBlock: (componentId: string) => (componentId === 'dynamic-block' ? { id: 'dynamic-block' } : null), +})); + +vi.mock('../../utils/encryptDecryptIndex', () => ({ + decryptIndex: (value: string) => Number(value), + encryptIndex: (value: number) => `enc-${value}`, +})); + +vi.mock('../store', () => ({ + useStoreDispatch: () => mockDispatch, + useStoreActions: () => ({ + deleteDynamicBlockAnswers: mockDeleteDynamicBlockAnswers, + }), + useStoreSelector: (selector: (state: { answers: Record }) => unknown) => selector({ + answers: { + dynamicBlock_1_component_0: { trialOrder: '1_0' }, + dynamicBlock_1_component_1: { trialOrder: '1_1' }, + }, + }), +})); + +function HookHarness() { + capturedHook = usePreviousStep(); + return null; +} + +describe('usePreviousStep', () => { + beforeEach(() => { + mockNavigate.mockReset(); + mockDeleteDynamicBlockAnswers.mockClear(); + mockDispatch.mockReset(); + mockCurrentStep = 1; + mockFuncIndex = undefined; + mockIsAnalysis = false; + capturedHook = undefined; + + vi.stubGlobal('window', { + location: { search: '?participantId=p-1' }, + }); + }); + + test('keeps the previous button enabled during analysis replay', () => { + mockIsAnalysis = true; + + renderToStaticMarkup(); + + expect(capturedHook?.isPreviousDisabled).toBe(false); + }); + + test('does not delete dynamic replay answers when moving backward in analysis replay', () => { + mockIsAnalysis = true; + mockFuncIndex = '1'; + + renderToStaticMarkup(); + + capturedHook?.goToPreviousStep(); + + expect(mockDispatch).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/study-1/enc-1/enc-0?participantId=p-1'); + }); +}); diff --git a/src/store/hooks/usePreviousStep.ts b/src/store/hooks/usePreviousStep.ts index 1096f72ec1..c4228bbbdd 100644 --- a/src/store/hooks/usePreviousStep.ts +++ b/src/store/hooks/usePreviousStep.ts @@ -19,7 +19,7 @@ export function usePreviousStep() { const answers = useStoreSelector((state) => state.answers); // Status of the previous button. If false, the previous button should be disabled - const isPreviousDisabled = typeof currentStep !== 'number' || isAnalysis || currentStep <= 0; + const isPreviousDisabled = typeof currentStep !== 'number' || currentStep <= 0; const goToPreviousStep = useCallback(() => { if (typeof currentStep !== 'number') { @@ -31,8 +31,10 @@ export function usePreviousStep() { // Dynamic block component if (funcIndex) { - // Delete current dynamic block component and go to previous - storeDispatch(deleteDynamicBlockAnswers({ currentStep, funcIndex: decryptIndex(funcIndex), funcName: flatSequence[currentStep] })); + if (!isAnalysis) { + // Delete current dynamic block component and go to previous during participant flow. + storeDispatch(deleteDynamicBlockAnswers({ currentStep, funcIndex: decryptIndex(funcIndex), funcName: flatSequence[currentStep] })); + } // If we're at the first element of a dynamic block, exit the dynamic block if (decryptIndex(funcIndex) !== 0) { @@ -53,7 +55,7 @@ export function usePreviousStep() { } else { navigate(`/${studyId}/${encryptIndex(previousStep)}${window.location.search}`); } - }, [currentStep, funcIndex, navigate, studyId, studyConfig, storeDispatch, deleteDynamicBlockAnswers, answers]); + }, [answers, currentStep, deleteDynamicBlockAnswers, funcIndex, isAnalysis, navigate, storeDispatch, studyConfig, studyId]); return { isPreviousDisabled, diff --git a/src/utils/useDisableBrowserBack.spec.tsx b/src/utils/useDisableBrowserBack.spec.tsx new file mode 100644 index 0000000000..e6d03c0f47 --- /dev/null +++ b/src/utils/useDisableBrowserBack.spec.tsx @@ -0,0 +1,82 @@ +/** @vitest-environment jsdom */ + +import { createRoot } from 'react-dom/client'; +import { act } from 'react'; +import { + afterEach, + beforeEach, + describe, + expect, + test, + vi, +} from 'vitest'; +import { useDisableBrowserBack } from './useDisableBrowserBack'; + +const mockSetAlertModal = vi.fn((payload) => ({ type: 'setAlertModal', payload })); +const mockDispatch = vi.fn(); +const mockPushState = vi.fn(); + +let mockIsAnalysis = false; + +vi.mock('../store/store', () => ({ + useStoreActions: () => ({ + setAlertModal: mockSetAlertModal, + }), + useStoreDispatch: () => mockDispatch, +})); + +vi.mock('../routes/utils', () => ({ + useCurrentStep: () => 1, +})); + +vi.mock('../store/hooks/useIsAnalysis', () => ({ + useIsAnalysis: () => mockIsAnalysis, +})); + +function HookHarness() { + useDisableBrowserBack(); + return null; +} + +describe('useDisableBrowserBack', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + mockIsAnalysis = false; + mockSetAlertModal.mockClear(); + mockDispatch.mockClear(); + mockPushState.mockClear(); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + Object.defineProperty(window, 'history', { + configurable: true, + value: { + pushState: mockPushState, + }, + }); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + window.onpopstate = null; + delete (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + }); + + test('does not intercept browser back in analysis replay', () => { + mockIsAnalysis = true; + + act(() => { + root.render(); + }); + + expect(mockPushState).not.toHaveBeenCalled(); + expect(window.onpopstate).toBeNull(); + }); +}); diff --git a/src/utils/useDisableBrowserBack.tsx b/src/utils/useDisableBrowserBack.tsx index 735425caef..ba812b0665 100644 --- a/src/utils/useDisableBrowserBack.tsx +++ b/src/utils/useDisableBrowserBack.tsx @@ -2,20 +2,28 @@ import { useEffect } from 'react'; import { useStoreActions, useStoreDispatch } from '../store/store'; import { useCurrentStep } from '../routes/utils'; +import { useIsAnalysis } from '../store/hooks/useIsAnalysis'; // Show the error modal when the participant tries to use the browser back button export function useDisableBrowserBack() { const currentStep = useCurrentStep(); const { setAlertModal } = useStoreActions(); const storeDispatch = useStoreDispatch(); + const isAnalysis = useIsAnalysis(); useEffect(() => { - if (import.meta.env.PROD) { + if (import.meta.env.PROD && !isAnalysis) { window.history.pushState(null, '', window.location.href); window.onpopstate = () => { window.history.pushState(null, '', window.location.href); storeDispatch(setAlertModal({ show: true, message: 'Using the browser\'s back button is prohibited during the study.', title: 'Prohibited' })); }; + return () => { + window.onpopstate = null; + }; } - }, [currentStep, setAlertModal, storeDispatch]); + return () => { + window.onpopstate = null; + }; + }, [currentStep, isAnalysis, setAlertModal, storeDispatch]); } From b5ee544f035245bdc756c8a74cb5a6e60339e86e Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:47:51 -0600 Subject: [PATCH 13/41] Fix replay task bug --- .../thinkAloud/ThinkAloudFooter.spec.ts | 39 +++++++++++++++++++ .../thinkAloud/ThinkAloudFooter.tsx | 21 +++++----- .../thinkAloud/taskNavigation.ts | 39 +++++++++++++++++++ 3 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.spec.ts create mode 100644 src/analysis/individualStudy/thinkAloud/taskNavigation.ts diff --git a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.spec.ts b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.spec.ts new file mode 100644 index 0000000000..c6f33f6f82 --- /dev/null +++ b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.spec.ts @@ -0,0 +1,39 @@ +import { + describe, + expect, + test, +} from 'vitest'; +import { encryptIndex } from '../../../utils/encryptDecryptIndex'; +import { buildTaskNavigationTarget } from './taskNavigation'; + +describe('buildTaskNavigationTarget', () => { + test('updates currentTrial when navigating between replay tasks', () => { + const target = buildTaskNavigationTarget({ + answerIdentifier: 'jupyterlite-task-2_8', + trialOrder: '8', + isReplay: true, + studyId: 'buckaroo', + search: '?participantId=66aa582aba2b183e61577d44¤tTrial=jupyterlite-task-1_7', + }); + + expect(target).toEqual({ + pathname: `/buckaroo/${encryptIndex(8)}`, + search: '?participantId=66aa582aba2b183e61577d44¤tTrial=jupyterlite-task-2_8', + }); + }); + + test('keeps analysis tagging navigation search unchanged', () => { + const target = buildTaskNavigationTarget({ + answerIdentifier: 'task_4', + trialOrder: '4', + isReplay: false, + studyId: 'study-1', + search: '?participantId=p-1¤tTrial=task_3', + }); + + expect(target).toEqual({ + pathname: '/analysis/stats/study-1/tagging/task_4', + search: '?participantId=p-1¤tTrial=task_3', + }); + }); +}); diff --git a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx index 6ef8fb1364..b7d3be80d7 100644 --- a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx +++ b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx @@ -36,6 +36,7 @@ import { buildProvenanceLegendEntries, } from '../../../components/audioAnalysis/provenanceColors'; import { revisitPageId, syncChannel } from '../../../utils/syncReplay'; +import { buildTaskNavigationTarget } from './taskNavigation'; const margin = { left: 5, top: 0, right: 5, bottom: 0, @@ -259,19 +260,19 @@ export function ThinkAloudFooter({ return; } - const { step, funcIndex } = parseTrialOrder(answer.trialOrder); - if (step === null) { + const navigationTarget = buildTaskNavigationTarget({ + answerIdentifier, + trialOrder: answer.trialOrder, + isReplay, + studyId, + search: location.search, + }); + + if (!navigationTarget) { return; } - const pathname = isReplay ? (funcIndex === null - ? `/${studyId}/${encryptIndex(step)}` - : `/${studyId}/${encryptIndex(step)}/${encryptIndex(funcIndex)}`) : `/analysis/stats/${studyId}/tagging/${encodeURIComponent(answerIdentifier)}`; - - navigate({ - pathname, - search: location.search, - }); + navigate(navigationTarget); if (answer.trialOrder) { syncChannel.postMessage({ diff --git a/src/analysis/individualStudy/thinkAloud/taskNavigation.ts b/src/analysis/individualStudy/thinkAloud/taskNavigation.ts new file mode 100644 index 0000000000..fcf0e0f5ee --- /dev/null +++ b/src/analysis/individualStudy/thinkAloud/taskNavigation.ts @@ -0,0 +1,39 @@ +import { encryptIndex } from '../../../utils/encryptDecryptIndex'; +import { parseTrialOrder } from '../../../utils/parseTrialOrder'; + +export function buildTaskNavigationTarget({ + answerIdentifier, + trialOrder, + isReplay, + studyId, + search, +}: { + answerIdentifier: string; + trialOrder: string; + isReplay: boolean; + studyId: string; + search: string; +}) { + const { step, funcIndex } = parseTrialOrder(trialOrder); + if (step === null) { + return null; + } + + if (!isReplay) { + return { + pathname: `/analysis/stats/${studyId}/tagging/${encodeURIComponent(answerIdentifier)}`, + search, + }; + } + + const params = new URLSearchParams(search); + params.set('currentTrial', answerIdentifier); + const nextSearch = params.toString(); + + return { + pathname: funcIndex === null + ? `/${studyId}/${encryptIndex(step)}` + : `/${studyId}/${encryptIndex(step)}/${encryptIndex(funcIndex)}`, + search: nextSearch ? `?${nextSearch}` : '', + }; +} From 51743cd45984b2ef1b6493103275ad05dfe4dff3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:26:23 +0000 Subject: [PATCH 14/41] Bump the npm_and_yarn group across 1 directory with 2 updates Bumps the npm_and_yarn group with 2 updates in the / directory: [uuid](https://github.com/uuidjs/uuid) and [postcss](https://github.com/postcss/postcss). Updates `uuid` from 11.1.0 to 14.0.0 - [Release notes](https://github.com/uuidjs/uuid/releases) - [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md) - [Commits](https://github.com/uuidjs/uuid/compare/v11.1.0...v14.0.0) Updates `postcss` from 8.5.6 to 8.5.12 - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.12) --- updated-dependencies: - dependency-name: uuid dependency-version: 14.0.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: postcss dependency-version: 8.5.12 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 19 +++++++------------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 2a2aa5ce96..e33131e821 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "typedoc": "^0.28.13", "typedoc-plugin-markdown": "^4.9.0", "use-deep-compare-effect": "^1.8.1", - "uuid": "^11.0.5", + "uuid": "^14.0.0", "vega": "^6.2.0", "vega-lite": "^5.23.0", "vite": "^7.3.2", diff --git a/yarn.lock b/yarn.lock index b616fa8424..5079338b71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6412,9 +6412,9 @@ possible-typed-array-names@^1.0.0: integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== postcss@^8.5.6: - version "8.5.6" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" - integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + version "8.5.12" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.12.tgz#cd0c0f667f7cb0521e2313234ea6e707a9ec1ddb" + integrity sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA== dependencies: nanoid "^3.3.11" picocolors "^1.1.1" @@ -7859,15 +7859,10 @@ uuid-parse@^1.1.0: resolved "https://registry.yarnpkg.com/uuid-parse/-/uuid-parse-1.1.0.tgz#7061c5a1384ae0e1f943c538094597e1b5f3a65b" integrity sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A== -uuid@*: - version "13.0.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-13.0.0.tgz#263dc341b19b4d755eb8fe36b78d95a6b65707e8" - integrity sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w== - -uuid@^11.0.5: - version "11.1.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" - integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== +uuid@*, uuid@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-14.0.0.tgz#0af883220163d264ffe0c084f6b8a89b9666966d" + integrity sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg== uuid@^8.3.2: version "8.3.2" From 4cc9d142459cb5b4023f3c27fc7508d3bb831285 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Fri, 1 May 2026 10:26:24 -0600 Subject: [PATCH 15/41] Speed up first load by clearing the hot path from un-necessary awaits Co-authored-by: Copilot --- src/GlobalConfigParser.tsx | 44 ++++- src/components/Shell.tsx | 191 +++++++++++++------ src/storage/engines/FirebaseStorageEngine.ts | 6 +- src/storage/engines/types.ts | 99 +++++++--- src/storage/tests/highLevel.spec.ts | 55 ++++++ src/store/store.tsx | 3 + 6 files changed, 303 insertions(+), 95 deletions(-) diff --git a/src/GlobalConfigParser.tsx b/src/GlobalConfigParser.tsx index 7010bb1547..c8a280c611 100644 --- a/src/GlobalConfigParser.tsx +++ b/src/GlobalConfigParser.tsx @@ -28,37 +28,69 @@ async function fetchGlobalConfigArray() { return parseGlobalConfig(configs); } +function isHomeRoute() { + const pathname = window.location.pathname.replace(/\/+$/, '') || '/'; + const basePath = PREFIX.replace(/\/+$/, '') || '/'; + + return pathname === basePath || pathname === `${basePath}/`; +} + export function GlobalConfigParser() { const [globalConfig, setGlobalConfig] = useState>(null); const [studyConfigs, setStudyConfigs] = useState | null>>({}); useEffect(() => { - async function fetchData() { - if (globalConfig) { - setStudyConfigs(await fetchStudyConfigs(globalConfig)); + if (!globalConfig) { + return undefined; + } + + if (!isHomeRoute()) { + setStudyConfigs({}); + return undefined; + } + + let cancelled = false; + + async function fetchData(currentGlobalConfig: GlobalConfig) { + const configs = await fetchStudyConfigs(currentGlobalConfig); + if (!cancelled) { + setStudyConfigs(configs); } } - fetchData(); + + fetchData(globalConfig); + + return () => { + cancelled = true; + }; }, [globalConfig]); useEffect(() => { - if (globalConfig) return; + if (globalConfig) { + return undefined; + } fetchGlobalConfigArray().then((gc) => { setGlobalConfig(gc); }); + + return undefined; }, [globalConfig]); // Initialize storage engine const { storageEngine, setStorageEngine } = useStorageEngine(); useEffect(() => { - if (storageEngine !== undefined) return; + if (storageEngine !== undefined) { + return undefined; + } async function fn() { const _storageEngine = await initializeStorageEngine(); setStorageEngine(_storageEngine); } fn(); + + return undefined; }, [setStorageEngine, storageEngine]); const analysisProtectedCallback = async (studyId: string) => { diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 10df2fd031..15a11a0118 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -38,6 +38,40 @@ import { resolveParticipantConditions, } from '../utils/handleConditionLogic'; +function createParticipantMetadata(ip: string = ''): ParticipantMetadata { + return { + language: navigator.language, + userAgent: navigator.userAgent, + resolution: { + width: window.screen.width, + height: window.screen.height, + availHeight: window.screen.availHeight, + availWidth: window.screen.availWidth, + colorDepth: window.screen.colorDepth, + orientation: window.screen.orientation.type, + pixelDepth: window.screen.pixelDepth, + }, + ip, + }; +} + +function createEmptyParticipantMetadata(): ParticipantMetadata { + return { + language: '', + userAgent: '', + resolution: { + width: 0, + height: 0, + availHeight: 0, + availWidth: 0, + colorDepth: 0, + orientation: '', + pixelDepth: 0, + }, + ip: '', + }; +} + export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { // Pull study config const routeStudyId = useStudyId(); @@ -53,19 +87,27 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { useEffect(() => { if (routeStudyId !== '__revisit-widget') { - getStudyConfig(routeStudyId, globalConfig).then((config) => { + const loadStudyConfig = async () => { + const config = await getStudyConfig(routeStudyId, globalConfig); setActiveConfig(config); - }); - return () => { }; + }; + + loadStudyConfig(); + return undefined; } + if (globalConfig && routeStudyId) { const messageListener = (event: MessageEvent) => { if (event.data.type === 'revisitWidget/CONFIG') { - parseStudyConfig(event.data.payload).then(async (config) => { + const loadWidgetConfig = async () => { + const config = await parseStudyConfig(event.data.payload); setActiveConfig(config); + const sequenceArray = await generateSequenceArray(config); window.parent.postMessage({ type: 'revisitWidget/SEQUENCE_ARRAY', payload: sequenceArray }, '*'); - }); + }; + + loadWidgetConfig(); } }; @@ -77,7 +119,8 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { window.removeEventListener('message', messageListener); }; } - return () => { }; + + return undefined; }, [globalConfig, routeStudyId]); const [routes, setRoutes] = useState([]); @@ -89,6 +132,23 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { const studyCondition = useMemo(() => parseConditionParam(searchParams.get('condition')), [searchParams]); useEffect(() => { + let isCancelled = false; + + async function fetchParticipantIp() { + const ipTimeoutController = new AbortController(); + const ipTimeoutId = window.setTimeout(() => ipTimeoutController.abort(), 1200); + + try { + const ipRes = await fetch('https://api.ipify.org?format=json', { + signal: ipTimeoutController.signal, + }).catch(() => ''); + + return ipRes instanceof Response ? await ipRes.json() as { ip: string } : { ip: '' }; + } finally { + window.clearTimeout(ipTimeoutId); + } + } + async function initializeUserStoreRouting() { // Check that we have a storage engine and active config (studyId is set for config, but typescript complains) if (!storageEngine || !activeConfig || !canonicalStudyId) return; @@ -96,16 +156,19 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { try { // Make sure that we have a study database and that the study database has a sequence array await storageEngine.initializeStudyDb(canonicalStudyId); + + const modesPromise = storageEngine.getModes(canonicalStudyId); + const activeHashPromise = hash(JSON.stringify(activeConfig)); + await storageEngine.saveConfig(activeConfig); const sequenceArray = await storageEngine.getSequenceArray(); + if (!sequenceArray) { - await storageEngine.setSequenceArray( - await generateSequenceArray(activeConfig), - ); - } + const generatedSequenceArray = await generateSequenceArray(activeConfig); - const modes = await storageEngine.getModes(canonicalStudyId); + await storageEngine.setSequenceArray(generatedSequenceArray); + } // Get or generate participant session const urlParticipantId = activeConfig.uiConfig.urlParticipantIdParam @@ -114,33 +177,17 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { : undefined; const searchParamsObject = Object.fromEntries(searchParams.entries()); - const ipTimeoutController = new AbortController(); - const ipTimeoutId = window.setTimeout(() => ipTimeoutController.abort(), 1200); - const ipRes = await fetch('https://api.ipify.org?format=json', { - signal: ipTimeoutController.signal, - }).catch(() => ''); - window.clearTimeout(ipTimeoutId); - const ip: { ip: string } = ipRes instanceof Response ? await ipRes.json() : { ip: '' }; - - const metadata: ParticipantMetadata = { - language: navigator.language, - userAgent: navigator.userAgent, - resolution: { - width: window.screen.width, - height: window.screen.height, - availHeight: window.screen.availHeight, - availWidth: window.screen.availWidth, - colorDepth: window.screen.colorDepth, - orientation: window.screen.orientation.type, - pixelDepth: window.screen.pixelDepth, - }, - ip: ip.ip, - }; + const [modes, activeHash] = await Promise.all([ + modesPromise, + activeHashPromise, + ]); + + const initialMetadata = createParticipantMetadata(); let participantSession = await storageEngine.initializeParticipantSession( searchParamsObject, activeConfig, - metadata, + initialMetadata, participantId || urlParticipantId, ); @@ -157,7 +204,6 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { conditions: studyCondition, }; } - const activeHash = await hash(JSON.stringify(activeConfig)); let participantConfig = activeConfig; @@ -165,34 +211,68 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { participantConfig = (await storageEngine.getAllConfigsFromHash([participantSession.participantConfigHash], canonicalStudyId))[participantSession.participantConfigHash] as ParsedConfig; } - const effectiveStudyCondition = resolveParticipantConditions({ + const resolvedCondition = resolveParticipantConditions({ urlCondition: studyCondition, participantConditions: participantSession.conditions, participantSearchParamCondition: participantSession.searchParams?.condition, allowUrlOverride: modes.developmentModeEnabled, }); - const filteredParticipantSequence = filterSequenceByCondition(participantSession.sequence, effectiveStudyCondition); - const participantCompleted = await storageEngine.getParticipantCompletionStatus(participantSession.participantId); + const filteredParticipantSequence = await filterSequenceByCondition(participantSession.sequence, resolvedCondition); + // Initialize the redux stores const newStore = await studyStoreCreator( canonicalStudyId, participantConfig, filteredParticipantSequence, - metadata, + participantSession.metadata, participantSession.answers, modes, participantSession.participantId, - participantCompleted, + false, false, participantSession.participantConfigHash !== activeHash, ); + + if (isCancelled) { + return; + } + setStore(newStore); + + fetchParticipantIp().then(async (ip) => { + if (isCancelled || !ip.ip || participantSession.metadata.ip === ip.ip) { + return; + } + + const metadataWithIp = createParticipantMetadata(ip.ip); + participantSession = { + ...participantSession, + metadata: metadataWithIp, + }; + + await storageEngine.updateParticipantMetadata(metadataWithIp); + + if (!isCancelled) { + newStore.store.dispatch(newStore.actions.setMetadata(metadataWithIp)); + } + }).catch((error) => { + console.error('Error fetching participant IP:', error); + }); + + storageEngine.getParticipantCompletionStatus(participantSession.participantId).then((participantCompleted) => { + if (!isCancelled) { + newStore.store.dispatch(newStore.actions.setParticipantCompleted(participantCompleted)); + } + }).catch((error) => { + console.error('Error fetching participant completion status:', error); + }); } catch (error) { console.error('Error initializing user store routing:', error); // Fallback: initialize the store with empty data - const generatedSequences = generateSequenceArray(activeConfig); + const generatedSequences = await generateSequenceArray(activeConfig); + const matchingSequence = generatedSequences[0]; - const fallbackSequence = filterSequenceByCondition( + const fallbackSequence = await filterSequenceByCondition( matchingSequence, studyCondition, ); @@ -201,29 +281,25 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { canonicalStudyId, activeConfig, fallbackSequence, - { - language: '', - userAgent: '', - resolution: { - width: 0, - height: 0, - availHeight: 0, - availWidth: 0, - colorDepth: 0, - orientation: '', - pixelDepth: 0, - }, - ip: '', - }, + createEmptyParticipantMetadata(), {}, { developmentModeEnabled: true, dataSharingEnabled: true, dataCollectionEnabled: false }, '', false, true, ); + + if (isCancelled) { + return; + } + setStore(emptyStore); } + if (isCancelled) { + return; + } + // Initialize the routing setRoutes([ { @@ -255,6 +331,9 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { ]); } initializeUserStoreRouting(); + return () => { + isCancelled = true; + }; }, [storageEngine, activeConfig, canonicalStudyId, searchParams, participantId, studyCondition]); const routing = useRoutes(routes); diff --git a/src/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts index ea54372160..389edf1f50 100644 --- a/src/storage/engines/FirebaseStorageEngine.ts +++ b/src/storage/engines/FirebaseStorageEngine.ts @@ -3,6 +3,7 @@ import localforage from 'localforage'; import { initializeApp } from 'firebase/app'; import { deleteObject, + getBlob, getDownloadURL, getStorage, ref, @@ -94,9 +95,8 @@ export class FirebaseStorageEngine extends CloudStorageEngine { let storageObj: StorageObject = {} as StorageObject; try { - const url = await getDownloadURL(storageRef); - const response = await fetch(url); - const fullProvStr = await response.text(); + const blob = await getBlob(storageRef); + const fullProvStr = await blob.text(); storageObj = JSON.parse(fullProvStr); } catch { console.warn( diff --git a/src/storage/engines/types.ts b/src/storage/engines/types.ts index 866512429e..8e5143bf9c 100644 --- a/src/storage/engines/types.ts +++ b/src/storage/engines/types.ts @@ -66,6 +66,11 @@ interface StageData { allStages: StageInfo[]; } +type ModesAndStageData = { + modes: Record; + stageData: StageData; +}; + export interface ConditionData { allConditions: string[]; conditionCounts: Record; @@ -188,6 +193,10 @@ export abstract class StorageEngine { return this.cloudEngine; } + protected shouldDeferInitialParticipantDataPersistence() { + return false; + } + /* * PRIMITIVE METHODS * These methods are provided by the storage engine implementation and are used by the higher-level methods. @@ -371,6 +380,33 @@ export abstract class StorageEngine { } } + private async getModesAndStageData(studyId: string): Promise { + const modesDoc = await this.getModes(studyId); + const { stage, ...modeValues } = modesDoc; + + if (stage) { + return { + modes: modeValues as Record, + stageData: stage, + }; + } + + const defaultStageData: StageData = { + currentStage: { stageName: 'DEFAULT', color: defaultStageColor }, + allStages: [{ stageName: 'DEFAULT', color: defaultStageColor }], + }; + + await this._setModesDocument(studyId, { + ...(modeValues as Record), + stage: defaultStageData, + }); + + return { + modes: modeValues as Record, + stageData: defaultStageData, + }; + } + private clearPendingParticipantDataWriteTimer() { if (this.pendingParticipantDataWriteTimer) { clearTimeout(this.pendingParticipantDataWriteTimer); @@ -566,19 +602,8 @@ export abstract class StorageEngine { } async getStageData(studyId: string): Promise { - const modesDoc = await this.getModes(studyId); - - if (modesDoc && modesDoc.stage) { - return modesDoc.stage as StageData; - } - - // Set default stage data if it doesn't exist - const defaultStageData: StageData = { - currentStage: { stageName: 'DEFAULT', color: defaultStageColor }, - allStages: [{ stageName: 'DEFAULT', color: defaultStageColor }], - }; - await this.setCurrentStage(studyId, 'DEFAULT', defaultStageColor); - return defaultStageData; + const { stageData } = await this.getModesAndStageData(studyId); + return stageData; } async getConditionData(studyId: string): Promise { @@ -665,6 +690,10 @@ export abstract class StorageEngine { // Hash the provided config const configHash = await hash(JSON.stringify(config)); + if (currentConfigHash === configHash) { + return; + } + // Push the config to storage and cache it, since it won't change await this._pushToStorage( `configs/${configHash}`, @@ -741,7 +770,7 @@ export abstract class StorageEngine { // This function is one of the most critical functions in the storage engine. // It uses the notion of sequence intents and assignments to determine the current sequence for the participant. // It handles rejected participants and allows for reusing a rejected participant's sequence. - protected async _getSequence(conditions?: string[]) { + protected async _getSequence(conditions?: string[], bootstrapData?: ModesAndStageData) { if (!this.currentParticipantId) { throw new Error('Participant not initialized'); } @@ -750,8 +779,7 @@ export abstract class StorageEngine { } let sequenceAssignments = await this.getAllSequenceAssignments(this.studyId); - const modes = await this.getModes(this.studyId); - const stageData = await this.getStageData(this.studyId); + const { modes, stageData } = bootstrapData ?? await this.getModesAndStageData(this.studyId); const currentStage = stageData.currentStage.stageName; // Find all rejected documents @@ -849,30 +877,25 @@ export abstract class StorageEngine { throw new Error('Participant not initialized'); } + const cachedParticipant = await this.getCachedParticipantDataSnapshot(this.currentParticipantId); + if (cachedParticipant) { + this.participantData = cachedParticipant; + return cachedParticipant; + } + // Check if the participant has already been initialized const participant = await this._getFromStorage( `participants/${this.currentParticipantId}`, 'participantData', ); - const cachedParticipant = await this.getCachedParticipantDataSnapshot(this.currentParticipantId); if (this.studyId === undefined) { throw new Error('Study ID is not set'); } - // Get modes - const modes = await this.getModes(this.studyId); - const stageData = await this.getStageData(this.studyId); + const { modes, stageData } = await this.getModesAndStageData(this.studyId); const currentStage = stageData.currentStage.stageName; - if ( - cachedParticipant - && (!isParticipantData(participant) || shouldPreferCachedParticipantData(cachedParticipant, participant)) - ) { - this.participantData = cachedParticipant; - return cachedParticipant; - } - if (isParticipantData(participant)) { // Participant already initialized this.participantData = participant; @@ -883,7 +906,7 @@ export abstract class StorageEngine { const participantConfigHash = await hash(JSON.stringify(config)); const parsedConditions = parseConditionParam(searchParams.condition); const conditions = parsedConditions.length > 0 ? parsedConditions : undefined; - const { currentRow, creationIndex } = await this._getSequence(conditions); + const { currentRow, creationIndex } = await this._getSequence(conditions, { modes, stageData }); this.participantData = { participantId: this.currentParticipantId, participantConfigHash, @@ -900,7 +923,7 @@ export abstract class StorageEngine { }; if (modes.dataCollectionEnabled) { - await this.persistCurrentParticipantData({ immediate: true }); + await this.persistCurrentParticipantData({ immediate: !this.shouldDeferInitialParticipantDataPersistence() }); } else { await this.cacheParticipantDataSnapshot(this.participantData, this.currentParticipantId); } @@ -1037,6 +1060,18 @@ export abstract class StorageEngine { await this.persistCurrentParticipantData({ immediate: true }); } + async updateParticipantMetadata(metadata: ParticipantMetadata) { + await this.verifyStudyDatabase(); + + if (!this.participantData) { + throw new Error('Participant data not initialized'); + } + + this.participantData.metadata = metadata; + + await this.persistCurrentParticipantData({ immediate: false }); + } + async updateStudyCondition(condition: string | string[]) { await this.verifyStudyDatabase(); @@ -1696,6 +1731,10 @@ export abstract class CloudStorageEngine extends StorageEngine { protected userManagementData: UserManagementData = {}; + protected shouldDeferInitialParticipantDataPersistence() { + return true; + } + /* * PRIMITIVE METHODS * These methods are provided by the storage engine implementation and are used by the higher-level methods. diff --git a/src/storage/tests/highLevel.spec.ts b/src/storage/tests/highLevel.spec.ts index d77988d0ab..5d5da8927b 100644 --- a/src/storage/tests/highLevel.spec.ts +++ b/src/storage/tests/highLevel.spec.ts @@ -278,6 +278,35 @@ describe.each([ expect(storedHashes[configComplexHash]).toEqual(configSimple2); }); + test('saveConfig skips storage writes when the config hash is unchanged', async () => { + await storageEngine.saveConfig(configSimple); + + const pushSpy = vi.spyOn( + storageEngine as unknown as { + _pushToStorage: StorageEngine['_pushToStorage']; + }, + '_pushToStorage', + ); + const deleteSpy = vi.spyOn( + storageEngine as unknown as { + _deleteFromStorage: StorageEngine['_deleteFromStorage']; + }, + '_deleteFromStorage', + ); + const setHashSpy = vi.spyOn( + storageEngine as unknown as { + _setCurrentConfigHash: StorageEngine['_setCurrentConfigHash']; + }, + '_setCurrentConfigHash', + ); + + await storageEngine.saveConfig(configSimple); + + expect(pushSpy).not.toHaveBeenCalled(); + expect(deleteSpy).not.toHaveBeenCalled(); + expect(setHashSpy).not.toHaveBeenCalled(); + }); + test('getCurrentParticipantId returns the current participant ID', async () => { const participantSession = await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata); const { participantId } = participantSession; @@ -318,6 +347,14 @@ describe.each([ expect(participantData!.participantTags).toEqual([]); }); + test('initializeParticipantSession reads modes only once for a new participant', async () => { + const getModesSpy = vi.spyOn(storageEngine, 'getModes'); + + await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata); + + expect(getModesSpy).toHaveBeenCalledTimes(1); + }); + test('initializeParticipantSession sets conditions from searchParams condition', async () => { const participantSession = await storageEngine.initializeParticipantSession({ condition: 'color' }, configSimple, participantMetadata); @@ -445,6 +482,24 @@ describe.each([ expect(Object.hasOwn(sequenceAssignment!, 'conditions')).toBe(false); }); + test('updateParticipantMetadata updates participant metadata', async () => { + const participantSession = await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata); + const updatedMetadata = { + ...participantMetadata, + ip: '8.8.8.8', + }; + + await storageEngine.updateParticipantMetadata(updatedMetadata); + + expect(storageEngine.getCurrentParticipantDataSnapshot()!.metadata).toEqual(updatedMetadata); + + await storageEngine.flushPendingParticipantData(); + + const participantData = await storageEngine.getParticipantData(participantSession.participantId); + expect(participantData).toBeDefined(); + expect(participantData!.metadata).toEqual(updatedMetadata); + }); + test('getConditionData includes default when participants have no explicit condition', async () => { await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata); await storageEngine.clearCurrentParticipantId(); diff --git a/src/store/store.tsx b/src/store/store.tsx index 1a4072dfd4..97992313b9 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -146,6 +146,9 @@ export async function studyStoreCreator( setConfig(state, { payload }: PayloadAction) { state.config = payload; }, + setMetadata(state, { payload }: PayloadAction) { + state.metadata = payload; + }, // eslint-disable-next-line @typescript-eslint/no-explicit-any pushToFuncSequence(state, { payload }: PayloadAction<{ component: string, funcName: string, index: number, funcIndex: number, parameters: Record | undefined, correctAnswer: Answer[] | undefined }>) { if (!state.funcSequence[payload.funcName]) { From 9ad7f67fb5e58d4ceb110542bb7ca156c61f44b0 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Thu, 7 May 2026 00:16:51 -0700 Subject: [PATCH 16/41] Prevent metadata writes in demo mode --- src/components/Shell.tsx | 40 +++++++++++++++-------------- src/storage/engines/types.ts | 10 ++++++++ src/storage/tests/highLevel.spec.ts | 26 +++++++++++++++++++ 3 files changed, 57 insertions(+), 19 deletions(-) diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 15a11a0118..f82f779796 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -239,25 +239,27 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { setStore(newStore); - fetchParticipantIp().then(async (ip) => { - if (isCancelled || !ip.ip || participantSession.metadata.ip === ip.ip) { - return; - } - - const metadataWithIp = createParticipantMetadata(ip.ip); - participantSession = { - ...participantSession, - metadata: metadataWithIp, - }; - - await storageEngine.updateParticipantMetadata(metadataWithIp); - - if (!isCancelled) { - newStore.store.dispatch(newStore.actions.setMetadata(metadataWithIp)); - } - }).catch((error) => { - console.error('Error fetching participant IP:', error); - }); + if (modes.dataCollectionEnabled) { + fetchParticipantIp().then(async (ip) => { + if (isCancelled || !ip.ip || participantSession.metadata.ip === ip.ip) { + return; + } + + const metadataWithIp = createParticipantMetadata(ip.ip); + participantSession = { + ...participantSession, + metadata: metadataWithIp, + }; + + await storageEngine.updateParticipantMetadata(metadataWithIp); + + if (!isCancelled) { + newStore.store.dispatch(newStore.actions.setMetadata(metadataWithIp)); + } + }).catch((error) => { + console.error('Error fetching participant IP:', error); + }); + } storageEngine.getParticipantCompletionStatus(participantSession.participantId).then((participantCompleted) => { if (!isCancelled) { diff --git a/src/storage/engines/types.ts b/src/storage/engines/types.ts index 8e5143bf9c..2434452e07 100644 --- a/src/storage/engines/types.ts +++ b/src/storage/engines/types.ts @@ -1069,6 +1069,16 @@ export abstract class StorageEngine { this.participantData.metadata = metadata; + if (!this.studyId) { + throw new Error('Study ID is not set'); + } + + const modes = await this.getModes(this.studyId); + if (!modes.dataCollectionEnabled) { + await this.cacheParticipantDataSnapshot(this.participantData, this.currentParticipantId); + return; + } + await this.persistCurrentParticipantData({ immediate: false }); } diff --git a/src/storage/tests/highLevel.spec.ts b/src/storage/tests/highLevel.spec.ts index 5d5da8927b..3d4a85e834 100644 --- a/src/storage/tests/highLevel.spec.ts +++ b/src/storage/tests/highLevel.spec.ts @@ -500,6 +500,32 @@ describe.each([ expect(participantData!.metadata).toEqual(updatedMetadata); }); + test('updateParticipantMetadata stays local when data collection is disabled', async () => { + await storageEngine.setMode(studyId, 'dataCollectionEnabled', false); + + const participantSession = await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata); + const updatedMetadata = { + ...participantMetadata, + ip: '8.8.8.8', + }; + + const pushSpy = vi.spyOn( + storageEngine as unknown as { + _pushToStorage: StorageEngine['_pushToStorage']; + }, + '_pushToStorage', + ); + + await storageEngine.updateParticipantMetadata(updatedMetadata); + + expect(pushSpy).not.toHaveBeenCalled(); + expect(storageEngine.getCurrentParticipantDataSnapshot()!.metadata).toEqual(updatedMetadata); + + const participantData = await storageEngine.getParticipantData(participantSession.participantId); + expect(participantData).toBeDefined(); + expect(participantData!.metadata).toEqual(updatedMetadata); + }); + test('getConditionData includes default when participants have no explicit condition', async () => { await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata); await storageEngine.clearCurrentParticipantId(); From cb034d686ea0a6d1b47a69536673feca9844d4f1 Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Mon, 11 May 2026 22:54:38 -0600 Subject: [PATCH 17/41] Fix showing app header warning --- src/components/interface/AppHeader.spec.tsx | 19 ++++++- src/components/interface/AppHeader.tsx | 39 +++++++------ .../interface/RecordingAudioWaveform.tsx | 10 +--- .../libraries/mic-check/assets/AudioTest.tsx | 57 ++++++++++++++----- .../assets/ScreenRecording.tsx | 8 +-- src/store/hooks/useRecording.ts | 26 ++++++--- 6 files changed, 99 insertions(+), 60 deletions(-) diff --git a/src/components/interface/AppHeader.spec.tsx b/src/components/interface/AppHeader.spec.tsx index c459f3f9c0..248b943a0e 100644 --- a/src/components/interface/AppHeader.spec.tsx +++ b/src/components/interface/AppHeader.spec.tsx @@ -13,6 +13,7 @@ let mockedRecordingContext = { clickToRecord: false, isSpeakingWhileMuted: false, showMutedWarning: false, + screenRecordingError: null as string | null, audioRecordingError: null as string | null, currentComponentHasAudioRecording: false, audioStatus: 'idle' as 'idle' | 'pending' | 'recording' | 'denied', @@ -150,6 +151,7 @@ describe('AppHeader', () => { clickToRecord: false, isSpeakingWhileMuted: false, showMutedWarning: false, + screenRecordingError: null, audioRecordingError: null, currentComponentHasAudioRecording: false, audioStatus: 'idle', @@ -159,21 +161,23 @@ describe('AppHeader', () => { test('shows disabled mic state when audio permission is denied before recording starts', () => { mockedRecordingContext = { ...mockedRecordingContext, + clickToRecord: true, currentComponentHasAudioRecording: true, - audioRecordingError: 'Microphone permission denied or not supported.', + audioRecordingError: 'Microphone permission denied', audioStatus: 'denied', }; const html = renderToStaticMarkup(); expect(html).toContain('Microphone error'); - expect(html).toContain('Microphone permission denied or not supported.'); + expect(html).toContain('Microphone permission denied'); expect(html).toContain('mic-off'); }); test('shows pending mic state before audio permission is granted', () => { mockedRecordingContext = { ...mockedRecordingContext, + clickToRecord: true, currentComponentHasAudioRecording: true, audioStatus: 'pending', }; @@ -184,4 +188,15 @@ describe('AppHeader', () => { expect(html).toContain('Microphone not enabled yet'); expect(html).toContain('mic-off'); }); + + test('shows screen recording error in the header', () => { + mockedRecordingContext = { + ...mockedRecordingContext, + screenRecordingError: 'Recording permission denied', + }; + + const html = renderToStaticMarkup(); + + expect(html).toContain('Recording permission denied'); + }); }); diff --git a/src/components/interface/AppHeader.tsx b/src/components/interface/AppHeader.tsx index 10aaca3f5c..5707d3f896 100644 --- a/src/components/interface/AppHeader.tsx +++ b/src/components/interface/AppHeader.tsx @@ -97,6 +97,7 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d clickToRecord, isSpeakingWhileMuted, showMutedWarning, + screenRecordingError, audioRecordingError, currentComponentHasAudioRecording, audioStatus, @@ -110,7 +111,16 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d const hasUnmetDeviceRequirement = developmentModeEnabled && (!isBrowserAllowed || !isDeviceAllowed || !isInputAllowed || !isDisplayAllowed); const showAudioStatus = currentComponentHasAudioRecording || isAudioRecording || audioStatus !== 'idle'; - const showRecordingStatus = showAudioStatus || isScreenRecording; + const showRecordingStatus = showAudioStatus || isScreenRecording || !!screenRecordingError; + const isAudioActivelyRecording = audioStatus === 'recording' && !isMuted; + let recordingLabel = ''; + if (isScreenRecording && isAudioActivelyRecording) { + recordingLabel = 'Recording screen and audio'; + } else if (isScreenRecording) { + recordingLabel = 'Recording screen'; + } else if (isAudioActivelyRecording) { + recordingLabel = 'Recording audio'; + } useEffect(() => { if (!(isMuted && isSpeakingWhileMuted)) return undefined; @@ -209,37 +219,26 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d {showRecordingStatus && ( - - {((audioStatus === 'recording' && !isMuted) || (isScreenRecording)) && 'Recording'} - {isScreenRecording && ' screen'} - {isScreenRecording && audioStatus === 'recording' && !isMuted && ' and'} - {audioStatus === 'recording' && !isMuted && ' audio'} - + {recordingLabel && {recordingLabel}} + {screenRecordingError && {screenRecordingError}} + {audioStatus === 'denied' && audioRecordingError && {audioRecordingError}} {audioStatus === 'recording' && !isMuted && } - {showAudioStatus && (audioStatus === 'denied' ? ( - - - - - + {clickToRecord && showAudioStatus && (audioStatus === 'denied' ? ( + + + ) : audioStatus === 'pending' ? ( - ) : clickToRecord ? ( + ) : ( setIsMuted(false)} onMouseUp={() => setIsMuted(true)} onTouchStart={() => setIsMuted(false)} onTouchEnd={() => setIsMuted(true)}> {isMuted ? : } - ) : ( - - setIsMuted(!isMuted)}> - {isMuted ? : } - - ))} )} diff --git a/src/components/interface/RecordingAudioWaveform.tsx b/src/components/interface/RecordingAudioWaveform.tsx index dc383130d3..4e2f2436e1 100644 --- a/src/components/interface/RecordingAudioWaveform.tsx +++ b/src/components/interface/RecordingAudioWaveform.tsx @@ -1,5 +1,5 @@ import { Flex } from '@mantine/core'; -import { useRef, useEffect, useState } from 'react'; +import { useRef, useEffect } from 'react'; export function RecordingAudioWaveform({ width = 60, @@ -23,8 +23,6 @@ export function RecordingAudioWaveform({ const animationFrameIdRef = useRef(0); const mediaStreamRef = useRef(null); - const [error, setError] = useState(null); - useEffect(() => { let lastTime = 0; const frameInterval = 1000 / fps; @@ -110,11 +108,6 @@ export function RecordingAudioWaveform({ animationFrameIdRef.current = requestAnimationFrame(draw); } catch (err) { console.error('Error accessing microphone:', err); - if (err instanceof Error) { - setError(`Error accessing microphone: ${err.message}. Please grant permission.`); - } else { - setError('An unknown error occurred while accessing the microphone.'); - } } }; @@ -139,7 +132,6 @@ export function RecordingAudioWaveform({ return ( - {error &&

{error}

}
); diff --git a/src/public/libraries/mic-check/assets/AudioTest.tsx b/src/public/libraries/mic-check/assets/AudioTest.tsx index 36f0ed6b6d..c08b4d37fc 100644 --- a/src/public/libraries/mic-check/assets/AudioTest.tsx +++ b/src/public/libraries/mic-check/assets/AudioTest.tsx @@ -1,5 +1,5 @@ import { - Center, Stack, Text, + Box, Title, } from '@mantine/core'; import { useEffect, @@ -57,20 +57,47 @@ export function AudioTest({ setAnswer }: StimulusParams) { }, [setAnswer]); return ( -
- - - Please allow us to access your microphone. There may be a popup in your browser window asking for access, click accept. - - - Once we can confirm that your microphone is on and we hear you say something, the continue button will become available. - - - If you are not comfortable or able to speak English during this study, please return the study. - -
-
-
+ + + Audio Recording Permission + + +

+ This study requires recording of your + {' '} + audio + . If you're not comfortable, you may exit and return the study. +

+

Follow the steps below to grant microphone access and confirm that your audio is working.

+ +
    +
  1. + Please allow us to access your microphone. + {' '} + There may be a popup in your browser window asking for access. Click allow to continue. +
  2. +
  3. + Speak + {' '} + into your microphone to check if audio is working. + + + +
  4. +
+ + Note: +
    +
  • + Once we can confirm that your microphone is on and we hear you say something, the + {' '} + Continue + {' '} + button will become available. +
  • +
  • If you are not comfortable or able to speak English during this study, please return the study.
  • +
+
); } diff --git a/src/public/libraries/screen-recording/assets/ScreenRecording.tsx b/src/public/libraries/screen-recording/assets/ScreenRecording.tsx index 5a49251201..e53f73928a 100644 --- a/src/public/libraries/screen-recording/assets/ScreenRecording.tsx +++ b/src/public/libraries/screen-recording/assets/ScreenRecording.tsx @@ -14,8 +14,6 @@ function ScreenRecordingPermission({ setAnswer }: StimulusParams) { stopScreenCapture: stopCapture, isScreenCapturing: screenCapturing, isAudioCapturing: audioCapturing, - screenRecordingError: error, - audioRecordingError, audioMediaStream, } = useRecordingContext(); @@ -115,8 +113,7 @@ function ScreenRecordingPermission({ setAnswer }: StimulusParams) { muted style={{ width: '400px', border: '1px solid #ccc', marginTop: '1rem' }} /> - {(error || audioRecordingError) &&

{error || audioRecordingError}

} -

Note: Please make sure you are recording the correct tab or window. Otherwise, stop and re-share the correct one.

+

Please make sure you are recording the correct tab or window. Otherwise, stop and re-share the correct one.

  • @@ -160,8 +157,7 @@ function ScreenRecordingPermission({ setAnswer }: StimulusParams) { muted style={{ width: '400px', border: '1px solid #ccc', marginTop: '1rem' }} /> - {error &&

    {error}

    } -

    Note: Please make sure you are recording the correct tab or window. Otherwise, stop and re-share the correct one.

    +

    Please make sure you are recording the correct tab or window. Otherwise, stop and re-share the correct one.

    Note:
      diff --git a/src/store/hooks/useRecording.ts b/src/store/hooks/useRecording.ts index 4dff93fcfc..7919575126 100644 --- a/src/store/hooks/useRecording.ts +++ b/src/store/hooks/useRecording.ts @@ -292,7 +292,7 @@ export function useRecording() { setIsAudioRecording(true); }).catch((err) => { console.error('Error accessing microphone:', err); - setAudioRecordingError('Microphone permission denied or not supported.'); + setAudioRecordingError('Microphone permission denied'); setIsAudioRecording(false); }); }, [storageEngine, isMuted]); @@ -352,6 +352,9 @@ export function useRecording() { document.title = `RECORD THIS TAB: ${pageTitle}`; try { + setRecordingError(null); + setAudioRecordingError(null); + const screenStream = studyHasScreenRecording ? await navigator.mediaDevices.getDisplayMedia({ video: { displaySurface: 'browser', ...(recordScreenFPS ? { frameRate: { ideal: recordScreenFPS } } : {}) }, audio: false, @@ -363,10 +366,18 @@ export function useRecording() { screenMediaStream.current = screenStream; - const micStream = studyHasAudioRecording ? await navigator.mediaDevices.getUserMedia({ - audio: true, - video: false, - }) : null; + let micStream: MediaStream | null = null; + if (studyHasAudioRecording) { + try { + micStream = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: false, + }); + } catch (err) { + console.error('Error accessing microphone:', err); + setAudioRecordingError('Microphone permission denied'); + } + } audioMediaStream.current = micStream; @@ -398,11 +409,10 @@ export function useRecording() { setIsAudioCapturing(micStream !== null); setIsMediaCapturing(screenStream !== null || micStream !== null); setScreenCaptureStarted(true); - setScreenWithAudioRecording(!!recordAudio); - setRecordingError(null); + setScreenWithAudioRecording(micStream !== null && !!recordAudio); } catch (err) { console.error('Error accessing screen:', err); - setRecordingError('Recording permission denied or not supported.'); + setRecordingError('Recording permission denied'); } finally { document.title = pageTitle; } From dde8728586541168bb543541301b78257d9094ba Mon Sep 17 00:00:00 2001 From: Jay Kim <76601570+yeonkim1213@users.noreply.github.com> Date: Mon, 11 May 2026 23:52:31 -0600 Subject: [PATCH 18/41] Address PR comments --- package.json | 1 + src/store/hooks/usePreviousStep.spec.tsx | 5 + src/store/hooks/usePreviousStep.ts | 2 +- yarn.lock | 247 ++++++++++++++++++++++- 4 files changed, 253 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 2a2aa5ce96..5265590184 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.4.0", "husky": "^9.1.7", + "jsdom": "^29.1.1", "lint-staged": "^16.1.6", "typescript": "^5.9.2", "vitest": "^3.2.4", diff --git a/src/store/hooks/usePreviousStep.spec.tsx b/src/store/hooks/usePreviousStep.spec.tsx index b83b3348d6..6ffa4e5f49 100644 --- a/src/store/hooks/usePreviousStep.spec.tsx +++ b/src/store/hooks/usePreviousStep.spec.tsx @@ -1,5 +1,6 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { + afterEach, beforeEach, describe, expect, @@ -83,6 +84,10 @@ describe('usePreviousStep', () => { }); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + test('keeps the previous button enabled during analysis replay', () => { mockIsAnalysis = true; diff --git a/src/store/hooks/usePreviousStep.ts b/src/store/hooks/usePreviousStep.ts index c4228bbbdd..ec57e15d36 100644 --- a/src/store/hooks/usePreviousStep.ts +++ b/src/store/hooks/usePreviousStep.ts @@ -18,7 +18,7 @@ export function usePreviousStep() { const { deleteDynamicBlockAnswers } = useStoreActions(); const answers = useStoreSelector((state) => state.answers); - // Status of the previous button. If false, the previous button should be disabled + // Status of the previous button. If true, the previous button should be disabled const isPreviousDisabled = typeof currentStep !== 'number' || currentStep <= 0; const goToPreviousStep = useCallback(() => { diff --git a/yarn.lock b/yarn.lock index b616fa8424..51fc006ae5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,38 @@ # yarn lockfile v1 +"@asamuzakjp/css-color@^5.1.11": + version "5.1.11" + resolved "https://registry.yarnpkg.com/@asamuzakjp/css-color/-/css-color-5.1.11.tgz#28a0aac8220a4cc19045ac3bd9a813d4060bd375" + integrity sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg== + dependencies: + "@asamuzakjp/generational-cache" "^1.0.1" + "@csstools/css-calc" "^3.2.0" + "@csstools/css-color-parser" "^4.1.0" + "@csstools/css-parser-algorithms" "^4.0.0" + "@csstools/css-tokenizer" "^4.0.0" + +"@asamuzakjp/dom-selector@^7.1.1": + version "7.1.1" + resolved "https://registry.yarnpkg.com/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz#01880086bb2490098f167beb58555da1a6c9adbd" + integrity sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ== + dependencies: + "@asamuzakjp/generational-cache" "^1.0.1" + "@asamuzakjp/nwsapi" "^2.3.9" + bidi-js "^1.0.3" + css-tree "^3.2.1" + is-potential-custom-element-name "^1.0.1" + +"@asamuzakjp/generational-cache@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz#3d0bf6be4fc059851390a7070720c6007af793ec" + integrity sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg== + +"@asamuzakjp/nwsapi@^2.3.9": + version "2.3.9" + resolved "https://registry.yarnpkg.com/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz#ad5549322dfe9d153d4b4dd6f7ff2ae234b06e24" + integrity sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q== + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" @@ -92,6 +124,46 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@bramus/specificity@^2.4.2": + version "2.4.2" + resolved "https://registry.yarnpkg.com/@bramus/specificity/-/specificity-2.4.2.tgz#aa8db8eb173fdee7324f82284833106adeecc648" + integrity sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw== + dependencies: + css-tree "^3.0.0" + +"@csstools/color-helpers@^6.0.2": + version "6.0.2" + resolved "https://registry.yarnpkg.com/@csstools/color-helpers/-/color-helpers-6.0.2.tgz#82c59fd30649cf0b4d3c82160489748666e6550b" + integrity sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q== + +"@csstools/css-calc@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-3.2.0.tgz#15ca1a80a026ced0f6c4e12124c398e3db8e1617" + integrity sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w== + +"@csstools/css-color-parser@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz#1d64ea09c548d3ed331648ea0b831e16b80c891c" + integrity sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ== + dependencies: + "@csstools/color-helpers" "^6.0.2" + "@csstools/css-calc" "^3.2.0" + +"@csstools/css-parser-algorithms@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz#e1c65dc09378b42f26a111fca7f7075fc2c26164" + integrity sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w== + +"@csstools/css-syntax-patches-for-csstree@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz#3204cf40deb97db83e225b0baa9e37d9c3bd344d" + integrity sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg== + +"@csstools/css-tokenizer@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz#798a33950d11226a0ebb6acafa60f5594424967f" + integrity sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA== + "@dnd-kit/accessibility@^3.1.1": version "3.1.1" resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz#3b4202bd6bb370a0730f6734867785919beac6af" @@ -457,6 +529,11 @@ "@eslint/core" "^0.17.0" levn "^0.4.1" +"@exodus/bytes@^1.11.0", "@exodus/bytes@^1.15.0", "@exodus/bytes@^1.6.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@exodus/bytes/-/bytes-1.15.0.tgz#54479e0f406cbad024d6fe1c3190ecca4468df3b" + integrity sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ== + "@firebase/ai@1.4.1": version "1.4.1" resolved "https://registry.yarnpkg.com/@firebase/ai/-/ai-1.4.1.tgz#0088b793590c9eb554fe4587c9ee3e3a33032cb0" @@ -2762,6 +2839,13 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +bidi-js@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/bidi-js/-/bidi-js-1.0.3.tgz#6f8bcf3c877c4d9220ddf49b9bb6930c88f877d2" + integrity sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw== + dependencies: + require-from-string "^2.0.2" + brace-expansion@^1.1.7: version "1.1.12" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" @@ -3052,6 +3136,14 @@ crypto-js@^4.2.0: resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== +css-tree@^3.0.0, css-tree@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-3.2.1.tgz#86cac7011561272b30e6b1e042ba6ce047aa7518" + integrity sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA== + dependencies: + mdn-data "2.27.1" + source-map-js "^1.2.1" + csstype@^3.0.2, csstype@^3.2.2, csstype@^3.2.3: version "3.2.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" @@ -3320,6 +3412,14 @@ damerau-levenshtein@^1.0.8: resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== +data-urls@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-7.0.0.tgz#6dce8b63226a1ecfdd907ce18a8ccfb1eee506d3" + integrity sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA== + dependencies: + whatwg-mimetype "^5.0.0" + whatwg-url "^16.0.0" + data-view-buffer@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2" @@ -3393,6 +3493,11 @@ debug@^4.0.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.1, debug@^4.4.3: dependencies: ms "^2.1.3" +decimal.js@^10.6.0: + version "10.6.0" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" + integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg== + decode-named-character-reference@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz#25c32ae6dd5e21889549d40f676030e9514cc0ed" @@ -3506,6 +3611,11 @@ entities@^4.4.0: resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== +entities@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-8.0.0.tgz#c1df5fe3602429747fa233d0dd26f142f0ce4743" + integrity sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA== + environment@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/environment/-/environment-1.1.0.tgz#8e86c66b180f363c7ab311787e0259665f45a9f1" @@ -4571,6 +4681,13 @@ hoist-non-react-statics@^3.3.1: dependencies: react-is "^16.7.0" +html-encoding-sniffer@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz#f8d9390b3b348b50d4f61c16dd2ef5c05980a882" + integrity sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg== + dependencies: + "@exodus/bytes" "^1.6.0" + html-url-attributes@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz#83b052cd5e437071b756cd74ae70f708870c2d87" @@ -4910,6 +5027,11 @@ is-plain-obj@^4.0.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + is-regex@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" @@ -5070,6 +5192,33 @@ js-yaml@^4.1.1: dependencies: argparse "^2.0.1" +jsdom@^29.1.1: + version "29.1.1" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-29.1.1.tgz#5b9704906f3cd510c34aa941ae2f8f7f8179df01" + integrity sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q== + dependencies: + "@asamuzakjp/css-color" "^5.1.11" + "@asamuzakjp/dom-selector" "^7.1.1" + "@bramus/specificity" "^2.4.2" + "@csstools/css-syntax-patches-for-csstree" "^1.1.3" + "@exodus/bytes" "^1.15.0" + css-tree "^3.2.1" + data-urls "^7.0.0" + decimal.js "^10.6.0" + html-encoding-sniffer "^6.0.0" + is-potential-custom-element-name "^1.0.1" + lru-cache "^11.3.5" + parse5 "^8.0.1" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^6.0.1" + undici "^7.25.0" + w3c-xmlserializer "^5.0.0" + webidl-conversions "^8.0.1" + whatwg-mimetype "^5.0.0" + whatwg-url "^16.0.1" + xml-name-validator "^5.0.0" + jsesc@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" @@ -5312,6 +5461,11 @@ loupe@^3.1.0, loupe@^3.1.4: resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.1.4.tgz#784a0060545cb38778ffb19ccde44d7870d5fdd9" integrity sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg== +lru-cache@^11.3.5: + version "11.3.6" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.3.6.tgz#f0306ad6e9f0a5dc25b16aeba4e8f57b7ec2df55" + integrity sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A== + lunr@^2.3.9: version "2.3.9" resolved "https://registry.yarnpkg.com/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1" @@ -5583,6 +5737,11 @@ mdast-util-to-string@^4.0.0: dependencies: "@types/mdast" "^4.0.0" +mdn-data@2.27.1: + version "2.27.1" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.27.1.tgz#e37b9c50880b75366c4d40ac63d9bbcacdb61f0e" + integrity sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ== + mdurl@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0" @@ -6324,6 +6483,13 @@ parse5@^7.0.0: dependencies: entities "^4.4.0" +parse5@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-8.0.1.tgz#f43bcd2cd683efe084075333e9ce0da7d06da31e" + integrity sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw== + dependencies: + entities "^8.0.0" + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -6472,7 +6638,7 @@ punycode.js@^2.3.1: resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7" integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA== -punycode@^2.1.0: +punycode@^2.1.0, punycode@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== @@ -6985,6 +7151,13 @@ safe-stable-stringify@^2.5.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== + dependencies: + xmlchars "^2.2.0" + scheduler@^0.23.2: version "0.23.2" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" @@ -7368,6 +7541,11 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +symbol-tree@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + tabbable@^6.0.0: version "6.2.0" resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97" @@ -7414,6 +7592,18 @@ tinyspy@^4.0.3: resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.3.tgz#d1d0f0602f4c15f1aae083a34d6d0df3363b1b52" integrity sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A== +tldts-core@^7.0.30: + version "7.0.30" + resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-7.0.30.tgz#c495dba27778f2220bea94f3f6399005c7aca61c" + integrity sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q== + +tldts@^7.0.5: + version "7.0.30" + resolved "https://registry.yarnpkg.com/tldts/-/tldts-7.0.30.tgz#497cea8d610953222f9dcb3ceb07c7207efcd816" + integrity sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw== + dependencies: + tldts-core "^7.0.30" + to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" @@ -7428,6 +7618,20 @@ topojson-client@^3.1.0: dependencies: commander "2" +tough-cookie@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-6.0.1.tgz#a495f833836609ed983c19bc65639cfbceb54c76" + integrity sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw== + dependencies: + tldts "^7.0.5" + +tr46@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-6.0.0.tgz#f5a1ae546a0adb32a277a2278d0d17fa2f9093e6" + integrity sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw== + dependencies: + punycode "^2.3.1" + tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" @@ -7653,6 +7857,11 @@ undici-types@~7.8.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.8.0.tgz#de00b85b710c54122e44fbfd911f8d70174cd294" integrity sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw== +undici@^7.25.0: + version "7.25.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-7.25.0.tgz#7d72fc429a0421769ca2966fd07cac875c85b781" + integrity sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ== + unified@^10.0.0: version "10.1.2" resolved "https://registry.yarnpkg.com/unified/-/unified-10.1.2.tgz#b1d64e55dafe1f0b98bb6c719881103ecf6c86df" @@ -8365,6 +8574,13 @@ vitest@^3.2.4: vite-node "3.2.4" why-is-node-running "^2.3.0" +w3c-xmlserializer@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c" + integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA== + dependencies: + xml-name-validator "^5.0.0" + wavesurfer-react@^3.0.4: version "3.0.5" resolved "https://registry.yarnpkg.com/wavesurfer-react/-/wavesurfer-react-3.0.5.tgz#fb8e4df7dc4346703277b29f9fb193049d046665" @@ -8390,6 +8606,11 @@ webidl-conversions@^3.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== +webidl-conversions@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz#0657e571fe6f06fcb15ca50ed1fdbcb495cd1686" + integrity sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ== + websocket-driver@>=0.5.1: version "0.7.4" resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" @@ -8404,6 +8625,20 @@ websocket-extensions@>=0.1.1: resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== +whatwg-mimetype@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz#d8232895dbd527ceaee74efd4162008fb8a8cf48" + integrity sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw== + +whatwg-url@^16.0.0, whatwg-url@^16.0.1: + version "16.0.1" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-16.0.1.tgz#047f7f4bd36ef76b7198c172d1b1cebc66f764dd" + integrity sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw== + dependencies: + "@exodus/bytes" "^1.11.0" + tr46 "^6.0.0" + webidl-conversions "^8.0.1" + whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" @@ -8547,6 +8782,16 @@ ws@^8.18.2: resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== +xml-name-validator@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673" + integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== + +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" From 342494b407119a02a85fe47424968268c48dabdd Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Tue, 12 May 2026 10:34:53 -0600 Subject: [PATCH 19/41] Refactor GlobalConfigParser to reduce config fetches --- src/GlobalConfigParser.tsx | 55 +++++++++++++++----------------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/src/GlobalConfigParser.tsx b/src/GlobalConfigParser.tsx index c8a280c611..e493b7f702 100644 --- a/src/GlobalConfigParser.tsx +++ b/src/GlobalConfigParser.tsx @@ -28,27 +28,10 @@ async function fetchGlobalConfigArray() { return parseGlobalConfig(configs); } -function isHomeRoute() { - const pathname = window.location.pathname.replace(/\/+$/, '') || '/'; - const basePath = PREFIX.replace(/\/+$/, '') || '/'; - - return pathname === basePath || pathname === `${basePath}/`; -} - -export function GlobalConfigParser() { - const [globalConfig, setGlobalConfig] = useState>(null); +function HomeRoute({ globalConfig }: { globalConfig: GlobalConfig }) { const [studyConfigs, setStudyConfigs] = useState | null>>({}); useEffect(() => { - if (!globalConfig) { - return undefined; - } - - if (!isHomeRoute()) { - setStudyConfigs({}); - return undefined; - } - let cancelled = false; async function fetchData(currentGlobalConfig: GlobalConfig) { @@ -65,6 +48,26 @@ export function GlobalConfigParser() { }; }, [globalConfig]); + return ( + <> + + + + + + + ); +} + +export function GlobalConfigParser() { + const [globalConfig, setGlobalConfig] = useState>(null); + useEffect(() => { if (globalConfig) { return undefined; @@ -108,21 +111,7 @@ export function GlobalConfigParser() { - - - - - - - )} + element={} /> Date: Tue, 12 May 2026 23:33:26 -0600 Subject: [PATCH 20/41] Enhance Shell component with loading overlay and completion check logic --- src/components/Shell.tsx | 44 +++++++++++++++++++++-------- src/storage/tests/highLevel.spec.ts | 1 + 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index f82f779796..f82f42feb5 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -6,7 +6,7 @@ import { } from 'react'; import { Provider } from 'react-redux'; import { RouteObject, useRoutes, useSearchParams } from 'react-router'; -import { LoadingOverlay, Title } from '@mantine/core'; +import { Box, LoadingOverlay, Title } from '@mantine/core'; import { GlobalConfig, Nullable, @@ -125,6 +125,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { const [routes, setRoutes] = useState([]); const [store, setStore] = useState>(null); + const [isCompletionCheckResolved, setIsCompletionCheckResolved] = useState(false); const { storageEngine } = useStorageEngine(); const [searchParams] = useSearchParams(); @@ -152,6 +153,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { async function initializeUserStoreRouting() { // Check that we have a storage engine and active config (studyId is set for config, but typescript complains) if (!storageEngine || !activeConfig || !canonicalStudyId) return; + setIsCompletionCheckResolved(false); try { // Make sure that we have a study database and that the study database has a sequence array @@ -261,13 +263,21 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { }); } - storageEngine.getParticipantCompletionStatus(participantSession.participantId).then((participantCompleted) => { - if (!isCancelled) { - newStore.store.dispatch(newStore.actions.setParticipantCompleted(participantCompleted)); - } - }).catch((error) => { - console.error('Error fetching participant completion status:', error); - }); + if (!modes.dataCollectionEnabled) { + setIsCompletionCheckResolved(true); + } else { + storageEngine.getParticipantCompletionStatus(participantSession.participantId).then((participantCompleted) => { + if (!isCancelled) { + newStore.store.dispatch(newStore.actions.setParticipantCompleted(participantCompleted)); + setIsCompletionCheckResolved(true); + } + }).catch((error) => { + console.error('Error fetching participant completion status:', error); + if (!isCancelled) { + setIsCompletionCheckResolved(true); + } + }); + } } catch (error) { console.error('Error initializing user store routing:', error); // Fallback: initialize the store with empty data @@ -296,6 +306,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { } setStore(emptyStore); + setIsCompletionCheckResolved(true); } if (isCancelled) { @@ -339,6 +350,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { }, [storageEngine, activeConfig, canonicalStudyId, searchParams, participantId, studyCondition]); const routing = useRoutes(routes); + const isInteractionLocked = routes.length > 0 && store !== null && !isCompletionCheckResolved; let toRender: ReactNode = null; @@ -350,9 +362,19 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { } else { // If routing is null, we didn't match any routes toRender = routing && store ? ( - - {routing} - + + + + + {routing} + + + ) : ( ); diff --git a/src/storage/tests/highLevel.spec.ts b/src/storage/tests/highLevel.spec.ts index 3d4a85e834..14a33191d9 100644 --- a/src/storage/tests/highLevel.spec.ts +++ b/src/storage/tests/highLevel.spec.ts @@ -501,6 +501,7 @@ describe.each([ }); test('updateParticipantMetadata stays local when data collection is disabled', async () => { + await storageEngine.getModes(studyId); await storageEngine.setMode(studyId, 'dataCollectionEnabled', false); const participantSession = await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata); From d1d77040c7078782d546a6efe88ab3d60eb27d13 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Tue, 12 May 2026 23:41:56 -0600 Subject: [PATCH 21/41] Add error handling and user feedback for participant completion check in Shell component --- src/components/Shell.tsx | 28 +++++++++++++++++++-- src/storage/engines/types.ts | 6 ++++- src/storage/tests/highLevel.spec.ts | 38 +++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index f82f42feb5..ee4bc1c0f8 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -6,7 +6,9 @@ import { } from 'react'; import { Provider } from 'react-redux'; import { RouteObject, useRoutes, useSearchParams } from 'react-router'; -import { Box, LoadingOverlay, Title } from '@mantine/core'; +import { + Box, Button, LoadingOverlay, Stack, Text, Title, +} from '@mantine/core'; import { GlobalConfig, Nullable, @@ -126,6 +128,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { const [routes, setRoutes] = useState([]); const [store, setStore] = useState>(null); const [isCompletionCheckResolved, setIsCompletionCheckResolved] = useState(false); + const [completionCheckError, setCompletionCheckError] = useState(null); const { storageEngine } = useStorageEngine(); const [searchParams] = useSearchParams(); @@ -154,6 +157,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { // Check that we have a storage engine and active config (studyId is set for config, but typescript complains) if (!storageEngine || !activeConfig || !canonicalStudyId) return; setIsCompletionCheckResolved(false); + setCompletionCheckError(null); try { // Make sure that we have a study database and that the study database has a sequence array @@ -274,7 +278,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { }).catch((error) => { console.error('Error fetching participant completion status:', error); if (!isCancelled) { - setIsCompletionCheckResolved(true); + setCompletionCheckError('We could not verify whether this study session was already completed. Please reload this page and try again.'); } }); } @@ -369,6 +373,26 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { overlayProps={{ blur: 1 }} loaderProps={{ size: 'sm' }} /> + {isInteractionLocked && completionCheckError && ( + + {completionCheckError} + + + )} {routing} diff --git a/src/storage/engines/types.ts b/src/storage/engines/types.ts index 2434452e07..9a223f7bc3 100644 --- a/src/storage/engines/types.ts +++ b/src/storage/engines/types.ts @@ -923,7 +923,11 @@ export abstract class StorageEngine { }; if (modes.dataCollectionEnabled) { - await this.persistCurrentParticipantData({ immediate: !this.shouldDeferInitialParticipantDataPersistence() }); + if (this.shouldDeferInitialParticipantDataPersistence()) { + this.persistCurrentParticipantData({ immediate: true }).catch(() => undefined); + } else { + await this.persistCurrentParticipantData({ immediate: true }); + } } else { await this.cacheParticipantDataSnapshot(this.participantData, this.currentParticipantId); } diff --git a/src/storage/tests/highLevel.spec.ts b/src/storage/tests/highLevel.spec.ts index 14a33191d9..d5ae1f4966 100644 --- a/src/storage/tests/highLevel.spec.ts +++ b/src/storage/tests/highLevel.spec.ts @@ -234,6 +234,12 @@ class DelayedLocalStorageEngine extends LocalStorageEngine { } } +class DeferredInitialWriteLocalStorageEngine extends DelayedLocalStorageEngine { + protected override shouldDeferInitialParticipantDataPersistence() { + return true; + } +} + describe.each([ { TestEngine: LocalStorageEngine }, // { TestEngine: SupabaseStorageEngine }, // Uncomment to test with Supabase @@ -355,6 +361,38 @@ describe.each([ expect(getModesSpy).toHaveBeenCalledTimes(1); }); + test('initializeParticipantSession starts deferred initial participant writes immediately in the background', async () => { + const deferredStorageEngine = new DeferredInitialWriteLocalStorageEngine(true); + await deferredStorageEngine.connect(); + await deferredStorageEngine.initializeStudyDb(studyId); + await deferredStorageEngine.setSequenceArray(sequenceArray); + deferredStorageEngine.holdNextParticipantDataWrite(); + + const participantSessionPromise = deferredStorageEngine.initializeParticipantSession({}, configSimple, participantMetadata); + + await deferredStorageEngine.waitForHeldParticipantDataWrite(); + + const participantSession = await participantSessionPromise; + + const observerEngine = new LocalStorageEngine(true); + await observerEngine.connect(); + await observerEngine.initializeStudyDb(studyId); + + expect(await observerEngine.getParticipantData(participantSession.participantId)).toBeNull(); + + deferredStorageEngine.releaseHeldParticipantDataWrite(); + await deferredStorageEngine.flushPendingParticipantData(); + + const persistedParticipantData = await observerEngine.getParticipantData(participantSession.participantId); + expect(persistedParticipantData).toBeDefined(); + expect(persistedParticipantData?.participantId).toBe(participantSession.participantId); + + // @ts-expect-error using protected method for testing + await deferredStorageEngine._testingReset(studyId); + // @ts-expect-error using protected method for testing + await observerEngine._testingReset(studyId); + }); + test('initializeParticipantSession sets conditions from searchParams condition', async () => { const participantSession = await storageEngine.initializeParticipantSession({ condition: 'color' }, configSimple, participantMetadata); From 6b84aaf614d5ee95c162204c122fb4cd0689fd42 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Tue, 12 May 2026 23:58:32 -0600 Subject: [PATCH 22/41] Refactor Shell component to improve loading state handling and simplify rendering logic --- src/components/Shell.tsx | 86 ++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 47 deletions(-) diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index ee4bc1c0f8..7f168732c1 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -7,7 +7,7 @@ import { import { Provider } from 'react-redux'; import { RouteObject, useRoutes, useSearchParams } from 'react-router'; import { - Box, Button, LoadingOverlay, Stack, Text, Title, + Button, LoadingOverlay, Stack, Text, Title, } from '@mantine/core'; import { GlobalConfig, @@ -223,7 +223,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { participantSearchParamCondition: participantSession.searchParams?.condition, allowUrlOverride: modes.developmentModeEnabled, }); - const filteredParticipantSequence = await filterSequenceByCondition(participantSession.sequence, resolvedCondition); + const filteredParticipantSequence = filterSequenceByCondition(participantSession.sequence, resolvedCondition); // Initialize the redux stores const newStore = await studyStoreCreator( @@ -354,54 +354,46 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { }, [storageEngine, activeConfig, canonicalStudyId, searchParams, participantId, studyCondition]); const routing = useRoutes(routes); - const isInteractionLocked = routes.length > 0 && store !== null && !isCompletionCheckResolved; + const isLoading = isValidStudyId && (routes.length === 0 || store === null || !isCompletionCheckResolved); - let toRender: ReactNode = null; + let content: ReactNode = null; - // Definitely a 404 if (!isValidStudyId) { - toRender = ; - } else if (routes.length === 0) { - toRender = ; - } else { - // If routing is null, we didn't match any routes - toRender = routing && store ? ( - - - {isInteractionLocked && completionCheckError && ( - - {completionCheckError} - - - )} - - - {routing} - - - - ) : ( - + content = ; + } else if (routing && store) { + content = ( + + {routing} + ); + } else if (!isLoading) { + content = ; } - return toRender; + + return ( + <> + + {isLoading && completionCheckError && ( + + {completionCheckError} + + + )} + {content} + + ); } From 84c582b187419ec36ead990700c8f06673ba031d Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Tue, 12 May 2026 23:58:48 -0600 Subject: [PATCH 23/41] Refactor AuthProvider to enhance loading state handling and conditionally render children based on route match --- src/store/hooks/useAuth.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/store/hooks/useAuth.tsx b/src/store/hooks/useAuth.tsx index 8d9370d751..f8daca8d45 100644 --- a/src/store/hooks/useAuth.tsx +++ b/src/store/hooks/useAuth.tsx @@ -4,6 +4,7 @@ import { useCallback, } from 'react'; import { LoadingOverlay } from '@mantine/core'; +import { useLocation, useMatch } from 'react-router'; import { useStorageEngine } from '../../storage/storageEngineHooks'; import { StoredUser, UserWrapped } from '../../storage/engines/types'; import { isCloudStorageEngine } from '../../storage/engines/utils/storageEngineHelpers'; @@ -65,6 +66,8 @@ export function AuthProvider({ children } : { children: ReactNode }) { const [user, setUser] = useState(loadingNullUser); const [enableAuthTrigger, setEnableAuthTrigger] = useState(false); const { storageEngine } = useStorageEngine(); + const location = useLocation(); + const studyRouteMatch = useMatch('/:studyId/*'); // Logs the user out by removing the user and navigating to '/login' const logout = async () => { @@ -166,9 +169,11 @@ export function AuthProvider({ children } : { children: ReactNode }) { // eslint-disable-next-line react-hooks/exhaustive-deps }), [user]); + const allowChildrenWhileDeterminingStatus = Boolean(studyRouteMatch) && !location.pathname.startsWith('/analysis'); + return ( - {user.determiningStatus ? : children } + {user.determiningStatus && !allowChildrenWhileDeterminingStatus ? : children } ); } From d87b94ec0011b8379d2252cbf0701b2f7a642498 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 13 May 2026 00:04:11 -0600 Subject: [PATCH 24/41] Eliminate unnecessary await --- src/components/Shell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 7f168732c1..afcf390464 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -288,7 +288,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { const generatedSequences = await generateSequenceArray(activeConfig); const matchingSequence = generatedSequences[0]; - const fallbackSequence = await filterSequenceByCondition( + const fallbackSequence = filterSequenceByCondition( matchingSequence, studyCondition, ); From f32fbf684ddbe72790dbc37e706e089603812b68 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 13 May 2026 00:05:45 -0600 Subject: [PATCH 25/41] Handle transient completion-status lookup failures to allow study entry --- src/components/Shell.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index afcf390464..7c2798b88f 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -279,6 +279,8 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { console.error('Error fetching participant completion status:', error); if (!isCancelled) { setCompletionCheckError('We could not verify whether this study session was already completed. Please reload this page and try again.'); + // A transient completion-status lookup failure should not block study entry. + setIsCompletionCheckResolved(true); } }); } From 1702f50cba712e019476a9d23949da2f334051e8 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 13 May 2026 00:58:05 -0600 Subject: [PATCH 26/41] Fix small issue with operators --- src/components/Shell.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index b0b3cca1e1..1eaf12cf4e 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -210,8 +210,7 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { let modes: Record | null = null; let storageOperationFailed = false; const urlParticipantId = activeConfig.uiConfig.urlParticipantIdParam - ? searchParams.get(activeConfig.uiConfig.urlParticipantIdParam) - || undefined + ? searchParams.get(activeConfig.uiConfig.urlParticipantIdParam) ?? undefined : undefined; try { // Make sure that we have a study database and that the study database has a sequence array From df8a9abf61de3f65f30cff9d23e54b03658a5e68 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 13 May 2026 02:15:08 -0600 Subject: [PATCH 27/41] Refactor isStorageStartupFailure function to simplify logic and remove unused parameter --- src/components/Shell.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 1eaf12cf4e..8de3072bc5 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -53,13 +53,8 @@ export function getScreenOrientationType(screen: Screen) { export function isStorageStartupFailure( storageEngine: StartupStorageStatus, configuredEngine: string, - storageOperationFailed: boolean = false, ) { - if (!storageEngine.isConnected() || storageEngine.getEngine() !== configuredEngine) { - return true; - } - - return storageOperationFailed && configuredEngine !== 'localStorage'; + return !storageEngine.isConnected() || storageEngine.getEngine() !== configuredEngine; } export function getStartupErrorMessage(error: unknown) { @@ -208,7 +203,6 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { setCompletionCheckError(null); let modes: Record | null = null; - let storageOperationFailed = false; const urlParticipantId = activeConfig.uiConfig.urlParticipantIdParam ? searchParams.get(activeConfig.uiConfig.urlParticipantIdParam) ?? undefined : undefined; @@ -335,12 +329,10 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { }); } } catch (error) { - storageOperationFailed = true; console.error('Error initializing user store routing:', error); const isStorageFailure = isStorageStartupFailure( storageEngine, import.meta.env.VITE_STORAGE_ENGINE, - storageOperationFailed, ); const resolvedModes = modes ?? await storageEngine.getModes(canonicalStudyId).catch(() => null); const developmentModeEnabledForAlert = resolvedModes?.developmentModeEnabled ?? false; From 903b77756d98e90f451b1ad9cdab2abbaf8c9c1b Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 13 May 2026 02:40:46 -0600 Subject: [PATCH 28/41] Fix microphone permission handling in AppHeader component tests --- src/components/interface/AppHeader.spec.tsx | 31 ++++++++++++++++++++- src/components/interface/AppHeader.tsx | 5 +++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/components/interface/AppHeader.spec.tsx b/src/components/interface/AppHeader.spec.tsx index 248b943a0e..5a574d2d05 100644 --- a/src/components/interface/AppHeader.spec.tsx +++ b/src/components/interface/AppHeader.spec.tsx @@ -5,6 +5,8 @@ import { } from 'vitest'; import { AppHeader } from './AppHeader'; +let mockedCurrentComponent = 'componentA'; + let mockedRecordingContext = { isScreenRecording: false, isAudioRecording: false, @@ -78,7 +80,7 @@ vi.mock('react-router', () => ({ })); vi.mock('../../routes/utils', () => ({ - useCurrentComponent: () => 'componentA', + useCurrentComponent: () => mockedCurrentComponent, useCurrentStep: () => 0, useStudyId: () => 'test-study', })); @@ -143,6 +145,7 @@ vi.mock('./RecordingAudioWaveform', () => ({ describe('AppHeader', () => { beforeEach(() => { + mockedCurrentComponent = 'componentA'; mockedRecordingContext = { isScreenRecording: false, isAudioRecording: false, @@ -189,6 +192,32 @@ describe('AppHeader', () => { expect(html).toContain('mic-off'); }); + test('hides stale mic error outside audio and permission pages', () => { + mockedRecordingContext = { + ...mockedRecordingContext, + audioRecordingError: 'Microphone permission denied', + audioStatus: 'denied', + }; + + const html = renderToStaticMarkup(); + + expect(html).not.toContain('Microphone error'); + expect(html).not.toContain('Microphone permission denied'); + }); + + test('shows mic error on the screen recording permission page', () => { + mockedCurrentComponent = '$screen-recording.components.screenRecordingPermission'; + mockedRecordingContext = { + ...mockedRecordingContext, + audioRecordingError: 'Microphone permission denied', + audioStatus: 'denied', + }; + + const html = renderToStaticMarkup(); + + expect(html).toContain('Microphone permission denied'); + }); + test('shows screen recording error in the header', () => { mockedRecordingContext = { ...mockedRecordingContext, diff --git a/src/components/interface/AppHeader.tsx b/src/components/interface/AppHeader.tsx index 5707d3f896..138d95840a 100644 --- a/src/components/interface/AppHeader.tsx +++ b/src/components/interface/AppHeader.tsx @@ -110,7 +110,10 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d } = useDeviceRules(studyConfig.studyRules); const hasUnmetDeviceRequirement = developmentModeEnabled && (!isBrowserAllowed || !isDeviceAllowed || !isInputAllowed || !isDisplayAllowed); - const showAudioStatus = currentComponentHasAudioRecording || isAudioRecording || audioStatus !== 'idle'; + const isScreenRecordingPermission = currentComponent === '$screen-recording.components.screenRecordingPermission'; + const showAudioStatus = currentComponentHasAudioRecording + || isAudioRecording + || (isScreenRecordingPermission && audioStatus !== 'idle'); const showRecordingStatus = showAudioStatus || isScreenRecording || !!screenRecordingError; const isAudioActivelyRecording = audioStatus === 'recording' && !isMuted; let recordingLabel = ''; From 75c8f122648cb6a5c8890862155b9634ad1d2ce7 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 13 May 2026 03:10:14 -0600 Subject: [PATCH 29/41] Fix replay previous-step navigation --- src/store/hooks/usePreviousStep.spec.tsx | 32 ++++++++++--- src/store/hooks/usePreviousStep.ts | 57 ++++++++++++++++++------ 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/src/store/hooks/usePreviousStep.spec.tsx b/src/store/hooks/usePreviousStep.spec.tsx index 6ffa4e5f49..5adce7961f 100644 --- a/src/store/hooks/usePreviousStep.spec.tsx +++ b/src/store/hooks/usePreviousStep.spec.tsx @@ -16,6 +16,7 @@ const mockDispatch = vi.fn(); let mockCurrentStep = 1; let mockFuncIndex: string | undefined; let mockIsAnalysis = false; +let mockAnswers: Record; let capturedHook: ReturnType | undefined; vi.mock('react-router', () => ({ @@ -57,10 +58,7 @@ vi.mock('../store', () => ({ deleteDynamicBlockAnswers: mockDeleteDynamicBlockAnswers, }), useStoreSelector: (selector: (state: { answers: Record }) => unknown) => selector({ - answers: { - dynamicBlock_1_component_0: { trialOrder: '1_0' }, - dynamicBlock_1_component_1: { trialOrder: '1_1' }, - }, + answers: mockAnswers, }), })); @@ -77,6 +75,10 @@ describe('usePreviousStep', () => { mockCurrentStep = 1; mockFuncIndex = undefined; mockIsAnalysis = false; + mockAnswers = { + dynamicBlock_1_component_0: { trialOrder: '1_0' }, + dynamicBlock_1_component_1: { trialOrder: '1_1' }, + }; capturedHook = undefined; vi.stubGlobal('window', { @@ -99,12 +101,32 @@ describe('usePreviousStep', () => { test('does not delete dynamic replay answers when moving backward in analysis replay', () => { mockIsAnalysis = true; mockFuncIndex = '1'; + vi.stubGlobal('window', { + location: { search: '?participantId=p-1¤tTrial=dynamicBlock_1_component_1' }, + }); renderToStaticMarkup(); capturedHook?.goToPreviousStep(); expect(mockDispatch).not.toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith('/study-1/enc-1/enc-0?participantId=p-1'); + expect(mockNavigate).toHaveBeenCalledWith('/study-1/enc-1/enc-0?participantId=p-1¤tTrial=dynamicBlock_1_component_0'); + }); + + test('skips unanswered dynamic blocks when moving backward in analysis replay', () => { + mockIsAnalysis = true; + mockCurrentStep = 2; + mockAnswers = { + intro_0: { trialOrder: '0' }, + }; + vi.stubGlobal('window', { + location: { search: '?participantId=p-1¤tTrial=end_2' }, + }); + + renderToStaticMarkup(); + + capturedHook?.goToPreviousStep(); + + expect(mockNavigate).toHaveBeenCalledWith('/study-1/enc-0?participantId=p-1¤tTrial=intro_0'); }); }); diff --git a/src/store/hooks/usePreviousStep.ts b/src/store/hooks/usePreviousStep.ts index ec57e15d36..f0207efb6a 100644 --- a/src/store/hooks/usePreviousStep.ts +++ b/src/store/hooks/usePreviousStep.ts @@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router'; import { useCurrentStep, useStudyId } from '../../routes/utils'; import { useIsAnalysis } from './useIsAnalysis'; import { decryptIndex, encryptIndex } from '../../utils/encryptDecryptIndex'; +import { parseTrialOrder } from '../../utils/parseTrialOrder'; import { useStudyConfig } from './useStudyConfig'; import { getSequenceFlatMap, findFuncBlock } from '../../utils/getSequenceFlatMap'; import { useStoreDispatch, useStoreActions, useStoreSelector } from '../store'; @@ -21,6 +22,26 @@ export function usePreviousStep() { // Status of the previous button. If true, the previous button should be disabled const isPreviousDisabled = typeof currentStep !== 'number' || currentStep <= 0; + const buildSearch = useCallback((targetTrialOrder?: string) => { + if (!isAnalysis) { + return window.location.search; + } + + const params = new URLSearchParams(window.location.search); + const matchingAnswerIdentifier = targetTrialOrder + ? Object.entries(answers).find(([, answer]) => answer.trialOrder === targetTrialOrder)?.[0] + : undefined; + + if (matchingAnswerIdentifier) { + params.set('currentTrial', matchingAnswerIdentifier); + } else { + params.delete('currentTrial'); + } + + const search = params.toString(); + return search ? `?${search}` : ''; + }, [answers, isAnalysis]); + const goToPreviousStep = useCallback(() => { if (typeof currentStep !== 'number') { return; @@ -38,24 +59,34 @@ export function usePreviousStep() { // If we're at the first element of a dynamic block, exit the dynamic block if (decryptIndex(funcIndex) !== 0) { - navigate(`/${studyId}/${encryptIndex(currentStep)}/${encryptIndex(decryptIndex(funcIndex) - 1)}${window.location.search}`); + const previousFuncIndex = decryptIndex(funcIndex) - 1; + navigate(`/${studyId}/${encryptIndex(currentStep)}/${encryptIndex(previousFuncIndex)}${buildSearch(`${currentStep}_${previousFuncIndex}`)}`); return; } } - const previousComponentId = flatSequence[previousStep]; - // Check if previous component is a dynamic block - const isDynamicBlock = findFuncBlock(previousComponentId, studyConfig.sequence); - if (isDynamicBlock) { - // Find the last component that has been answered in the dynamic block - const dynamicBlockAnswers = Object.keys(answers).filter((key) => key.startsWith(`${previousComponentId}_${previousStep}_`)); - const previousDynamicBlockIndex = dynamicBlockAnswers.length - 1; - // Navigate to the last answered index in the dynamic block - navigate(`/${studyId}/${encryptIndex(previousStep)}/${encryptIndex(previousDynamicBlockIndex)}${window.location.search}`); - } else { - navigate(`/${studyId}/${encryptIndex(previousStep)}${window.location.search}`); + for (let stepIndex = previousStep; stepIndex >= 0; stepIndex -= 1) { + const previousComponentId = flatSequence[stepIndex]; + const isDynamicBlock = findFuncBlock(previousComponentId, studyConfig.sequence); + + if (!isDynamicBlock) { + navigate(`/${studyId}/${encryptIndex(stepIndex)}${buildSearch(`${stepIndex}`)}`); + return; + } + + const previousDynamicBlockIndex = Object.entries(answers) + .filter(([key]) => key.startsWith(`${previousComponentId}_${stepIndex}_`)) + .reduce((maxIndex, [, answer]) => { + const { funcIndex: answerFuncIndex } = parseTrialOrder(answer.trialOrder); + return answerFuncIndex !== null ? Math.max(maxIndex, answerFuncIndex) : maxIndex; + }, -1); + + if (previousDynamicBlockIndex >= 0) { + navigate(`/${studyId}/${encryptIndex(stepIndex)}/${encryptIndex(previousDynamicBlockIndex)}${buildSearch(`${stepIndex}_${previousDynamicBlockIndex}`)}`); + return; + } } - }, [answers, currentStep, deleteDynamicBlockAnswers, funcIndex, isAnalysis, navigate, storeDispatch, studyConfig, studyId]); + }, [answers, buildSearch, currentStep, deleteDynamicBlockAnswers, funcIndex, isAnalysis, navigate, storeDispatch, studyConfig, studyId]); return { isPreviousDisabled, From 622b45908ea9f16cfe87f807e40ac577533aa4a4 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 13 May 2026 23:43:19 -0600 Subject: [PATCH 30/41] Fix replay bug and remove test fixture --- public/global.json | 2 +- .../thinkAloud/ThinkAloudAnalysis.tsx | 3 +- .../thinkAloud/ThinkAloudFooter.spec.ts | 8 ++--- .../thinkAloud/taskNavigation.ts | 9 ++---- src/components/interface/StepsPanel.tsx | 5 ++-- src/routes/utils.tsx | 9 +++--- src/store/hooks/usePreviousStep.spec.tsx | 4 +-- src/store/hooks/usePreviousStep.ts | 29 +++++-------------- src/utils/navigationSearch.spec.ts | 21 ++++++++++++++ src/utils/navigationSearch.ts | 11 +++++++ 10 files changed, 59 insertions(+), 42 deletions(-) create mode 100644 src/utils/navigationSearch.spec.ts create mode 100644 src/utils/navigationSearch.ts diff --git a/public/global.json b/public/global.json index 5cb448ef13..9f59ed829a 100644 --- a/public/global.json +++ b/public/global.json @@ -239,4 +239,4 @@ "test": true } } -} \ No newline at end of file +} diff --git a/src/analysis/individualStudy/thinkAloud/ThinkAloudAnalysis.tsx b/src/analysis/individualStudy/thinkAloud/ThinkAloudAnalysis.tsx index a737fd749b..2834398ff8 100644 --- a/src/analysis/individualStudy/thinkAloud/ThinkAloudAnalysis.tsx +++ b/src/analysis/individualStudy/thinkAloud/ThinkAloudAnalysis.tsx @@ -22,6 +22,7 @@ import { ThinkAloudFooter } from './ThinkAloudFooter'; import { useEvent } from '../../../store/hooks/useEvent'; import { FirebaseStorageEngine } from '../../../storage/engines/FirebaseStorageEngine'; import { ReplayContext, useReplay } from '../../../store/hooks/useReplay'; +import { removeCurrentTrialFromSearch } from '../../../utils/navigationSearch'; import { parseTrialOrder } from '../../../utils/parseTrialOrder'; async function getTranscript(storageEngine: FirebaseStorageEngine, partId: string | undefined, trialName: string | undefined, authEmail: string | null | undefined) { @@ -143,7 +144,7 @@ export function ThinkAloudAnalysis({ visibleParticipants, storageEngine } : { vi return; } - navigate(`/analysis/stats/${studyId}/tagging/${encodeURIComponent(firstTrialIdentifier)}${location.search}`, { replace: true }); + navigate(`/analysis/stats/${studyId}/tagging/${encodeURIComponent(firstTrialIdentifier)}${removeCurrentTrialFromSearch(location.search)}`, { replace: true }); }, [currentTrial, location.search, navigate, participant, studyId]); const setEditedTranscript = useCallback((editedText: EditedText[]) => { diff --git a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.spec.ts b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.spec.ts index c6f33f6f82..1c879dbbcb 100644 --- a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.spec.ts +++ b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.spec.ts @@ -7,7 +7,7 @@ import { encryptIndex } from '../../../utils/encryptDecryptIndex'; import { buildTaskNavigationTarget } from './taskNavigation'; describe('buildTaskNavigationTarget', () => { - test('updates currentTrial when navigating between replay tasks', () => { + test('removes currentTrial when navigating between replay tasks', () => { const target = buildTaskNavigationTarget({ answerIdentifier: 'jupyterlite-task-2_8', trialOrder: '8', @@ -18,11 +18,11 @@ describe('buildTaskNavigationTarget', () => { expect(target).toEqual({ pathname: `/buckaroo/${encryptIndex(8)}`, - search: '?participantId=66aa582aba2b183e61577d44¤tTrial=jupyterlite-task-2_8', + search: '?participantId=66aa582aba2b183e61577d44', }); }); - test('keeps analysis tagging navigation search unchanged', () => { + test('removes currentTrial from analysis tagging navigation search', () => { const target = buildTaskNavigationTarget({ answerIdentifier: 'task_4', trialOrder: '4', @@ -33,7 +33,7 @@ describe('buildTaskNavigationTarget', () => { expect(target).toEqual({ pathname: '/analysis/stats/study-1/tagging/task_4', - search: '?participantId=p-1¤tTrial=task_3', + search: '?participantId=p-1', }); }); }); diff --git a/src/analysis/individualStudy/thinkAloud/taskNavigation.ts b/src/analysis/individualStudy/thinkAloud/taskNavigation.ts index fcf0e0f5ee..c173694b0f 100644 --- a/src/analysis/individualStudy/thinkAloud/taskNavigation.ts +++ b/src/analysis/individualStudy/thinkAloud/taskNavigation.ts @@ -1,4 +1,5 @@ import { encryptIndex } from '../../../utils/encryptDecryptIndex'; +import { removeCurrentTrialFromSearch } from '../../../utils/navigationSearch'; import { parseTrialOrder } from '../../../utils/parseTrialOrder'; export function buildTaskNavigationTarget({ @@ -22,18 +23,14 @@ export function buildTaskNavigationTarget({ if (!isReplay) { return { pathname: `/analysis/stats/${studyId}/tagging/${encodeURIComponent(answerIdentifier)}`, - search, + search: removeCurrentTrialFromSearch(search), }; } - const params = new URLSearchParams(search); - params.set('currentTrial', answerIdentifier); - const nextSearch = params.toString(); - return { pathname: funcIndex === null ? `/${studyId}/${encryptIndex(step)}` : `/${studyId}/${encryptIndex(step)}/${encryptIndex(funcIndex)}`, - search: nextSearch ? `?${nextSearch}` : '', + search: removeCurrentTrialFromSearch(search), }; } diff --git a/src/components/interface/StepsPanel.tsx b/src/components/interface/StepsPanel.tsx index 54a43c223b..a8c968b921 100644 --- a/src/components/interface/StepsPanel.tsx +++ b/src/components/interface/StepsPanel.tsx @@ -24,6 +24,7 @@ import type { Sequence, StoredAnswer } from '../../store/types'; import { addPathToComponentBlock } from '../../utils/getSequenceFlatMap'; import { useStudyId } from '../../routes/utils'; import { encryptIndex } from '../../utils/encryptDecryptIndex'; +import { removeCurrentTrialFromSearch } from '../../utils/navigationSearch'; import { isDynamicBlock } from '../../parser/utils'; import { componentAnswersAreCorrect } from '../../utils/correctAnswer'; import { studyComponentToIndividualComponent } from '../../utils/handleComponentInheritance'; @@ -745,9 +746,9 @@ export function StepsPanel({ onClick={() => { if (isComponent && href && !isExcluded) { if (isAnalysis) { - navigate(`/analysis/stats/${studyId}/stats/${encodeURIComponent(String(componentName))}${location.search}`); + navigate(`/analysis/stats/${studyId}/stats/${encodeURIComponent(String(componentName))}${removeCurrentTrialFromSearch(location.search)}`); } else { - navigate(`${href}${location.search}`); + navigate(`${href}${removeCurrentTrialFromSearch(location.search)}`); } } else if (!isComponent) { // Both included and excluded blocks can be collapsed/expanded diff --git a/src/routes/utils.tsx b/src/routes/utils.tsx index 8e9ab3f3c9..b5db9c7c46 100644 --- a/src/routes/utils.tsx +++ b/src/routes/utils.tsx @@ -7,6 +7,7 @@ import { useFlatSequence, useStoreActions, useStoreDispatch, useStoreSelector, } from '../store/store'; import { decryptIndex, encryptIndex } from '../utils/encryptDecryptIndex'; +import { removeCurrentTrialFromSearch } from '../utils/navigationSearch'; import { parseTrialOrder } from '../utils/parseTrialOrder'; import { JumpFunctionParameters, JumpFunctionReturnVal } from '../store/types'; import { findFuncBlock } from '../utils/getSequenceFlatMap'; @@ -95,7 +96,7 @@ export function useCurrentComponent(): string { useEffect(() => { if (!funcIndex && nextFunc && typeof currentStep === 'number') { - navigate(`/${studyId}/${encryptIndex(currentStep)}/${encryptIndex(currentTrialStep === currentStep && currentTrialFuncIndex !== null ? currentTrialFuncIndex : 0)}${window.location.search}`); + navigate(`/${studyId}/${encryptIndex(currentStep)}/${encryptIndex(currentTrialStep === currentStep && currentTrialFuncIndex !== null ? currentTrialFuncIndex : 0)}${removeCurrentTrialFromSearch(window.location.search)}`); } }, [currentStep, currentTrialFuncIndex, currentTrialStep, funcIndex, navigate, nextFunc, studyId]); @@ -106,12 +107,12 @@ export function useCurrentComponent(): string { const decryptedFuncIndex = decryptIndex(funcIndex); if (currentTrialFuncIndex === null) { - navigate(`/${studyId}/${encryptIndex(currentStep)}${window.location.search}`); + navigate(`/${studyId}/${encryptIndex(currentStep)}${removeCurrentTrialFromSearch(window.location.search)}`); return; } if (decryptedFuncIndex !== currentTrialFuncIndex) { - navigate(`/${studyId}/${encryptIndex(currentStep)}/${encryptIndex(currentTrialFuncIndex)}${window.location.search}`); + navigate(`/${studyId}/${encryptIndex(currentStep)}/${encryptIndex(currentTrialFuncIndex)}${removeCurrentTrialFromSearch(window.location.search)}`); } }, [currentStep, currentTrialFuncIndex, currentTrialStep, funcIndex, navigate, studyId]); @@ -158,7 +159,7 @@ export function useCurrentComponent(): string { setCompName('__dynamicLoading'); setIndexWhenSettingComponentName(null); - navigate(`/${studyId}/${encryptIndex(currentStep + 1)}${window.location.search}`); + navigate(`/${studyId}/${encryptIndex(currentStep + 1)}${removeCurrentTrialFromSearch(window.location.search)}`); } } } diff --git a/src/store/hooks/usePreviousStep.spec.tsx b/src/store/hooks/usePreviousStep.spec.tsx index 5adce7961f..de16729487 100644 --- a/src/store/hooks/usePreviousStep.spec.tsx +++ b/src/store/hooks/usePreviousStep.spec.tsx @@ -110,7 +110,7 @@ describe('usePreviousStep', () => { capturedHook?.goToPreviousStep(); expect(mockDispatch).not.toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith('/study-1/enc-1/enc-0?participantId=p-1¤tTrial=dynamicBlock_1_component_0'); + expect(mockNavigate).toHaveBeenCalledWith('/study-1/enc-1/enc-0?participantId=p-1'); }); test('skips unanswered dynamic blocks when moving backward in analysis replay', () => { @@ -127,6 +127,6 @@ describe('usePreviousStep', () => { capturedHook?.goToPreviousStep(); - expect(mockNavigate).toHaveBeenCalledWith('/study-1/enc-0?participantId=p-1¤tTrial=intro_0'); + expect(mockNavigate).toHaveBeenCalledWith('/study-1/enc-0?participantId=p-1'); }); }); diff --git a/src/store/hooks/usePreviousStep.ts b/src/store/hooks/usePreviousStep.ts index f0207efb6a..70fd28ec45 100644 --- a/src/store/hooks/usePreviousStep.ts +++ b/src/store/hooks/usePreviousStep.ts @@ -4,6 +4,7 @@ import { useCurrentStep, useStudyId } from '../../routes/utils'; import { useIsAnalysis } from './useIsAnalysis'; import { decryptIndex, encryptIndex } from '../../utils/encryptDecryptIndex'; import { parseTrialOrder } from '../../utils/parseTrialOrder'; +import { removeCurrentTrialFromSearch } from '../../utils/navigationSearch'; import { useStudyConfig } from './useStudyConfig'; import { getSequenceFlatMap, findFuncBlock } from '../../utils/getSequenceFlatMap'; import { useStoreDispatch, useStoreActions, useStoreSelector } from '../store'; @@ -22,25 +23,9 @@ export function usePreviousStep() { // Status of the previous button. If true, the previous button should be disabled const isPreviousDisabled = typeof currentStep !== 'number' || currentStep <= 0; - const buildSearch = useCallback((targetTrialOrder?: string) => { - if (!isAnalysis) { - return window.location.search; - } - - const params = new URLSearchParams(window.location.search); - const matchingAnswerIdentifier = targetTrialOrder - ? Object.entries(answers).find(([, answer]) => answer.trialOrder === targetTrialOrder)?.[0] - : undefined; - - if (matchingAnswerIdentifier) { - params.set('currentTrial', matchingAnswerIdentifier); - } else { - params.delete('currentTrial'); - } - - const search = params.toString(); - return search ? `?${search}` : ''; - }, [answers, isAnalysis]); + const buildSearch = useCallback(() => ( + isAnalysis ? removeCurrentTrialFromSearch(window.location.search) : window.location.search + ), [isAnalysis]); const goToPreviousStep = useCallback(() => { if (typeof currentStep !== 'number') { @@ -60,7 +45,7 @@ export function usePreviousStep() { // If we're at the first element of a dynamic block, exit the dynamic block if (decryptIndex(funcIndex) !== 0) { const previousFuncIndex = decryptIndex(funcIndex) - 1; - navigate(`/${studyId}/${encryptIndex(currentStep)}/${encryptIndex(previousFuncIndex)}${buildSearch(`${currentStep}_${previousFuncIndex}`)}`); + navigate(`/${studyId}/${encryptIndex(currentStep)}/${encryptIndex(previousFuncIndex)}${buildSearch()}`); return; } } @@ -70,7 +55,7 @@ export function usePreviousStep() { const isDynamicBlock = findFuncBlock(previousComponentId, studyConfig.sequence); if (!isDynamicBlock) { - navigate(`/${studyId}/${encryptIndex(stepIndex)}${buildSearch(`${stepIndex}`)}`); + navigate(`/${studyId}/${encryptIndex(stepIndex)}${buildSearch()}`); return; } @@ -82,7 +67,7 @@ export function usePreviousStep() { }, -1); if (previousDynamicBlockIndex >= 0) { - navigate(`/${studyId}/${encryptIndex(stepIndex)}/${encryptIndex(previousDynamicBlockIndex)}${buildSearch(`${stepIndex}_${previousDynamicBlockIndex}`)}`); + navigate(`/${studyId}/${encryptIndex(stepIndex)}/${encryptIndex(previousDynamicBlockIndex)}${buildSearch()}`); return; } } diff --git a/src/utils/navigationSearch.spec.ts b/src/utils/navigationSearch.spec.ts new file mode 100644 index 0000000000..3c17b25cca --- /dev/null +++ b/src/utils/navigationSearch.spec.ts @@ -0,0 +1,21 @@ +import { + describe, + expect, + test, +} from 'vitest'; +import { removeCurrentTrialFromSearch } from './navigationSearch'; + +describe('removeCurrentTrialFromSearch', () => { + test('removes currentTrial and keeps other params', () => { + expect(removeCurrentTrialFromSearch('?participantId=p-1¤tTrial=task_3&revisitPageId=abc')) + .toBe('?participantId=p-1&revisitPageId=abc'); + }); + + test('returns an empty string when currentTrial is the only param', () => { + expect(removeCurrentTrialFromSearch('?currentTrial=task_3')).toBe(''); + }); + + test('leaves the search unchanged when currentTrial is absent', () => { + expect(removeCurrentTrialFromSearch('?participantId=p-1')).toBe('?participantId=p-1'); + }); +}); diff --git a/src/utils/navigationSearch.ts b/src/utils/navigationSearch.ts new file mode 100644 index 0000000000..f89424811b --- /dev/null +++ b/src/utils/navigationSearch.ts @@ -0,0 +1,11 @@ +export function removeSearchParam(search: string, key: string): string { + const params = new URLSearchParams(search); + params.delete(key); + + const nextSearch = params.toString(); + return nextSearch ? `?${nextSearch}` : ''; +} + +export function removeCurrentTrialFromSearch(search: string): string { + return removeSearchParam(search, 'currentTrial'); +} From e77cca1662f4ddb5d77b95773141b4cec0a98cc7 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Thu, 14 May 2026 00:11:04 -0600 Subject: [PATCH 31/41] Remove currentTrial query param plumbing --- .../thinkAloud/ThinkAloudAnalysis.tsx | 4 +- .../thinkAloud/ThinkAloudFooter.spec.ts | 8 ++-- .../thinkAloud/ThinkAloudFooter.tsx | 2 - .../thinkAloud/taskNavigation.ts | 5 +-- .../audioAnalysis/AudioProvenanceVis.tsx | 2 - src/components/interface/StepsPanel.tsx | 5 +-- src/routes/utils.tsx | 41 ++----------------- src/utils/navigationSearch.spec.ts | 21 ---------- src/utils/navigationSearch.ts | 11 ----- 9 files changed, 13 insertions(+), 86 deletions(-) delete mode 100644 src/utils/navigationSearch.spec.ts delete mode 100644 src/utils/navigationSearch.ts diff --git a/src/analysis/individualStudy/thinkAloud/ThinkAloudAnalysis.tsx b/src/analysis/individualStudy/thinkAloud/ThinkAloudAnalysis.tsx index 2834398ff8..5b0e9bda75 100644 --- a/src/analysis/individualStudy/thinkAloud/ThinkAloudAnalysis.tsx +++ b/src/analysis/individualStudy/thinkAloud/ThinkAloudAnalysis.tsx @@ -22,7 +22,6 @@ import { ThinkAloudFooter } from './ThinkAloudFooter'; import { useEvent } from '../../../store/hooks/useEvent'; import { FirebaseStorageEngine } from '../../../storage/engines/FirebaseStorageEngine'; import { ReplayContext, useReplay } from '../../../store/hooks/useReplay'; -import { removeCurrentTrialFromSearch } from '../../../utils/navigationSearch'; import { parseTrialOrder } from '../../../utils/parseTrialOrder'; async function getTranscript(storageEngine: FirebaseStorageEngine, partId: string | undefined, trialName: string | undefined, authEmail: string | null | undefined) { @@ -122,7 +121,6 @@ export function ThinkAloudAnalysis({ visibleParticipants, storageEngine } : { vi if (!participantId && visibleParticipants.length > 0) { setSearchParams((params) => { params.set('participantId', visibleParticipants[0].participantId); - params.delete('currentTrial'); return params; }); } @@ -144,7 +142,7 @@ export function ThinkAloudAnalysis({ visibleParticipants, storageEngine } : { vi return; } - navigate(`/analysis/stats/${studyId}/tagging/${encodeURIComponent(firstTrialIdentifier)}${removeCurrentTrialFromSearch(location.search)}`, { replace: true }); + navigate(`/analysis/stats/${studyId}/tagging/${encodeURIComponent(firstTrialIdentifier)}${location.search}`, { replace: true }); }, [currentTrial, location.search, navigate, participant, studyId]); const setEditedTranscript = useCallback((editedText: EditedText[]) => { diff --git a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.spec.ts b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.spec.ts index 1c879dbbcb..8d4a75e6d4 100644 --- a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.spec.ts +++ b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.spec.ts @@ -7,13 +7,13 @@ import { encryptIndex } from '../../../utils/encryptDecryptIndex'; import { buildTaskNavigationTarget } from './taskNavigation'; describe('buildTaskNavigationTarget', () => { - test('removes currentTrial when navigating between replay tasks', () => { + test('preserves search params when navigating between replay tasks', () => { const target = buildTaskNavigationTarget({ answerIdentifier: 'jupyterlite-task-2_8', trialOrder: '8', isReplay: true, studyId: 'buckaroo', - search: '?participantId=66aa582aba2b183e61577d44¤tTrial=jupyterlite-task-1_7', + search: '?participantId=66aa582aba2b183e61577d44', }); expect(target).toEqual({ @@ -22,13 +22,13 @@ describe('buildTaskNavigationTarget', () => { }); }); - test('removes currentTrial from analysis tagging navigation search', () => { + test('preserves search params when navigating to analysis tagging', () => { const target = buildTaskNavigationTarget({ answerIdentifier: 'task_4', trialOrder: '4', isReplay: false, studyId: 'study-1', - search: '?participantId=p-1¤tTrial=task_3', + search: '?participantId=p-1', }); expect(target).toEqual({ diff --git a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx index b7d3be80d7..7dc7b78c1e 100644 --- a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx +++ b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx @@ -221,7 +221,6 @@ export function ThinkAloudFooter({ setSearchParams((params) => { params.set('participantId', visibleParticipants[index] || ''); - params.delete('currentTrial'); return params; }); }, [participantId, setSearchParams, visibleParticipants]); @@ -454,7 +453,6 @@ export function ThinkAloudFooter({ onChange={(e: string | null) => { setSearchParams((params) => { params.set('participantId', e || ''); - params.delete('currentTrial'); return params; }); syncChannel.postMessage({ diff --git a/src/analysis/individualStudy/thinkAloud/taskNavigation.ts b/src/analysis/individualStudy/thinkAloud/taskNavigation.ts index c173694b0f..19e8fac04d 100644 --- a/src/analysis/individualStudy/thinkAloud/taskNavigation.ts +++ b/src/analysis/individualStudy/thinkAloud/taskNavigation.ts @@ -1,5 +1,4 @@ import { encryptIndex } from '../../../utils/encryptDecryptIndex'; -import { removeCurrentTrialFromSearch } from '../../../utils/navigationSearch'; import { parseTrialOrder } from '../../../utils/parseTrialOrder'; export function buildTaskNavigationTarget({ @@ -23,7 +22,7 @@ export function buildTaskNavigationTarget({ if (!isReplay) { return { pathname: `/analysis/stats/${studyId}/tagging/${encodeURIComponent(answerIdentifier)}`, - search: removeCurrentTrialFromSearch(search), + search, }; } @@ -31,6 +30,6 @@ export function buildTaskNavigationTarget({ pathname: funcIndex === null ? `/${studyId}/${encryptIndex(step)}` : `/${studyId}/${encryptIndex(step)}/${encryptIndex(funcIndex)}`, - search: removeCurrentTrialFromSearch(search), + search, }; } diff --git a/src/components/audioAnalysis/AudioProvenanceVis.tsx b/src/components/audioAnalysis/AudioProvenanceVis.tsx index 5a92c9dc6e..23586ee22b 100644 --- a/src/components/audioAnalysis/AudioProvenanceVis.tsx +++ b/src/components/audioAnalysis/AudioProvenanceVis.tsx @@ -134,7 +134,6 @@ export function AudioProvenanceVis({ const participantIdListener = (newId: string) => { setSearchParams((params) => { params.set('participantId', newId || ''); - params.delete('currentTrial'); return params; }); }; @@ -147,7 +146,6 @@ export function AudioProvenanceVis({ const params = new URLSearchParams(routerLocation.search); params.set('participantId', participantId || ''); - params.delete('currentTrial'); const search = params.toString(); if (context === 'provenanceVis') { diff --git a/src/components/interface/StepsPanel.tsx b/src/components/interface/StepsPanel.tsx index a8c968b921..54a43c223b 100644 --- a/src/components/interface/StepsPanel.tsx +++ b/src/components/interface/StepsPanel.tsx @@ -24,7 +24,6 @@ import type { Sequence, StoredAnswer } from '../../store/types'; import { addPathToComponentBlock } from '../../utils/getSequenceFlatMap'; import { useStudyId } from '../../routes/utils'; import { encryptIndex } from '../../utils/encryptDecryptIndex'; -import { removeCurrentTrialFromSearch } from '../../utils/navigationSearch'; import { isDynamicBlock } from '../../parser/utils'; import { componentAnswersAreCorrect } from '../../utils/correctAnswer'; import { studyComponentToIndividualComponent } from '../../utils/handleComponentInheritance'; @@ -746,9 +745,9 @@ export function StepsPanel({ onClick={() => { if (isComponent && href && !isExcluded) { if (isAnalysis) { - navigate(`/analysis/stats/${studyId}/stats/${encodeURIComponent(String(componentName))}${removeCurrentTrialFromSearch(location.search)}`); + navigate(`/analysis/stats/${studyId}/stats/${encodeURIComponent(String(componentName))}${location.search}`); } else { - navigate(`${href}${removeCurrentTrialFromSearch(location.search)}`); + navigate(`${href}${location.search}`); } } else if (!isComponent) { // Both included and excluded blocks can be collapsed/expanded diff --git a/src/routes/utils.tsx b/src/routes/utils.tsx index b5db9c7c46..81115dbeec 100644 --- a/src/routes/utils.tsx +++ b/src/routes/utils.tsx @@ -1,4 +1,4 @@ -import { useNavigate, useParams, useSearchParams } from 'react-router'; +import { useNavigate, useParams } from 'react-router'; import { useEffect, useMemo, useState, } from 'react'; @@ -7,8 +7,6 @@ import { useFlatSequence, useStoreActions, useStoreDispatch, useStoreSelector, } from '../store/store'; import { decryptIndex, encryptIndex } from '../utils/encryptDecryptIndex'; -import { removeCurrentTrialFromSearch } from '../utils/navigationSearch'; -import { parseTrialOrder } from '../utils/parseTrialOrder'; import { JumpFunctionParameters, JumpFunctionReturnVal } from '../store/types'; import { findFuncBlock } from '../utils/getSequenceFlatMap'; import { useStudyConfig } from '../store/hooks/useStudyConfig'; @@ -22,7 +20,6 @@ export function useStudyId(): string { export function useCurrentStep() { const { index } = useParams(); - const answers = useStoreSelector((state) => state.answers); const decrypted = useMemo(() => { if (index === undefined) { @@ -36,16 +33,6 @@ export function useCurrentStep() { return decryptIndex(index); }, [index]); - const [searchParams] = useSearchParams(); - - const currentTrial = searchParams.get('currentTrial') || ''; - const currentTrialOrder = currentTrial ? answers[currentTrial]?.trialOrder : undefined; - const { step: currentTrialStep } = parseTrialOrder(currentTrialOrder); - - if (currentTrial && currentTrialStep !== null) { - return currentTrialStep; - } - return decrypted; } @@ -60,7 +47,6 @@ const modules = import.meta.glob( export function useCurrentComponent(): string { const { funcIndex } = useParams(); const _answers = useStoreSelector((state) => state.answers); - const [searchParams] = useSearchParams(); const studyConfig = useStudyConfig(); const currentStep = useCurrentStep(); const flatSequence = useFlatSequence(); @@ -68,9 +54,6 @@ export function useCurrentComponent(): string { const studyId = useStudyId(); const storeDispatch = useStoreDispatch(); const { pushToFuncSequence } = useStoreActions(); - const currentTrial = searchParams.get('currentTrial') || ''; - const currentTrialOrder = currentTrial ? _answers[currentTrial]?.trialOrder : undefined; - const { step: currentTrialStep, funcIndex: currentTrialFuncIndex } = parseTrialOrder(currentTrialOrder); const [indexWhenSettingComponentName, setIndexWhenSettingComponentName] = useState(null); @@ -96,25 +79,9 @@ export function useCurrentComponent(): string { useEffect(() => { if (!funcIndex && nextFunc && typeof currentStep === 'number') { - navigate(`/${studyId}/${encryptIndex(currentStep)}/${encryptIndex(currentTrialStep === currentStep && currentTrialFuncIndex !== null ? currentTrialFuncIndex : 0)}${removeCurrentTrialFromSearch(window.location.search)}`); - } - }, [currentStep, currentTrialFuncIndex, currentTrialStep, funcIndex, navigate, nextFunc, studyId]); - - useEffect(() => { - if (typeof currentStep !== 'number' || currentTrialStep === null || currentTrialStep !== currentStep || !funcIndex) { - return; - } - - const decryptedFuncIndex = decryptIndex(funcIndex); - if (currentTrialFuncIndex === null) { - navigate(`/${studyId}/${encryptIndex(currentStep)}${removeCurrentTrialFromSearch(window.location.search)}`); - return; - } - - if (decryptedFuncIndex !== currentTrialFuncIndex) { - navigate(`/${studyId}/${encryptIndex(currentStep)}/${encryptIndex(currentTrialFuncIndex)}${removeCurrentTrialFromSearch(window.location.search)}`); + navigate(`/${studyId}/${encryptIndex(currentStep)}/${encryptIndex(0)}${window.location.search}`); } - }, [currentStep, currentTrialFuncIndex, currentTrialStep, funcIndex, navigate, studyId]); + }, [currentStep, funcIndex, navigate, nextFunc, studyId]); useEffect(() => { if (typeof currentStep === 'number') { @@ -159,7 +126,7 @@ export function useCurrentComponent(): string { setCompName('__dynamicLoading'); setIndexWhenSettingComponentName(null); - navigate(`/${studyId}/${encryptIndex(currentStep + 1)}${removeCurrentTrialFromSearch(window.location.search)}`); + navigate(`/${studyId}/${encryptIndex(currentStep + 1)}${window.location.search}`); } } } diff --git a/src/utils/navigationSearch.spec.ts b/src/utils/navigationSearch.spec.ts deleted file mode 100644 index 3c17b25cca..0000000000 --- a/src/utils/navigationSearch.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - describe, - expect, - test, -} from 'vitest'; -import { removeCurrentTrialFromSearch } from './navigationSearch'; - -describe('removeCurrentTrialFromSearch', () => { - test('removes currentTrial and keeps other params', () => { - expect(removeCurrentTrialFromSearch('?participantId=p-1¤tTrial=task_3&revisitPageId=abc')) - .toBe('?participantId=p-1&revisitPageId=abc'); - }); - - test('returns an empty string when currentTrial is the only param', () => { - expect(removeCurrentTrialFromSearch('?currentTrial=task_3')).toBe(''); - }); - - test('leaves the search unchanged when currentTrial is absent', () => { - expect(removeCurrentTrialFromSearch('?participantId=p-1')).toBe('?participantId=p-1'); - }); -}); diff --git a/src/utils/navigationSearch.ts b/src/utils/navigationSearch.ts deleted file mode 100644 index f89424811b..0000000000 --- a/src/utils/navigationSearch.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function removeSearchParam(search: string, key: string): string { - const params = new URLSearchParams(search); - params.delete(key); - - const nextSearch = params.toString(); - return nextSearch ? `?${nextSearch}` : ''; -} - -export function removeCurrentTrialFromSearch(search: string): string { - return removeSearchParam(search, 'currentTrial'); -} From a2b6f1ef56959ef0f1d8633108f41e763c9316a2 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Thu, 14 May 2026 00:12:22 -0600 Subject: [PATCH 32/41] Fix dynamic backward navigation --- src/store/hooks/useNextStep.spec.tsx | 15 +++++++++++++++ src/store/hooks/usePreviousStep.spec.tsx | 13 +++++++++++-- src/store/hooks/usePreviousStep.ts | 7 ++----- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/store/hooks/useNextStep.spec.tsx b/src/store/hooks/useNextStep.spec.tsx index 805f7228d7..5d68bfe4ac 100644 --- a/src/store/hooks/useNextStep.spec.tsx +++ b/src/store/hooks/useNextStep.spec.tsx @@ -223,4 +223,19 @@ describe('useNextStep', () => { expect(mockStoredAnswer.endTime).toBeGreaterThan(-1); expect(mockNavigate).toHaveBeenCalledTimes(2); }); + + test('preserves participant query params on next navigation', async () => { + mockSaveAnswers.mockResolvedValueOnce(undefined); + vi.stubGlobal('window', { + location: { search: '?participantId=p-1' }, + }); + + renderToStaticMarkup(); + + await capturedGoToNextStep?.(); + await Promise.resolve(); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate.mock.calls[0][0]).toContain('?participantId=p-1'); + }); }); diff --git a/src/store/hooks/usePreviousStep.spec.tsx b/src/store/hooks/usePreviousStep.spec.tsx index de16729487..8225aceb94 100644 --- a/src/store/hooks/usePreviousStep.spec.tsx +++ b/src/store/hooks/usePreviousStep.spec.tsx @@ -98,11 +98,20 @@ describe('usePreviousStep', () => { expect(capturedHook?.isPreviousDisabled).toBe(false); }); + test('keeps previous enabled for the first item inside a dynamic block', () => { + mockCurrentStep = 0; + mockFuncIndex = '1'; + + renderToStaticMarkup(); + + expect(capturedHook?.isPreviousDisabled).toBe(false); + }); + test('does not delete dynamic replay answers when moving backward in analysis replay', () => { mockIsAnalysis = true; mockFuncIndex = '1'; vi.stubGlobal('window', { - location: { search: '?participantId=p-1¤tTrial=dynamicBlock_1_component_1' }, + location: { search: '?participantId=p-1' }, }); renderToStaticMarkup(); @@ -120,7 +129,7 @@ describe('usePreviousStep', () => { intro_0: { trialOrder: '0' }, }; vi.stubGlobal('window', { - location: { search: '?participantId=p-1¤tTrial=end_2' }, + location: { search: '?participantId=p-1' }, }); renderToStaticMarkup(); diff --git a/src/store/hooks/usePreviousStep.ts b/src/store/hooks/usePreviousStep.ts index 70fd28ec45..9e2c6ce728 100644 --- a/src/store/hooks/usePreviousStep.ts +++ b/src/store/hooks/usePreviousStep.ts @@ -4,7 +4,6 @@ import { useCurrentStep, useStudyId } from '../../routes/utils'; import { useIsAnalysis } from './useIsAnalysis'; import { decryptIndex, encryptIndex } from '../../utils/encryptDecryptIndex'; import { parseTrialOrder } from '../../utils/parseTrialOrder'; -import { removeCurrentTrialFromSearch } from '../../utils/navigationSearch'; import { useStudyConfig } from './useStudyConfig'; import { getSequenceFlatMap, findFuncBlock } from '../../utils/getSequenceFlatMap'; import { useStoreDispatch, useStoreActions, useStoreSelector } from '../store'; @@ -21,11 +20,9 @@ export function usePreviousStep() { const answers = useStoreSelector((state) => state.answers); // Status of the previous button. If true, the previous button should be disabled - const isPreviousDisabled = typeof currentStep !== 'number' || currentStep <= 0; + const isPreviousDisabled = typeof currentStep !== 'number' || (currentStep <= 0 && (!funcIndex || decryptIndex(funcIndex) <= 0)); - const buildSearch = useCallback(() => ( - isAnalysis ? removeCurrentTrialFromSearch(window.location.search) : window.location.search - ), [isAnalysis]); + const buildSearch = useCallback(() => window.location.search, []); const goToPreviousStep = useCallback(() => { if (typeof currentStep !== 'number') { From 8e5726db2a0858af52018decf35f4b25f4ed3f30 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Thu, 14 May 2026 00:29:46 -0600 Subject: [PATCH 33/41] Refactor tests to preserve dynamic child routes from pathname and improve navigation assertions --- tests/replay-current-trial-dynamic.spec.ts | 24 +++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/tests/replay-current-trial-dynamic.spec.ts b/tests/replay-current-trial-dynamic.spec.ts index 291ef87f2b..83e6f7cee7 100644 --- a/tests/replay-current-trial-dynamic.spec.ts +++ b/tests/replay-current-trial-dynamic.spec.ts @@ -61,7 +61,7 @@ function getStudyRouteSegments(urlString: string) { }; } -test('syncs dynamic child route index from currentTrial query', async ({ page }) => { +test('preserves dynamic child route from the pathname', async ({ page }) => { await resetClientStudyState(page); await openStudyFromLanding(page, 'Demo Studies', 'Dynamic Blocks'); @@ -100,13 +100,18 @@ test('syncs dynamic child route index from currentTrial query', async ({ page }) const wrongChildUrl = new URL(page.url()); wrongChildUrl.pathname = `/${studySegment}/${stepSegment}/${firstFuncIndexSegment}`; - wrongChildUrl.searchParams.set('currentTrial', 'dynamicBlock_1_HSLColorCodes_1'); await page.goto(wrongChildUrl.toString()); + await expect.poll(() => new URL(page.url()).pathname).toBe(`/${studySegment}/${stepSegment}/${firstFuncIndexSegment}`); + + const secondChildUrl = new URL(page.url()); + secondChildUrl.pathname = `/${studySegment}/${stepSegment}/${secondFuncIndexSegment}`; + await page.goto(secondChildUrl.toString()); + await expect.poll(() => new URL(page.url()).pathname).toBe(`/${studySegment}/${stepSegment}/${secondFuncIndexSegment}`); }); -test('removes dynamic child route index for non-dynamic currentTrial query', async ({ page }) => { +test('preserves non-dynamic routes when navigating directly', async ({ page }) => { await resetClientStudyState(page); await openStudyFromLanding(page, 'Demo Studies', 'Dynamic Blocks'); @@ -129,11 +134,16 @@ test('removes dynamic child route index for non-dynamic currentTrial query', asy } const { stepSegment: dynamicStepSegment, funcIndexSegment: dynamicFuncIndexSegment } = dynamicRoute; - const wrongChildUrl = new URL(page.url()); - wrongChildUrl.pathname = `/${studySegment}/${dynamicStepSegment}/${dynamicFuncIndexSegment}`; - wrongChildUrl.searchParams.set('currentTrial', 'introduction_0'); - await page.goto(wrongChildUrl.toString()); + const introUrl = new URL(page.url()); + introUrl.pathname = `/${studySegment}/${introStepSegment}`; + await page.goto(introUrl.toString()); await expect.poll(() => new URL(page.url()).pathname).toBe(`/${studySegment}/${introStepSegment}`); await expect(page.getByText(/sample study.*dynamic blocks/i)).toBeVisible(); + + const dynamicUrl = new URL(page.url()); + dynamicUrl.pathname = `/${studySegment}/${dynamicStepSegment}/${dynamicFuncIndexSegment}`; + await page.goto(dynamicUrl.toString()); + + await expect.poll(() => new URL(page.url()).pathname).toBe(`/${studySegment}/${dynamicStepSegment}/${dynamicFuncIndexSegment}`); }); From 0a0717ae16569c74965f06c2f324113372cf2b6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 06:53:00 +0000 Subject: [PATCH 34/41] Initial plan From e3f2b46183fa945868336a955a91de96abaf10ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 07:09:26 +0000 Subject: [PATCH 35/41] feat: add component auto-advance timeout options Agent-Logs-Url: https://github.com/revisit-studies/study/sessions/85bdb0ee-0034-45cf-9535-f19f250395ff --- public/global.json | 27 +++-- public/test-component-timeout/config.json | 58 +++++++++++ src/components/NextButton.tsx | 40 +++++++- src/components/nextButtonTimeout.spec.ts | 51 +++++++++ src/components/nextButtonTimeout.ts | 43 ++++++++ src/parser/LibraryConfigSchema.json | 120 ++++++++++++++++++++++ src/parser/StudyConfigSchema.json | 120 ++++++++++++++++++++++ src/parser/parser.spec.ts | 49 +++++++++ src/parser/types.ts | 6 ++ src/store/hooks/useNextStep.spec.tsx | 116 ++++++++++++++------- src/store/hooks/useNextStep.ts | 6 +- tests/test-component-timeout.spec.ts | 21 ++++ 12 files changed, 607 insertions(+), 50 deletions(-) create mode 100644 public/test-component-timeout/config.json create mode 100644 src/components/nextButtonTimeout.spec.ts create mode 100644 src/components/nextButtonTimeout.ts create mode 100644 tests/test-component-timeout.spec.ts diff --git a/public/global.json b/public/global.json index b376204fb0..1b3c042210 100644 --- a/public/global.json +++ b/public/global.json @@ -49,10 +49,11 @@ "test-device-restriction", "test-library", "test-likert-matrix", - "test-parser-errors", - "test-randomization", - "test-skip-logic", - "test-step-logic" + "test-parser-errors", + "test-randomization", + "test-component-timeout", + "test-skip-logic", + "test-step-logic" ], "configs": { "tutorial": { @@ -210,13 +211,17 @@ "path": "test-parser-errors/config.json", "test": true }, - "test-randomization": { - "path": "test-randomization/config.json", - "test": true - }, - "test-skip-logic": { - "path": "test-skip-logic/config.json", - "test": true + "test-randomization": { + "path": "test-randomization/config.json", + "test": true + }, + "test-component-timeout": { + "path": "test-component-timeout/config.json", + "test": true + }, + "test-skip-logic": { + "path": "test-skip-logic/config.json", + "test": true }, "test-step-logic": { "path": "test-step-logic/config.json", diff --git a/public/test-component-timeout/config.json b/public/test-component-timeout/config.json new file mode 100644 index 0000000000..a60446807b --- /dev/null +++ b/public/test-component-timeout/config.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://raw.githubusercontent.com/revisit-studies/study/v2.4.2/src/parser/StudyConfigSchema.json", + "studyMetadata": { + "title": "Component Timeout Auto-Advance Test", + "version": "pilot", + "authors": [ + "The reVISit Team" + ], + "date": "2026-05-14", + "description": "A test study for component-level auto-advance timeouts.", + "organizations": [ + "The reVISit Team" + ] + }, + "uiConfig": { + "contactEmail": "contact@revisit.dev", + "logoPath": "revisitAssets/revisitLogoSquare.svg", + "withProgressBar": true, + "autoDownloadStudy": false, + "withSidebar": true, + "studyEndMsg": "Timeout auto-advance test complete." + }, + "baseComponents": { + "timed-question": { + "type": "questionnaire", + "instruction": "Do not answer this question. It should automatically advance.", + "response": [ + { + "id": "timeout-response", + "prompt": "Optional answer", + "location": "belowStimulus", + "type": "shortText", + "required": false + } + ], + "nextButtonAutoAdvanceTime": 2500, + "nextButtonAutoAdvanceWarningTime": 1500, + "nextButtonAutoAdvanceWarningMessage": "Custom timeout warning: advancing in {seconds} seconds without saving this component." + } + }, + "components": { + "introduction": { + "type": "questionnaire", + "instruction": "Press next to begin the timeout auto-advance test.", + "response": [] + }, + "timeout-question": { + "baseComponent": "timed-question" + } + }, + "sequence": { + "order": "fixed", + "components": [ + "introduction", + "timeout-question" + ] + } +} diff --git a/src/components/NextButton.tsx b/src/components/NextButton.tsx index eced74dbbe..b0d9d00a63 100644 --- a/src/components/NextButton.tsx +++ b/src/components/NextButton.tsx @@ -1,13 +1,18 @@ import { Alert, Button, Group } from '@mantine/core'; import { - JSX, useEffect, useMemo, useState, + JSX, useEffect, useMemo, useRef, useState, } from 'react'; import { IconInfoCircle, IconAlertTriangle } from '@tabler/icons-react'; import { useNavigate } from 'react-router'; import { useNextStep } from '../store/hooks/useNextStep'; -import { IndividualComponent, ResponseBlockLocation } from '../parser/types'; +import type { IndividualComponent, ResponseBlockLocation } from '../parser/types'; import { useStudyConfig } from '../store/hooks/useStudyConfig'; import { PreviousButton } from './PreviousButton'; +import { + DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE, + DEFAULT_AUTO_ADVANCE_WARNING_TIME, + getAutoAdvanceWarning, +} from './nextButtonTimeout'; type Props = { label?: string; @@ -30,8 +35,18 @@ export function NextButton({ const nextButtonDisableTime = useMemo(() => config?.nextButtonDisableTime ?? studyConfig.uiConfig.nextButtonDisableTime, [config, studyConfig]); const nextButtonEnableTime = useMemo(() => config?.nextButtonEnableTime ?? studyConfig.uiConfig.nextButtonEnableTime ?? 0, [config, studyConfig]); + const nextButtonAutoAdvanceTime = useMemo(() => config?.nextButtonAutoAdvanceTime, [config]); + const nextButtonAutoAdvanceWarningTime = useMemo( + () => config?.nextButtonAutoAdvanceWarningTime ?? DEFAULT_AUTO_ADVANCE_WARNING_TIME, + [config], + ); + const nextButtonAutoAdvanceWarningMessage = useMemo( + () => config?.nextButtonAutoAdvanceWarningMessage ?? DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE, + [config], + ); const [timer, setTimer] = useState(undefined); + const autoAdvanceTriggered = useRef(false); // Use Date.now() to keep time even if tab is hidden useEffect(() => { const start = Date.now(); @@ -53,6 +68,15 @@ export function NextButton({ } }, [nextButtonDisableTime, timer, navigate, studyConfig.uiConfig.timeoutReject]); + useEffect(() => { + if (timer === undefined || nextButtonAutoAdvanceTime === undefined || timer < nextButtonAutoAdvanceTime || autoAdvanceTriggered.current) { + return; + } + + autoAdvanceTriggered.current = true; + goToNextStep(false); + }, [goToNextStep, nextButtonAutoAdvanceTime, timer]); + const buttonTimerSatisfied = useMemo( () => { if (timer === undefined) { @@ -65,6 +89,13 @@ export function NextButton({ [nextButtonDisableTime, nextButtonEnableTime, timer], ); + const autoAdvanceWarning = useMemo(() => getAutoAdvanceWarning({ + timer, + autoAdvanceTime: nextButtonAutoAdvanceTime, + warningTime: nextButtonAutoAdvanceWarningTime, + warningMessage: nextButtonAutoAdvanceWarningMessage, + }), [nextButtonAutoAdvanceTime, nextButtonAutoAdvanceWarningMessage, nextButtonAutoAdvanceWarningTime, timer]); + const nextOnEnter = useMemo(() => config?.nextOnEnter ?? studyConfig.uiConfig.nextOnEnter, [config, studyConfig]); useEffect(() => { @@ -134,6 +165,11 @@ export function NextButton({ ))} + {autoAdvanceWarning && ( + }> + {autoAdvanceWarning.message} + + )} )} diff --git a/src/components/nextButtonTimeout.spec.ts b/src/components/nextButtonTimeout.spec.ts new file mode 100644 index 0000000000..c9fc970529 --- /dev/null +++ b/src/components/nextButtonTimeout.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'vitest'; +import { + DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE, + DEFAULT_AUTO_ADVANCE_WARNING_TIME, + formatAutoAdvanceWarningMessage, + getAutoAdvanceWarning, +} from './nextButtonTimeout'; + +describe('nextButtonTimeout', () => { + test('formats warning messages with the remaining seconds placeholder', () => { + expect(formatAutoAdvanceWarningMessage( + 'Custom timeout warning: advancing in {seconds} seconds without saving this component.', + 4, + )).toBe('Custom timeout warning: advancing in 4 seconds without saving this component.'); + }); + + test('shows the default warning as soon as a shorter auto-advance timer enters the default warning window', () => { + expect(getAutoAdvanceWarning({ + timer: 1000, + autoAdvanceTime: 25000, + })).toEqual({ + remainingTime: 24000, + message: `${DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE} 24 seconds remaining.`, + }); + }); + + test('suppresses the warning outside the configured warning window', () => { + expect(getAutoAdvanceWarning({ + timer: 500, + autoAdvanceTime: 5000, + warningTime: 1000, + warningMessage: 'Advancing in {seconds} seconds.', + })).toBeNull(); + }); + + test('uses the configured warning window', () => { + expect(getAutoAdvanceWarning({ + timer: 4000, + autoAdvanceTime: 5000, + warningTime: 1000, + warningMessage: 'Advancing in {seconds} seconds.', + })).toEqual({ + remainingTime: 1000, + message: 'Advancing in 1 seconds.', + }); + }); + + test('retains the documented default warning lead time', () => { + expect(DEFAULT_AUTO_ADVANCE_WARNING_TIME).toBe(30000); + }); +}); diff --git a/src/components/nextButtonTimeout.ts b/src/components/nextButtonTimeout.ts new file mode 100644 index 0000000000..aa3aecb6d2 --- /dev/null +++ b/src/components/nextButtonTimeout.ts @@ -0,0 +1,43 @@ +export const DEFAULT_AUTO_ADVANCE_WARNING_TIME = 30000; +export const DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE = 'You will be automatically advanced to the next component. Responses on this component will not be saved.'; + +export function formatAutoAdvanceWarningMessage(message: string, secondsRemaining: number) { + if (message.includes('{seconds}')) { + return message.replaceAll('{seconds}', String(secondsRemaining)); + } + + return `${message} ${secondsRemaining} second${secondsRemaining === 1 ? '' : 's'} remaining.`; +} + +export function getAutoAdvanceWarning({ + timer, + autoAdvanceTime, + warningTime = DEFAULT_AUTO_ADVANCE_WARNING_TIME, + warningMessage = DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE, +}: { + timer?: number; + autoAdvanceTime?: number; + warningTime?: number; + warningMessage?: string; +}) { + if ( + timer === undefined + || autoAdvanceTime === undefined + || warningTime <= 0 + ) { + return null; + } + + const remainingTime = autoAdvanceTime - timer; + if (remainingTime <= 0 || remainingTime > warningTime) { + return null; + } + + return { + remainingTime, + message: formatAutoAdvanceWarningMessage( + warningMessage, + Math.ceil(remainingTime / 1000), + ), + }; +} diff --git a/src/parser/LibraryConfigSchema.json b/src/parser/LibraryConfigSchema.json index f387dd16c2..f2a83bb53b 100644 --- a/src/parser/LibraryConfigSchema.json +++ b/src/parser/LibraryConfigSchema.json @@ -84,6 +84,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -948,6 +960,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -1211,6 +1235,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -1729,6 +1765,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -2287,6 +2335,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -2697,6 +2757,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -3503,6 +3575,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -3658,6 +3742,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -3821,6 +3917,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -3980,6 +4088,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" diff --git a/src/parser/StudyConfigSchema.json b/src/parser/StudyConfigSchema.json index 0d6a04b2db..e9c9b85e26 100644 --- a/src/parser/StudyConfigSchema.json +++ b/src/parser/StudyConfigSchema.json @@ -84,6 +84,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -1021,6 +1033,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -1284,6 +1308,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -1753,6 +1789,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -2311,6 +2359,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -2721,6 +2781,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -3847,6 +3919,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -4002,6 +4086,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -4165,6 +4261,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" @@ -4324,6 +4432,18 @@ "description": "The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig.", "type": "number" }, + "nextButtonAutoAdvanceTime": { + "description": "The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component.", + "type": "number" + }, + "nextButtonAutoAdvanceWarningMessage": { + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "type": "string" + }, + "nextButtonAutoAdvanceWarningTime": { + "description": "The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000.", + "type": "number" + }, "nextButtonEnableTime": { "description": "The time in milliseconds to wait before the next button is enabled. If present, will override the next button enable time setting in the uiConfig.", "type": "number" diff --git a/src/parser/parser.spec.ts b/src/parser/parser.spec.ts index 50c0cf02ae..009c67f50f 100644 --- a/src/parser/parser.spec.ts +++ b/src/parser/parser.spec.ts @@ -7,6 +7,55 @@ import { isDynamicBlock } from './utils'; // Mock the fetch function for library loading global.fetch = vi.fn(); +describe('Component auto-advance config parsing', () => { + test('accepts component-level auto-advance timeout options on a base component', async () => { + const studyConfig = { + $schema: '', + studyMetadata: { + title: 'Timeout Config Test', + version: '1.0', + authors: ['Test'], + date: '2026-05-14', + description: 'Ensures component timeout options are accepted.', + organizations: ['Test Org'], + }, + uiConfig: { + contactEmail: 'test@test.com', + helpTextPath: '', + logoPath: '', + withProgressBar: true, + autoDownloadStudy: false, + withSidebar: true, + }, + baseComponents: { + timedQuestion: { + type: 'questionnaire', + response: [], + nextButtonAutoAdvanceTime: 5000, + nextButtonAutoAdvanceWarningTime: 3000, + nextButtonAutoAdvanceWarningMessage: 'Advancing in {seconds} seconds.', + }, + }, + components: { + question1: { + baseComponent: 'timedQuestion', + }, + }, + sequence: { + order: 'fixed', + components: ['question1'], + }, + }; + + const result = await parseStudyConfig(JSON.stringify(studyConfig)); + + const hasAutoAdvanceFieldError = result.errors.some( + (error) => error.message?.includes('nextButtonAutoAdvance'), + ); + expect(hasAutoAdvanceFieldError).toBe(false); + }); +}); + describe('BaseComponent Macro Expansion', () => { describe('.co. macro expansion in baseComponent references', () => { test('expands .co. to .components. in baseComponent field', async () => { diff --git a/src/parser/types.ts b/src/parser/types.ts index 681a8d8b7e..f9124c14c5 100644 --- a/src/parser/types.ts +++ b/src/parser/types.ts @@ -1029,6 +1029,12 @@ export interface BaseIndividualComponent { nextButtonEnableTime?: number; /** The time in milliseconds to wait before the next button is disabled. If present, will override the next button disable time setting in the uiConfig. */ nextButtonDisableTime?: number; + /** The time in milliseconds after which the participant is automatically advanced to the next component without saving answers from the current component. */ + nextButtonAutoAdvanceTime?: number; + /** The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000. */ + nextButtonAutoAdvanceWarningTime?: number; + /** The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds. */ + nextButtonAutoAdvanceWarningMessage?: string; /** Whether to show the previous button. If present, will override the previous button setting in the uiConfig. */ previousButton?: boolean; /** The text that is displayed on the previous button. If present, will override the previous button text setting in the uiConfig. */ diff --git a/src/store/hooks/useNextStep.spec.tsx b/src/store/hooks/useNextStep.spec.tsx index 5d68bfe4ac..f37e8b2a8a 100644 --- a/src/store/hooks/useNextStep.spec.tsx +++ b/src/store/hooks/useNextStep.spec.tsx @@ -42,6 +42,18 @@ let mockStoredAnswer: { }; let mockAnswers: Record; +let mockSequence: { + id: string; + orderPath: string; + order: 'fixed'; + components: string[]; + skip: unknown[]; +}; +let mockFlatSequence: string[]; +let mockStudyConfig: { + components: Record; +}; +let mockTrialValidation: Record; let capturedGoToNextStep: ((collectData?: boolean) => Promise) | undefined; const mockDispatch = vi.fn((action) => { @@ -60,35 +72,9 @@ vi.mock('react-router', () => ({ })); vi.mock('../store', () => ({ - useStoreSelector: (selector: (state: { - trialValidation: Record; - sequence: { - id: string; - orderPath: string; - order: 'fixed'; - components: string[]; - skip: never[]; - }; - answers: Record; - modes: { dataCollectionEnabled: boolean }; - clickedPrevious: boolean; - }) => unknown) => selector({ - trialValidation: { - intro_0: { - response: { - values: { - response: 'saved-answer', - }, - }, - }, - }, - sequence: { - id: 'root', - orderPath: 'root', - order: 'fixed', - components: ['intro'], - skip: [], - }, + useStoreSelector: (selector: (state: Record) => unknown) => selector({ + trialValidation: mockTrialValidation, + sequence: mockSequence, answers: mockAnswers, modes: { dataCollectionEnabled: true }, clickedPrevious: false, @@ -102,7 +88,7 @@ vi.mock('../store', () => ({ }), useStoreDispatch: () => mockDispatch, useAreResponsesValid: () => true, - useFlatSequence: () => ['intro'], + useFlatSequence: () => mockFlatSequence, })); vi.mock('../../routes/utils', () => ({ @@ -128,17 +114,18 @@ vi.mock('./useWindowEvents', () => ({ })); vi.mock('./useStudyConfig', () => ({ - useStudyConfig: () => ({ - components: { - intro: {}, - }, - }), + useStudyConfig: () => mockStudyConfig, })); vi.mock('./useIsAnalysis', () => ({ useIsAnalysis: () => false, })); +vi.mock('../../utils/encryptDecryptIndex', () => ({ + encryptIndex: (value: number) => String(value), + decryptIndex: (value: string) => Number(value), +})); + vi.mock('../../utils/notifications', () => ({ showNotification: (...args: unknown[]) => mockShowNotification(...args), })); @@ -161,6 +148,28 @@ describe('useNextStep', () => { mockSetRankingAnswers.mockClear(); mockDispatch.mockClear(); mockAnswers = {}; + mockTrialValidation = { + intro_0: { + response: { + values: { + response: 'saved-answer', + }, + }, + }, + }; + mockSequence = { + id: 'root', + orderPath: 'root', + order: 'fixed', + components: ['intro'], + skip: [], + }; + mockFlatSequence = ['intro']; + mockStudyConfig = { + components: { + intro: {}, + }, + }; mockStoredAnswer = { answer: {}, componentName: 'intro', @@ -238,4 +247,41 @@ describe('useNextStep', () => { expect(mockNavigate).toHaveBeenCalledTimes(1); expect(mockNavigate.mock.calls[0][0]).toContain('?participantId=p-1'); }); + + test('marks timed out auto-advance answers as empty and does not use cleared answers for skip logic', async () => { + mockSaveAnswers.mockResolvedValueOnce(undefined); + mockSequence = { + id: 'root', + orderPath: 'root', + order: 'fixed', + components: ['intro', 'followup', 'skip-target'], + skip: [{ + name: 'intro', + check: 'response', + responseId: 'response', + comparison: 'equal', + value: 'saved-answer', + to: 'skip-target', + }], + }; + mockFlatSequence = ['intro', 'followup', 'skip-target']; + mockStudyConfig = { + components: { + intro: {}, + followup: {}, + 'skip-target': {}, + }, + }; + + renderToStaticMarkup(); + + await capturedGoToNextStep?.(false); + await Promise.resolve(); + + expect(mockSaveTrialAnswer).toHaveBeenCalledWith(expect.objectContaining({ + answer: {}, + timedOut: true, + })); + expect(mockNavigate).toHaveBeenCalledWith('/study-1/1'); + }); }); diff --git a/src/store/hooks/useNextStep.ts b/src/store/hooks/useNextStep.ts index 5365694339..a30748ace6 100644 --- a/src/store/hooks/useNextStep.ts +++ b/src/store/hooks/useNextStep.ts @@ -75,6 +75,7 @@ export function useNextStep() { }, {}) as StoredAnswer['answer'] : {}; const { provenanceGraph } = trialValidationCopy || {}; const endTime = Date.now(); + const answerToPersist = collectData ? answer : {}; // Get current window events. Splice empties the array and returns the removed elements, which handles clearing the array const currentWindowEvents = windowEvents && 'current' in windowEvents && windowEvents.current ? windowEvents.current.splice(0, windowEvents.current.length) : []; @@ -82,7 +83,7 @@ export function useNextStep() { if (dataCollectionEnabled && (storedAnswer.endTime === -1 || clickedPrevious)) { const toSave = { ...storedAnswer, - answer: collectData ? answer : {}, + answer: answerToPersist, startTime, endTime, provenanceGraph, @@ -125,11 +126,12 @@ export function useNextStep() { const answersWithNewAnswer = { ...answers, [identifier]: { - answer, + answer: answerToPersist, startTime, endTime, provenanceGraph, windowEvents: currentWindowEvents, + timedOut: !collectData, }, }; diff --git a/tests/test-component-timeout.spec.ts b/tests/test-component-timeout.spec.ts new file mode 100644 index 0000000000..56a12f2f1d --- /dev/null +++ b/tests/test-component-timeout.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from '@playwright/test'; +import { + nextClick, + openStudyFromLanding, + resetClientStudyState, + waitForStudyEndMessage, +} from './utils'; + +test('shows the timeout warning and auto-advances to the next component', async ({ page }) => { + await resetClientStudyState(page); + await openStudyFromLanding(page, 'Test Studies', 'Component Timeout Auto-Advance Test'); + + await expect(page.getByText('Press next to begin the timeout auto-advance test.')).toBeVisible(); + await nextClick(page); + + await expect(page.getByText('Do not answer this question. It should automatically advance.')).toBeVisible(); + await expect(page.getByText(/Custom timeout warning: advancing in \d+ seconds without saving this component\./)).toBeVisible({ timeout: 2500 }); + + await waitForStudyEndMessage(page); + await expect(page.getByText('Timeout auto-advance test complete.')).toBeVisible(); +}); From 1778c8fa150803088b00fd06c630e42f0d51da46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 07:12:41 +0000 Subject: [PATCH 36/41] test: refine timeout warning messaging Agent-Logs-Url: https://github.com/revisit-studies/study/sessions/85bdb0ee-0034-45cf-9535-f19f250395ff --- public/test-component-timeout/config.json | 2 +- src/components/nextButtonTimeout.spec.ts | 14 +++++++------- src/components/nextButtonTimeout.ts | 4 +++- src/parser/LibraryConfigSchema.json | 20 ++++++++++---------- src/parser/StudyConfigSchema.json | 20 ++++++++++---------- src/parser/parser.spec.ts | 2 +- src/parser/types.ts | 2 +- tests/test-component-timeout.spec.ts | 2 +- 8 files changed, 34 insertions(+), 32 deletions(-) diff --git a/public/test-component-timeout/config.json b/public/test-component-timeout/config.json index a60446807b..24cdb6da2a 100644 --- a/public/test-component-timeout/config.json +++ b/public/test-component-timeout/config.json @@ -35,7 +35,7 @@ ], "nextButtonAutoAdvanceTime": 2500, "nextButtonAutoAdvanceWarningTime": 1500, - "nextButtonAutoAdvanceWarningMessage": "Custom timeout warning: advancing in {seconds} seconds without saving this component." + "nextButtonAutoAdvanceWarningMessage": "Custom timeout warning: advancing in {seconds} {unit} without saving this component." } }, "components": { diff --git a/src/components/nextButtonTimeout.spec.ts b/src/components/nextButtonTimeout.spec.ts index c9fc970529..3610525d45 100644 --- a/src/components/nextButtonTimeout.spec.ts +++ b/src/components/nextButtonTimeout.spec.ts @@ -9,7 +9,7 @@ import { describe('nextButtonTimeout', () => { test('formats warning messages with the remaining seconds placeholder', () => { expect(formatAutoAdvanceWarningMessage( - 'Custom timeout warning: advancing in {seconds} seconds without saving this component.', + 'Custom timeout warning: advancing in {seconds} {unit} without saving this component.', 4, )).toBe('Custom timeout warning: advancing in 4 seconds without saving this component.'); }); @@ -29,19 +29,19 @@ describe('nextButtonTimeout', () => { timer: 500, autoAdvanceTime: 5000, warningTime: 1000, - warningMessage: 'Advancing in {seconds} seconds.', + warningMessage: 'Advancing in {seconds} {unit}.', })).toBeNull(); }); test('uses the configured warning window', () => { expect(getAutoAdvanceWarning({ - timer: 4000, + timer: 3000, autoAdvanceTime: 5000, - warningTime: 1000, - warningMessage: 'Advancing in {seconds} seconds.', + warningTime: 2500, + warningMessage: 'Advancing in {seconds} {unit}.', })).toEqual({ - remainingTime: 1000, - message: 'Advancing in 1 seconds.', + remainingTime: 2000, + message: 'Advancing in 2 seconds.', }); }); diff --git a/src/components/nextButtonTimeout.ts b/src/components/nextButtonTimeout.ts index aa3aecb6d2..1aa2f456df 100644 --- a/src/components/nextButtonTimeout.ts +++ b/src/components/nextButtonTimeout.ts @@ -3,7 +3,9 @@ export const DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE = 'You will be automatically a export function formatAutoAdvanceWarningMessage(message: string, secondsRemaining: number) { if (message.includes('{seconds}')) { - return message.replaceAll('{seconds}', String(secondsRemaining)); + return message + .replaceAll('{seconds}', String(secondsRemaining)) + .replaceAll('{unit}', secondsRemaining === 1 ? 'second' : 'seconds'); } return `${message} ${secondsRemaining} second${secondsRemaining === 1 ? '' : 's'} remaining.`; diff --git a/src/parser/LibraryConfigSchema.json b/src/parser/LibraryConfigSchema.json index f2a83bb53b..8fac4ee3f7 100644 --- a/src/parser/LibraryConfigSchema.json +++ b/src/parser/LibraryConfigSchema.json @@ -89,7 +89,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -965,7 +965,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -1240,7 +1240,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -1770,7 +1770,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -2340,7 +2340,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -2762,7 +2762,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -3580,7 +3580,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -3747,7 +3747,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -3922,7 +3922,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -4093,7 +4093,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { diff --git a/src/parser/StudyConfigSchema.json b/src/parser/StudyConfigSchema.json index e9c9b85e26..32b7f22927 100644 --- a/src/parser/StudyConfigSchema.json +++ b/src/parser/StudyConfigSchema.json @@ -89,7 +89,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -1038,7 +1038,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -1313,7 +1313,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -1794,7 +1794,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -2364,7 +2364,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -2786,7 +2786,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -3924,7 +3924,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -4091,7 +4091,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -4266,7 +4266,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { @@ -4437,7 +4437,7 @@ "type": "number" }, "nextButtonAutoAdvanceWarningMessage": { - "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds.", + "description": "The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`.", "type": "string" }, "nextButtonAutoAdvanceWarningTime": { diff --git a/src/parser/parser.spec.ts b/src/parser/parser.spec.ts index 009c67f50f..9155922756 100644 --- a/src/parser/parser.spec.ts +++ b/src/parser/parser.spec.ts @@ -33,7 +33,7 @@ describe('Component auto-advance config parsing', () => { response: [], nextButtonAutoAdvanceTime: 5000, nextButtonAutoAdvanceWarningTime: 3000, - nextButtonAutoAdvanceWarningMessage: 'Advancing in {seconds} seconds.', + nextButtonAutoAdvanceWarningMessage: 'Advancing in {seconds} {unit}.', }, }, components: { diff --git a/src/parser/types.ts b/src/parser/types.ts index f9124c14c5..5f5dc7b504 100644 --- a/src/parser/types.ts +++ b/src/parser/types.ts @@ -1033,7 +1033,7 @@ export interface BaseIndividualComponent { nextButtonAutoAdvanceTime?: number; /** The time in milliseconds before auto-advance when the warning message is shown. Defaults to 30000. */ nextButtonAutoAdvanceWarningTime?: number; - /** The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining seconds. */ + /** The warning message shown before auto-advance. Include `{seconds}` to interpolate the remaining number and `{unit}` to interpolate `second`/`seconds`. */ nextButtonAutoAdvanceWarningMessage?: string; /** Whether to show the previous button. If present, will override the previous button setting in the uiConfig. */ previousButton?: boolean; diff --git a/tests/test-component-timeout.spec.ts b/tests/test-component-timeout.spec.ts index 56a12f2f1d..916e3e79c3 100644 --- a/tests/test-component-timeout.spec.ts +++ b/tests/test-component-timeout.spec.ts @@ -14,7 +14,7 @@ test('shows the timeout warning and auto-advances to the next component', async await nextClick(page); await expect(page.getByText('Do not answer this question. It should automatically advance.')).toBeVisible(); - await expect(page.getByText(/Custom timeout warning: advancing in \d+ seconds without saving this component\./)).toBeVisible({ timeout: 2500 }); + await expect(page.getByText(/Custom timeout warning: advancing in \d+ second(?:s)? without saving this component\./)).toBeVisible({ timeout: 2500 }); await waitForStudyEndMessage(page); await expect(page.getByText('Timeout auto-advance test complete.')).toBeVisible(); From 5babc03e4257813fed457f5cb82c3da61c6bb40e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 07:15:04 +0000 Subject: [PATCH 37/41] fix: handle auto-advance warning placeholders consistently Agent-Logs-Url: https://github.com/revisit-studies/study/sessions/85bdb0ee-0034-45cf-9535-f19f250395ff --- src/components/nextButtonTimeout.spec.ts | 7 +++++++ src/components/nextButtonTimeout.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/nextButtonTimeout.spec.ts b/src/components/nextButtonTimeout.spec.ts index 3610525d45..0eacf1a983 100644 --- a/src/components/nextButtonTimeout.spec.ts +++ b/src/components/nextButtonTimeout.spec.ts @@ -14,6 +14,13 @@ describe('nextButtonTimeout', () => { )).toBe('Custom timeout warning: advancing in 4 seconds without saving this component.'); }); + test('formats the unit placeholder even when the message omits the numeric placeholder', () => { + expect(formatAutoAdvanceWarningMessage( + 'Custom timeout warning: the component will advance in one {unit}.', + 1, + )).toBe('Custom timeout warning: the component will advance in one second.'); + }); + test('shows the default warning as soon as a shorter auto-advance timer enters the default warning window', () => { expect(getAutoAdvanceWarning({ timer: 1000, diff --git a/src/components/nextButtonTimeout.ts b/src/components/nextButtonTimeout.ts index 1aa2f456df..c100731e43 100644 --- a/src/components/nextButtonTimeout.ts +++ b/src/components/nextButtonTimeout.ts @@ -2,7 +2,7 @@ export const DEFAULT_AUTO_ADVANCE_WARNING_TIME = 30000; export const DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE = 'You will be automatically advanced to the next component. Responses on this component will not be saved.'; export function formatAutoAdvanceWarningMessage(message: string, secondsRemaining: number) { - if (message.includes('{seconds}')) { + if (message.includes('{seconds}') || message.includes('{unit}')) { return message .replaceAll('{seconds}', String(secondsRemaining)) .replaceAll('{unit}', secondsRemaining === 1 ? 'second' : 'seconds'); From f7b45d54ca843ebd4998a80dbb3a99586c95e267 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Thu, 14 May 2026 21:29:02 -0600 Subject: [PATCH 38/41] refactor: simplify NextButton and useNextStep hooks - Remove unnecessary useMemo calls for trivial config value reads in NextButton (nextButtonDisableTime, nextButtonEnableTime, nextOnEnter, previousButtonText) - Remove unnecessary useMemo for modes.dataCollectionEnabled destructuring in useNextStep - Fix consistent-return lint error by unconditionally returning cleanup function in nextOnEnter useEffect - Fix prefer-destructuring lint warning in useNextStep --- src/components/NextButton.tsx | 30 +++++++++++------------------- src/store/hooks/useNextStep.ts | 2 +- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/src/components/NextButton.tsx b/src/components/NextButton.tsx index b0d9d00a63..3ffeab5f88 100644 --- a/src/components/NextButton.tsx +++ b/src/components/NextButton.tsx @@ -33,17 +33,11 @@ export function NextButton({ const studyConfig = useStudyConfig(); const navigate = useNavigate(); - const nextButtonDisableTime = useMemo(() => config?.nextButtonDisableTime ?? studyConfig.uiConfig.nextButtonDisableTime, [config, studyConfig]); - const nextButtonEnableTime = useMemo(() => config?.nextButtonEnableTime ?? studyConfig.uiConfig.nextButtonEnableTime ?? 0, [config, studyConfig]); - const nextButtonAutoAdvanceTime = useMemo(() => config?.nextButtonAutoAdvanceTime, [config]); - const nextButtonAutoAdvanceWarningTime = useMemo( - () => config?.nextButtonAutoAdvanceWarningTime ?? DEFAULT_AUTO_ADVANCE_WARNING_TIME, - [config], - ); - const nextButtonAutoAdvanceWarningMessage = useMemo( - () => config?.nextButtonAutoAdvanceWarningMessage ?? DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE, - [config], - ); + const nextButtonDisableTime = config?.nextButtonDisableTime ?? studyConfig.uiConfig.nextButtonDisableTime; + const nextButtonEnableTime = config?.nextButtonEnableTime ?? studyConfig.uiConfig.nextButtonEnableTime ?? 0; + const nextButtonAutoAdvanceTime = config?.nextButtonAutoAdvanceTime; + const nextButtonAutoAdvanceWarningTime = config?.nextButtonAutoAdvanceWarningTime ?? DEFAULT_AUTO_ADVANCE_WARNING_TIME; + const nextButtonAutoAdvanceWarningMessage = config?.nextButtonAutoAdvanceWarningMessage ?? DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE; const [timer, setTimer] = useState(undefined); const autoAdvanceTriggered = useRef(false); @@ -96,7 +90,7 @@ export function NextButton({ warningMessage: nextButtonAutoAdvanceWarningMessage, }), [nextButtonAutoAdvanceTime, nextButtonAutoAdvanceWarningMessage, nextButtonAutoAdvanceWarningTime, timer]); - const nextOnEnter = useMemo(() => config?.nextOnEnter ?? studyConfig.uiConfig.nextOnEnter, [config, studyConfig]); + const nextOnEnter = config?.nextOnEnter ?? studyConfig.uiConfig.nextOnEnter; useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -107,15 +101,14 @@ export function NextButton({ if (nextOnEnter) { window.addEventListener('keydown', handleKeyDown); - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; } - return () => {}; + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; }, [disabled, isNextDisabled, buttonTimerSatisfied, goToNextStep, nextOnEnter]); - const nextButtonDisabled = useMemo(() => disabled || isNextDisabled || !buttonTimerSatisfied, [disabled, isNextDisabled, buttonTimerSatisfied]); - const previousButtonText = useMemo(() => config?.previousButtonText ?? studyConfig.uiConfig.previousButtonText ?? 'Previous', [config, studyConfig]); + const nextButtonDisabled = disabled || isNextDisabled || !buttonTimerSatisfied; + const previousButtonText = config?.previousButtonText ?? studyConfig.uiConfig.previousButtonText ?? 'Previous'; return ( <> @@ -171,7 +164,6 @@ export function NextButton({ )} - )} ); diff --git a/src/store/hooks/useNextStep.ts b/src/store/hooks/useNextStep.ts index a30748ace6..ec6331336a 100644 --- a/src/store/hooks/useNextStep.ts +++ b/src/store/hooks/useNextStep.ts @@ -44,7 +44,7 @@ export function useNextStep() { const studyId = useStudyId(); - const dataCollectionEnabled = useMemo(() => modes.dataCollectionEnabled, [modes]); + const { dataCollectionEnabled } = modes; const areResponsesValid = useAreResponsesValid(identifier); From 6e9bf73a9381f62c2d87a3d16194593180740665 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Thu, 14 May 2026 22:11:56 -0600 Subject: [PATCH 39/41] fix: correct E2E test setup for timeout component study - Use correct tab name 'Tests' instead of 'Test Studies' - Activate the Tests tab before opening the study - Handle custom studyEndMsg instead of relying on default message - Simplify study card locator to avoid strict mode violation --- tests/test-component-timeout.spec.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/test-component-timeout.spec.ts b/tests/test-component-timeout.spec.ts index 916e3e79c3..8fc2e70697 100644 --- a/tests/test-component-timeout.spec.ts +++ b/tests/test-component-timeout.spec.ts @@ -1,14 +1,17 @@ import { test, expect } from '@playwright/test'; -import { - nextClick, - openStudyFromLanding, - resetClientStudyState, - waitForStudyEndMessage, -} from './utils'; +import { nextClick, resetClientStudyState } from './utils'; test('shows the timeout warning and auto-advances to the next component', async ({ page }) => { await resetClientStudyState(page); - await openStudyFromLanding(page, 'Test Studies', 'Component Timeout Auto-Advance Test'); + await page.goto('/'); + await page.getByRole('tab', { name: 'Tests' }).click(); + await page + .getByLabel('Tests') + .locator('div') + .filter({ hasText: 'Component Timeout Auto-Advance Test' }) + .getByText('Go to Study') + .first() + .click(); await expect(page.getByText('Press next to begin the timeout auto-advance test.')).toBeVisible(); await nextClick(page); @@ -16,6 +19,5 @@ test('shows the timeout warning and auto-advances to the next component', async await expect(page.getByText('Do not answer this question. It should automatically advance.')).toBeVisible(); await expect(page.getByText(/Custom timeout warning: advancing in \d+ second(?:s)? without saving this component\./)).toBeVisible({ timeout: 2500 }); - await waitForStudyEndMessage(page); await expect(page.getByText('Timeout auto-advance test complete.')).toBeVisible(); }); From a2099fd7b8e73dbf2295afd38d1075579d4a2ac6 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 20 May 2026 21:55:30 -0600 Subject: [PATCH 40/41] Address feedback from review --- src/components/NextButton.spec.tsx | 137 +++++++++++++++++++++++++++ src/components/NextButton.tsx | 7 +- src/store/hooks/useNextStep.spec.tsx | 47 +++++++++ src/store/hooks/useNextStep.ts | 7 +- 4 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 src/components/NextButton.spec.tsx diff --git a/src/components/NextButton.spec.tsx b/src/components/NextButton.spec.tsx new file mode 100644 index 0000000000..cac7258d16 --- /dev/null +++ b/src/components/NextButton.spec.tsx @@ -0,0 +1,137 @@ +/** @vitest-environment jsdom */ + +import { act, type ReactNode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { + afterEach, + beforeEach, + describe, + expect, + test, + vi, +} from 'vitest'; +import type { IndividualComponent } from '../parser/types'; +import { NextButton } from './NextButton'; + +const mockNavigate = vi.fn(); +const mockGoToNextStep = vi.fn(); + +let mockIdentifier = 'intro_0'; + +vi.mock('@mantine/core', () => ({ + Alert: ({ children }: { children: ReactNode }) =>
      {children}
      , + Button: ({ + children, + disabled, + onClick, + }: { + children: ReactNode; + disabled?: boolean; + onClick?: () => void; + }) => ( + + ), + Group: ({ children }: { children: ReactNode }) =>
      {children}
      , +})); + +vi.mock('@tabler/icons-react', () => ({ + IconInfoCircle: () => null, + IconAlertTriangle: () => null, +})); + +vi.mock('react-router', () => ({ + useNavigate: () => mockNavigate, +})); + +vi.mock('../store/hooks/useNextStep', () => ({ + useNextStep: () => ({ + isNextDisabled: false, + goToNextStep: mockGoToNextStep, + }), +})); + +vi.mock('../store/hooks/useStudyConfig', () => ({ + useStudyConfig: () => ({ + uiConfig: { + nextOnEnter: false, + timeoutReject: false, + }, + }), +})); + +vi.mock('../routes/utils', () => ({ + useCurrentIdentifier: () => mockIdentifier, +})); + +vi.mock('./PreviousButton', () => ({ + PreviousButton: () => null, +})); + +describe('NextButton', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + mockIdentifier = 'intro_0'; + mockNavigate.mockReset(); + mockGoToNextStep.mockReset(); + vi.useFakeTimers(); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + delete (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + }); + + test('resets auto-advance state when the current identifier changes', () => { + const config = { + type: 'questionnaire', + response: [], + nextButtonAutoAdvanceTime: 1000, + } as unknown as IndividualComponent; + + act(() => { + root.render( + , + ); + }); + + act(() => { + vi.advanceTimersByTime(1100); + }); + + expect(mockGoToNextStep).toHaveBeenCalledTimes(1); + expect(mockGoToNextStep).toHaveBeenLastCalledWith(false); + + mockIdentifier = 'intro_0_followup_1'; + + act(() => { + root.render( + , + ); + }); + + act(() => { + vi.advanceTimersByTime(1100); + }); + + expect(mockGoToNextStep).toHaveBeenCalledTimes(2); + expect(mockGoToNextStep).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/src/components/NextButton.tsx b/src/components/NextButton.tsx index 3ffeab5f88..53a4829bcb 100644 --- a/src/components/NextButton.tsx +++ b/src/components/NextButton.tsx @@ -7,6 +7,7 @@ import { useNavigate } from 'react-router'; import { useNextStep } from '../store/hooks/useNextStep'; import type { IndividualComponent, ResponseBlockLocation } from '../parser/types'; import { useStudyConfig } from '../store/hooks/useStudyConfig'; +import { useCurrentIdentifier } from '../routes/utils'; import { PreviousButton } from './PreviousButton'; import { DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE, @@ -32,6 +33,7 @@ export function NextButton({ const { isNextDisabled, goToNextStep } = useNextStep(); const studyConfig = useStudyConfig(); const navigate = useNavigate(); + const identifier = useCurrentIdentifier(); const nextButtonDisableTime = config?.nextButtonDisableTime ?? studyConfig.uiConfig.nextButtonDisableTime; const nextButtonEnableTime = config?.nextButtonEnableTime ?? studyConfig.uiConfig.nextButtonEnableTime ?? 0; @@ -41,8 +43,9 @@ export function NextButton({ const [timer, setTimer] = useState(undefined); const autoAdvanceTriggered = useRef(false); - // Use Date.now() to keep time even if tab is hidden + // Use the current identifier so nested function-sequence items reset their timer state. useEffect(() => { + autoAdvanceTriggered.current = false; const start = Date.now(); setTimer(0); const interval = setInterval(() => { @@ -51,7 +54,7 @@ export function NextButton({ return () => { clearInterval(interval); }; - }, []); + }, [identifier]); useEffect(() => { if (timer === undefined) { diff --git a/src/store/hooks/useNextStep.spec.tsx b/src/store/hooks/useNextStep.spec.tsx index f37e8b2a8a..31118c741d 100644 --- a/src/store/hooks/useNextStep.spec.tsx +++ b/src/store/hooks/useNextStep.spec.tsx @@ -284,4 +284,51 @@ describe('useNextStep', () => { })); expect(mockNavigate).toHaveBeenCalledWith('/study-1/1'); }); + + test('excludes timed out answers from block skip conditions', async () => { + mockSaveAnswers.mockResolvedValueOnce(undefined); + mockSequence = { + id: 'root', + orderPath: 'root', + order: 'fixed', + components: ['intro', 'followup', 'skip-target'], + skip: [{ + check: 'block', + condition: 'numIncorrect', + value: 1, + to: 'skip-target', + }], + }; + mockFlatSequence = ['intro', 'followup', 'skip-target']; + mockStudyConfig = { + components: { + intro: { + type: 'questionnaire', + response: [{ + id: 'response', + type: 'radio', + prompt: 'Pick one', + options: ['saved-answer', 'other-answer'], + }], + correctAnswer: [{ + id: 'response', + answer: 'saved-answer', + }], + }, + followup: {}, + 'skip-target': {}, + }, + }; + + renderToStaticMarkup(); + + await capturedGoToNextStep?.(false); + await Promise.resolve(); + + expect(mockSaveTrialAnswer).toHaveBeenCalledWith(expect.objectContaining({ + answer: {}, + timedOut: true, + })); + expect(mockNavigate).toHaveBeenCalledWith('/study-1/1'); + }); }); diff --git a/src/store/hooks/useNextStep.ts b/src/store/hooks/useNextStep.ts index ec6331336a..ca5dd0c28c 100644 --- a/src/store/hooks/useNextStep.ts +++ b/src/store/hooks/useNextStep.ts @@ -134,6 +134,9 @@ export function useNextStep() { timedOut: !collectData, }, }; + const answersForSkipEvaluation = Object.fromEntries( + Object.entries(answersWithNewAnswer).filter(([_, responseObj]) => !responseObj.timedOut), + ) as typeof answersWithNewAnswer; // Check if the skip block should be triggered if (hasSkipBlock) { @@ -145,10 +148,10 @@ export function useNextStep() { skipConditions.some((condition) => { let conditionIsTriggered = false; - const validationCandidates = Object.fromEntries(Object.entries(answersWithNewAnswer).filter(([key]) => { + const validationCandidates = Object.fromEntries(Object.entries(answersForSkipEvaluation).filter(([key]) => { const componentIndex = parseInt(key.slice(key.lastIndexOf('_') + 1), 10); return componentIndex >= condition.firstIndex && componentIndex <= currentStep; - })) as unknown as StoredAnswer; + })) as Record; // Slim down the validationCandidates to only include the skip condition's component const componentsToCheck = condition.check !== 'block' ? Object.entries(validationCandidates).filter(([key]) => key.slice(0, key.lastIndexOf('_')) === condition.name) : Object.entries(validationCandidates); From 3af76fb3fb87a579e15d8e2b86322cbb02c24ad0 Mon Sep 17 00:00:00 2001 From: ZachCutler04 Date: Fri, 22 May 2026 12:53:34 -0700 Subject: [PATCH 41/41] Separating provenance data from answers (#1230) * moving provenance * ensuring fallback works * Persist provenance as separate assets * Add provenance bulk download export * Ignore coverage output * Fix type error in test --------- Co-authored-by: Jack Wilburn --- .gitignore | 2 + .../individualStudy/summary/utils.test.ts | 6 - .../thinkAloud/ThinkAloudFooter.tsx | 32 ++++- .../audioAnalysis/AudioProvenanceVis.tsx | 68 +++++++--- .../audioAnalysis/TaskProvenanceTimeline.tsx | 49 +++---- src/components/downloader/DownloadButtons.tsx | 33 ++++- src/storage/engines/types.ts | 99 +++++++++++++- .../utils/participantDataRecovery.spec.ts | 6 - src/storage/tests/highLevel.spec.ts | 100 ++++++++++++-- src/storage/types.ts | 8 +- src/store/hooks/useNextStep.spec.tsx | 15 +-- src/store/hooks/useNextStep.ts | 14 +- src/store/provenance.spec.ts | 51 ++++++++ src/store/provenance.ts | 82 ++++++++++++ src/store/store.tsx | 12 -- src/store/types.ts | 5 +- .../handleDownloadFiles.provenance.spec.ts | 122 ++++++++++++++++++ src/utils/handleDownloadFiles.ts | 77 +++++++++++ 18 files changed, 667 insertions(+), 114 deletions(-) create mode 100644 src/store/provenance.spec.ts create mode 100644 src/store/provenance.ts create mode 100644 src/utils/handleDownloadFiles.provenance.spec.ts diff --git a/.gitignore b/.gitignore index 26dd272055..0289daf5b5 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ supabase/volumes/* !supabase/volumes/db/ supabase/volumes/db/data !supabase/volumes/api/ + +coverage/ diff --git a/src/analysis/individualStudy/summary/utils.test.ts b/src/analysis/individualStudy/summary/utils.test.ts index 57a21812a7..32fb79a54a 100644 --- a/src/analysis/individualStudy/summary/utils.test.ts +++ b/src/analysis/individualStudy/summary/utils.test.ts @@ -99,12 +99,6 @@ function createMockAnswer(overrides: { incorrectAnswers: {}, startTime: overrides.startTime, endTime: overrides.endTime, - provenanceGraph: { - sidebar: undefined, - aboveStimulus: undefined, - belowStimulus: undefined, - stimulus: undefined, - }, windowEvents: [], timedOut: false, helpButtonClickedCount: 0, diff --git a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx index 7dc7b78c1e..52d431e27b 100644 --- a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx +++ b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx @@ -36,6 +36,7 @@ import { buildProvenanceLegendEntries, } from '../../../components/audioAnalysis/provenanceColors'; import { revisitPageId, syncChannel } from '../../../utils/syncReplay'; +import { getLegacyStoredAnswerProvenance } from '../../../store/provenance'; import { buildTaskNavigationTarget } from './taskNavigation'; const margin = { @@ -50,6 +51,29 @@ function getParticipantData(trrackId: string | undefined, storageEngine: Storage return null; } +function getLegacyProvenance(answer: unknown) { + return getLegacyStoredAnswerProvenance(answer); +} + +async function getTaskProvenance( + storageEngine: StorageEngine | undefined, + participantId: string, + currentTrial: string, + answer: unknown, +) { + const legacyProvenance = getLegacyProvenance(answer); + + if (!storageEngine || !participantId || !currentTrial) { + return legacyProvenance; + } + + try { + return await storageEngine.getProvenance(currentTrial, participantId) ?? legacyProvenance; + } catch { + return legacyProvenance; + } +} + async function getParticipantTags(authEmail: string, trrackId: string | undefined, studyId: string, storageEngine: StorageEngine | undefined) { if (storageEngine && trrackId) { return (await storageEngine.getAllParticipantAndTaskTags(authEmail, trrackId)); @@ -84,6 +108,7 @@ export function ThinkAloudFooter({ const participantId = useMemo(() => searchParams.get('participantId') || '', [searchParams]); const { value: participant } = useAsync(getParticipantData, [participantId, storageEngine]); + const { value: provenanceGraph } = useAsync(getTaskProvenance, [storageEngine, participantId, currentTrial, participant?.answers[currentTrial]]); const { value: taskTags, execute: pullTags } = useAsync(getTags, [storageEngine, 'task']); @@ -345,13 +370,12 @@ export function ThinkAloudFooter({ const [timeString, setTimeString] = useState(''); const provenanceLegendEntries = useMemo(() => { - const answer = participant?.answers[currentTrial]; - if (!answer?.provenanceGraph) { + if (!provenanceGraph) { return new Map(); } - return buildProvenanceLegendEntries(Object.values(answer.provenanceGraph)); - }, [participant, currentTrial]); + return buildProvenanceLegendEntries(Object.values(provenanceGraph)); + }, [provenanceGraph]); const tasksList = useMemo(() => orderedAnswers .filter((answer) => answer.identifier && answer.componentName) diff --git a/src/components/audioAnalysis/AudioProvenanceVis.tsx b/src/components/audioAnalysis/AudioProvenanceVis.tsx index 23586ee22b..456dbc281a 100644 --- a/src/components/audioAnalysis/AudioProvenanceVis.tsx +++ b/src/components/audioAnalysis/AudioProvenanceVis.tsx @@ -26,6 +26,8 @@ import { parseTrialOrder } from '../../utils/parseTrialOrder'; import { useUpdateProvenance } from './useUpdateProvenance'; import { useReplayContext } from '../../store/hooks/useReplay'; import { syncChannel, syncEmitter } from '../../utils/syncReplay'; +import type { StoredProvenance } from '../../store/types'; +import { getLegacyStoredAnswerProvenance } from '../../store/provenance'; const margin = { left: 20, top: 0, right: 20, bottom: 0, @@ -62,6 +64,12 @@ export function AudioProvenanceVis({ } = useReplayContext(); const { storageEngine } = useStorageEngine(); + const legacyProvenanceGraph = useMemo( + () => getLegacyStoredAnswerProvenance(answers[taskName]), + [answers, taskName], + ); + const [storedProvenanceGraph, setStoredProvenanceGraph] = useState(legacyProvenanceGraph); + const provenanceGraph = storedProvenanceGraph ?? legacyProvenanceGraph; const [analysisHasAudio, _setAnalysisHasAudio] = useState(true); @@ -97,8 +105,36 @@ export function AudioProvenanceVis({ const trrackForTrial = useRef | null>(null); + useEffect(() => { + let canceled = false; + + async function fetchProvenance() { + if (!taskName || !participantId || !storageEngine) { + setStoredProvenanceGraph(legacyProvenanceGraph); + return; + } + + try { + const storedProvenance = await storageEngine.getProvenance(taskName, participantId); + if (!canceled) { + setStoredProvenanceGraph(storedProvenance ?? legacyProvenanceGraph); + } + } catch { + if (!canceled) { + setStoredProvenanceGraph(legacyProvenanceGraph); + } + } + } + + fetchProvenance(); + + return () => { + canceled = true; + }; + }, [legacyProvenanceGraph, participantId, storageEngine, taskName]); + const _setCurrentResponseNodes = useEvent((node: string | null, location: ResponseBlockLocation) => { - const graph = answers[taskName]?.provenanceGraph[location]; + const graph = provenanceGraph?.[location]; if (graph && node) { if (!currentGlobalNode || graph.nodes[node].createdOn > currentGlobalNode.time || playTime < currentGlobalNode.time) { setCurrentGlobalNode({ name: node || '', time: graph.nodes[node].createdOn }); @@ -176,26 +212,28 @@ export function AudioProvenanceVis({ }; }, [answers, context, navigate, participantId, routerLocation.search, setSearchParams, studyId]); - useUpdateProvenance('aboveStimulus', playTime, answers[taskName]?.provenanceGraph.aboveStimulus, currentResponseNodes.aboveStimulus, _setCurrentResponseNodes, saveProvenance); + useUpdateProvenance('aboveStimulus', playTime, provenanceGraph?.aboveStimulus, currentResponseNodes.aboveStimulus, _setCurrentResponseNodes, saveProvenance); - useUpdateProvenance('belowStimulus', playTime, answers[taskName]?.provenanceGraph.belowStimulus, currentResponseNodes.belowStimulus, _setCurrentResponseNodes, saveProvenance); + useUpdateProvenance('belowStimulus', playTime, provenanceGraph?.belowStimulus, currentResponseNodes.belowStimulus, _setCurrentResponseNodes, saveProvenance); - useUpdateProvenance('sidebar', playTime, answers[taskName]?.provenanceGraph.sidebar, currentResponseNodes.sidebar, _setCurrentResponseNodes, saveProvenance); + useUpdateProvenance('sidebar', playTime, provenanceGraph?.sidebar, currentResponseNodes.sidebar, _setCurrentResponseNodes, saveProvenance); // Create an instance of trrack to ensure getState works, incase the saved state is not a full state node. useEffect(() => { - if (taskName && answers[taskName]?.provenanceGraph) { + trrackForTrial.current = null; + + if (taskName && provenanceGraph) { const reg = Registry.create(); const trrack = initializeTrrack({ registry: reg, initialState: {} }); - if (answers[taskName]?.provenanceGraph.stimulus) { - trrack.importObject(structuredClone(answers[taskName]?.provenanceGraph!.stimulus)); + if (provenanceGraph.stimulus) { + trrack.importObject(structuredClone(provenanceGraph.stimulus)); trrackForTrial.current = trrack; } } - }, [answers, taskName]); + }, [provenanceGraph, taskName]); const _setCurrentNode = useCallback((node: string | undefined) => { if (!node) { @@ -203,21 +241,21 @@ export function AudioProvenanceVis({ } if (taskName && trrackForTrial.current && context === 'provenanceVis' && saveProvenance) { - saveProvenance({ prov: trrackForTrial.current.getState(answers[taskName]?.provenanceGraph.stimulus?.nodes[node]), location: 'stimulus' }); + saveProvenance({ prov: trrackForTrial.current.getState(provenanceGraph?.stimulus?.nodes[node]), location: 'stimulus' }); trrackForTrial.current.to(node); } _setCurrentResponseNodes(node, 'stimulus'); setCurrentNode(node); - }, [taskName, context, _setCurrentResponseNodes, saveProvenance, answers]); + }, [taskName, context, _setCurrentResponseNodes, saveProvenance, provenanceGraph]); // use effect to control the current provenance node based on the changing playtime. useEffect(() => { - if (!taskName || !trrackForTrial.current || !answers[taskName]?.provenanceGraph) { + if (!taskName || !trrackForTrial.current || !provenanceGraph) { return; } - const provGraph = answers[taskName]?.provenanceGraph; + const provGraph = provenanceGraph; if (!provGraph.stimulus) { return; @@ -249,7 +287,7 @@ export function AudioProvenanceVis({ if (tempNode.id !== currentNode) { _setCurrentNode(tempNode.id); } - }, [_setCurrentNode, currentNode, participantId, playTime, taskName, answers]); + }, [_setCurrentNode, currentNode, participantId, playTime, taskName, provenanceGraph]); useEffect(() => { if (duration === 0) { @@ -353,13 +391,13 @@ export function AudioProvenanceVis({
      ) : null} - {xScale && taskName && answers[taskName]?.provenanceGraph + {xScale && taskName && provenanceGraph ? ( ; - answers: ParticipantData['answers']; + provenanceGraph: StoredProvenance; width: number; height: number; currentNode: string | null; @@ -34,34 +35,22 @@ export function TaskProvenanceTimeline({ ); const provenanceNodes = useMemo( - () => Object.entries(answers) - .filter((entry) => (trialName ? trialName === entry[0] : true)) - .map((entry) => { - const [name, answer] = entry; - - const provenanceGraphComponents = Object.keys(answer.provenanceGraph).map( - (provenanceArea) => { - const graph = answer.provenanceGraph[ - provenanceArea as keyof typeof answer.provenanceGraph - ]; - if (graph) { - return ( - - ); - } - return null; - }, + () => PROVENANCE_LOCATIONS.map((provenanceArea) => { + const graph = provenanceGraph[provenanceArea]; + if (graph) { + return ( + ); - - return provenanceGraphComponents; - }), - [currentNode, height, answers, trialName, newXScale], + } + return null; + }), + [currentNode, height, provenanceGraph, trialName, newXScale], ); return ( diff --git a/src/components/downloader/DownloadButtons.tsx b/src/components/downloader/DownloadButtons.tsx index 1de8c858b9..4899893715 100644 --- a/src/components/downloader/DownloadButtons.tsx +++ b/src/components/downloader/DownloadButtons.tsx @@ -2,14 +2,14 @@ import { Button, Group, Tooltip, } from '@mantine/core'; import { - IconDatabaseExport, IconDeviceDesktopDown, IconMusicDown, IconTableExport, + IconDatabaseExport, IconDeviceDesktopDown, IconDownload, IconMusicDown, IconTableExport, } from '@tabler/icons-react'; import { useState } from 'react'; import { useDisclosure } from '@mantine/hooks'; import { DownloadTidy, download } from './DownloadTidy'; import { ParticipantDataWithStatus } from '../../storage/types'; import { useStorageEngine } from '../../storage/storageEngineHooks'; -import { downloadParticipantsAudioZip, downloadParticipantsScreenRecordingZip } from '../../utils/handleDownloadFiles'; +import { downloadParticipantsAudioZip, downloadParticipantsProvenanceZip, downloadParticipantsScreenRecordingZip } from '../../utils/handleDownloadFiles'; type ParticipantDataFetcher = ParticipantDataWithStatus[] | (() => Promise); @@ -18,6 +18,7 @@ export function DownloadButtons({ }: { visibleParticipants: ParticipantDataFetcher; studyId: string, gap?: string, fileName?: string | null; hasAudio?: boolean; hasScreenRecording?: boolean; }) { const [openDownload, { open, close }] = useDisclosure(false); const [participants, setParticipants] = useState([]); + const [loadingProvenance, setLoadingProvenance] = useState(false); const [loadingAudio, setLoadingAudio] = useState(false); const [loadingScreenRecording, setLoadingScreenRecording] = useState(false); const { storageEngine } = useStorageEngine(); @@ -56,6 +57,23 @@ export function DownloadButtons({ } }; + const handleDownloadProvenance = async () => { + setLoadingProvenance(true); + + try { + const currParticipants = await fetchParticipants(); + if (!storageEngine) return; + await downloadParticipantsProvenanceZip({ + storageEngine, + participants: currParticipants, + studyId, + fileName, + }); + } finally { + setLoadingProvenance(false); + } + }; + const handleDownloadScreenRecording = async () => { setLoadingScreenRecording(true); @@ -98,6 +116,17 @@ export function DownloadButtons({ + + + {hasAudio && (