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/package.json b/package.json index 2a2aa5ce96..2001059ebe 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", @@ -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/public/global.json b/public/global.json index e33864d33a..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", @@ -239,4 +244,4 @@ "test": true } } -} \ No newline at end of file +} diff --git a/public/libraries/calvi/config.json b/public/libraries/calvi/config.json index fc8d07532d..4d2575cdcf 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", diff --git a/public/libraries/mic-check/config.json b/public/libraries/mic-check/config.json index 217fdb8bcd..db86f6fba1 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 54ea4284e3..129849443c 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/test-component-timeout/config.json b/public/test-component-timeout/config.json new file mode 100644 index 0000000000..24cdb6da2a --- /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} {unit} 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/GlobalConfigParser.tsx b/src/GlobalConfigParser.tsx index 7010bb1547..e493b7f702 100644 --- a/src/GlobalConfigParser.tsx +++ b/src/GlobalConfigParser.tsx @@ -28,37 +28,72 @@ async function fetchGlobalConfigArray() { return parseGlobalConfig(configs); } -export function GlobalConfigParser() { - const [globalConfig, setGlobalConfig] = useState>(null); +function HomeRoute({ globalConfig }: { globalConfig: GlobalConfig }) { const [studyConfigs, setStudyConfigs] = useState | null>>({}); useEffect(() => { - async function fetchData() { - if (globalConfig) { - setStudyConfigs(await fetchStudyConfigs(globalConfig)); + let cancelled = false; + + async function fetchData(currentGlobalConfig: GlobalConfig) { + const configs = await fetchStudyConfigs(currentGlobalConfig); + if (!cancelled) { + setStudyConfigs(configs); } } - fetchData(); + + fetchData(globalConfig); + + return () => { + cancelled = true; + }; }, [globalConfig]); + return ( + <> + + + + + + + ); +} + +export function GlobalConfigParser() { + const [globalConfig, setGlobalConfig] = useState>(null); + 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) => { @@ -76,21 +111,7 @@ export function GlobalConfigParser() { - - - - - - - )} + element={} /> 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 && ( 0) { setSearchParams((params) => { params.set('participantId', visibleParticipants[0].participantId); - params.delete('currentTrial'); return params; }); } diff --git a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.spec.ts b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.spec.ts new file mode 100644 index 0000000000..8d4a75e6d4 --- /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('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', + }); + + expect(target).toEqual({ + pathname: `/buckaroo/${encryptIndex(8)}`, + search: '?participantId=66aa582aba2b183e61577d44', + }); + }); + + 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', + }); + + expect(target).toEqual({ + pathname: '/analysis/stats/study-1/tagging/task_4', + search: '?participantId=p-1', + }); + }); +}); diff --git a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx index 6ef8fb1364..52d431e27b 100644 --- a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx +++ b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx @@ -36,6 +36,8 @@ import { buildProvenanceLegendEntries, } from '../../../components/audioAnalysis/provenanceColors'; import { revisitPageId, syncChannel } from '../../../utils/syncReplay'; +import { getLegacyStoredAnswerProvenance } from '../../../store/provenance'; +import { buildTaskNavigationTarget } from './taskNavigation'; const margin = { left: 5, top: 0, right: 5, bottom: 0, @@ -49,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)); @@ -83,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']); @@ -220,7 +246,6 @@ export function ThinkAloudFooter({ setSearchParams((params) => { params.set('participantId', visibleParticipants[index] || ''); - params.delete('currentTrial'); return params; }); }, [participantId, setSearchParams, visibleParticipants]); @@ -259,19 +284,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({ @@ -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) @@ -453,7 +477,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 new file mode 100644 index 0000000000..19e8fac04d --- /dev/null +++ b/src/analysis/individualStudy/thinkAloud/taskNavigation.ts @@ -0,0 +1,35 @@ +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, + }; + } + + return { + pathname: funcIndex === null + ? `/${studyId}/${encryptIndex(step)}` + : `/${studyId}/${encryptIndex(step)}/${encryptIndex(funcIndex)}`, + search, + }; +} 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 eced74dbbe..53a4829bcb 100644 --- a/src/components/NextButton.tsx +++ b/src/components/NextButton.tsx @@ -1,13 +1,19 @@ 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 { useCurrentIdentifier } from '../routes/utils'; import { PreviousButton } from './PreviousButton'; +import { + DEFAULT_AUTO_ADVANCE_WARNING_MESSAGE, + DEFAULT_AUTO_ADVANCE_WARNING_TIME, + getAutoAdvanceWarning, +} from './nextButtonTimeout'; type Props = { label?: string; @@ -27,13 +33,19 @@ export function NextButton({ const { isNextDisabled, goToNextStep } = useNextStep(); const studyConfig = useStudyConfig(); const navigate = useNavigate(); + const identifier = useCurrentIdentifier(); - const nextButtonDisableTime = useMemo(() => config?.nextButtonDisableTime ?? studyConfig.uiConfig.nextButtonDisableTime, [config, studyConfig]); - const nextButtonEnableTime = useMemo(() => config?.nextButtonEnableTime ?? studyConfig.uiConfig.nextButtonEnableTime ?? 0, [config, studyConfig]); + 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); - // Use Date.now() to keep time even if tab is hidden + const autoAdvanceTriggered = useRef(false); + // 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(() => { @@ -42,7 +54,7 @@ export function NextButton({ return () => { clearInterval(interval); }; - }, []); + }, [identifier]); useEffect(() => { if (timer === undefined) { @@ -53,6 +65,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,7 +86,14 @@ export function NextButton({ [nextButtonDisableTime, nextButtonEnableTime, timer], ); - const nextOnEnter = useMemo(() => config?.nextOnEnter ?? studyConfig.uiConfig.nextOnEnter, [config, studyConfig]); + const autoAdvanceWarning = useMemo(() => getAutoAdvanceWarning({ + timer, + autoAdvanceTime: nextButtonAutoAdvanceTime, + warningTime: nextButtonAutoAdvanceWarningTime, + warningMessage: nextButtonAutoAdvanceWarningMessage, + }), [nextButtonAutoAdvanceTime, nextButtonAutoAdvanceWarningMessage, nextButtonAutoAdvanceWarningTime, timer]); + + const nextOnEnter = config?.nextOnEnter ?? studyConfig.uiConfig.nextOnEnter; useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -76,15 +104,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 ( <> @@ -134,8 +161,12 @@ export function NextButton({ ))} + {autoAdvanceWarning && ( + }> + {autoAdvanceWarning.message} + + )} - )} ); diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 10df2fd031..8de3072bc5 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 { LoadingOverlay, Title } from '@mantine/core'; +import { + Button, LoadingOverlay, Stack, Text, Title, +} from '@mantine/core'; import { GlobalConfig, Nullable, @@ -26,18 +28,95 @@ 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/storageEngineHelpers'; +import type { StorageEngine, REVISIT_MODE } from '../storage/engines/types'; import { filterSequenceByCondition, parseConditionParam, resolveParticipantConditions, } from '../utils/handleConditionLogic'; +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), + }; +} + +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: getScreenOrientationType(window.screen), + 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 +132,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,11 +164,14 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { window.removeEventListener('message', messageListener); }; } - return () => { }; + + return undefined; }, [globalConfig, routeStudyId]); 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(); @@ -89,62 +179,68 @@ 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; + setIsCompletionCheckResolved(false); + setCompletionCheckError(null); + let modes: Record | null = null; + 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); + + 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 - ? 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', { - 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 [resolvedModes, activeHash] = await Promise.all([ + storageEngine.getModes(canonicalStudyId), + activeHashPromise, + ]); + modes = resolvedModes; + + const initialMetadata = createParticipantMetadata(); let participantSession = await storageEngine.initializeParticipantSession( searchParamsObject, activeConfig, - metadata, + initialMetadata, participantId || urlParticipantId, ); - if (studyCondition.length > 0 && modes.developmentModeEnabled) { + if (studyCondition.length > 0 && resolvedModes.developmentModeEnabled) { const updatedSearchParams = { ...participantSession.searchParams, condition: studyCondition.join(','), @@ -157,40 +253,104 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { conditions: studyCondition, }; } - const activeHash = await hash(JSON.stringify(activeConfig)); - let participantConfig = activeConfig; - if (participantSession.participantConfigHash !== activeHash) { - participantConfig = (await storageEngine.getAllConfigsFromHash([participantSession.participantConfigHash], canonicalStudyId))[participantSession.participantConfigHash] as ParsedConfig; + 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, + allowUrlOverride: resolvedModes.developmentModeEnabled, }); - const filteredParticipantSequence = filterSequenceByCondition(participantSession.sequence, effectiveStudyCondition); - const participantCompleted = await storageEngine.getParticipantCompletionStatus(participantSession.participantId); + const filteredParticipantSequence = filterSequenceByCondition(participantSession.sequence, resolvedCondition); + // Initialize the redux stores const newStore = await studyStoreCreator( canonicalStudyId, participantConfig, filteredParticipantSequence, - metadata, + participantSession.metadata, participantSession.answers, - modes, + resolvedModes, participantSession.participantId, - participantCompleted, + false, false, participantSession.participantConfigHash !== activeHash, ); + + if (isCancelled) { + return; + } + setStore(newStore); + + if (resolvedModes.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); + }); + } + + if (!resolvedModes.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) { + 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); + } + }); + } } 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 resumeParticipantId = participantId + || urlParticipantId + || await storageEngine.peekCurrentParticipantId(canonicalStudyId).catch(() => undefined); + const initialAlertModal = !isStorageFailure + ? getInitialStartupAlert(error, developmentModeEnabledForAlert, resumeParticipantId) + : undefined; + // Fallback: initialize the store with empty data - const generatedSequences = generateSequenceArray(activeConfig); + const generatedSequences = await generateSequenceArray(activeConfig); + const matchingSequence = generatedSequences[0]; const fallbackSequence = filterSequenceByCondition( matchingSequence, @@ -201,27 +361,26 @@ 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 }, + fallbackModes, '', false, - true, + isStorageFailure, + false, + initialAlertModal, ); + + if (isCancelled) { + return; + } + setStore(emptyStore); + setIsCompletionCheckResolved(true); + } + + if (isCancelled) { + return; } // Initialize the routing @@ -255,26 +414,52 @@ export function Shell({ globalConfig }: { globalConfig: GlobalConfig }) { ]); } initializeUserStoreRouting(); + return () => { + isCancelled = true; + }; }, [storageEngine, activeConfig, canonicalStudyId, searchParams, participantId, studyCondition]); const routing = useRoutes(routes); + 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 ? ( + content = ; + } else if (routing && store) { + content = ( {routing} - ) : ( - ); + } else if (!isLoading) { + content = ; } - return toRender; + + return ( + <> + + {isLoading && completionCheckError && ( + + {completionCheckError} + + + )} + {content} + + ); } diff --git a/src/components/audioAnalysis/AudioProvenanceVis.tsx b/src/components/audioAnalysis/AudioProvenanceVis.tsx index 5a92c9dc6e..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 }); @@ -134,7 +170,6 @@ export function AudioProvenanceVis({ const participantIdListener = (newId: string) => { setSearchParams((params) => { params.set('participantId', newId || ''); - params.delete('currentTrial'); return params; }); }; @@ -147,7 +182,6 @@ export function AudioProvenanceVis({ const params = new URLSearchParams(routerLocation.search); params.set('participantId', participantId || ''); - params.delete('currentTrial'); const search = params.toString(); if (context === 'provenanceVis') { @@ -178,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) { @@ -205,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; @@ -251,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) { @@ -355,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 && ( , + 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: () => mockedCurrentComponent, + 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(() => { + mockedCurrentComponent = 'componentA'; + mockedRecordingContext = { + isScreenRecording: false, + isAudioRecording: false, + setIsMuted: vi.fn(), + isMuted: false, + clickToRecord: false, + isSpeakingWhileMuted: false, + showMutedWarning: false, + screenRecordingError: null, + audioRecordingError: null, + currentComponentHasAudioRecording: false, + audioStatus: 'idle', + }; + }); + + test('shows disabled mic state when audio permission is denied before recording starts', () => { + mockedRecordingContext = { + ...mockedRecordingContext, + clickToRecord: true, + currentComponentHasAudioRecording: true, + audioRecordingError: 'Microphone permission denied', + audioStatus: 'denied', + }; + + const html = renderToStaticMarkup(); + + expect(html).toContain('Microphone error'); + 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', + }; + + const html = renderToStaticMarkup(); + + expect(html).toContain('Microphone pending'); + expect(html).toContain('Microphone not enabled yet'); + 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, + 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 5863f55d84..138d95840a 100644 --- a/src/components/interface/AppHeader.tsx +++ b/src/components/interface/AppHeader.tsx @@ -90,7 +90,17 @@ 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, + screenRecordingError, + audioRecordingError, + currentComponentHasAudioRecording, + audioStatus, } = useRecordingContext(); const { isBrowserAllowed, @@ -100,6 +110,20 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d } = useDeviceRules(studyConfig.studyRules); const hasUnmetDeviceRequirement = developmentModeEnabled && (!isBrowserAllowed || !isDeviceAllowed || !isInputAllowed || !isDisplayAllowed); + 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 = ''; + 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; @@ -195,29 +219,30 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d - {(isAudioRecording || isScreenRecording) && ( + {showRecordingStatus && ( - - {((isAudioRecording && !isMuted) || (isScreenRecording)) && 'Recording'} - {isScreenRecording && ' screen'} - {isScreenRecording && isAudioRecording && !isMuted && ' and'} - {isAudioRecording && !isMuted && ' audio'} - - {isAudioRecording && !isMuted && } - {clickToRecord ? ( - - setIsMuted(false)} onMouseUp={() => setIsMuted(true)} onTouchStart={() => setIsMuted(false)} onTouchEnd={() => setIsMuted(true)}> - {isMuted ? : } + {recordingLabel && {recordingLabel}} + {screenRecordingError && {screenRecordingError}} + {audioStatus === 'denied' && audioRecordingError && {audioRecordingError}} + {audioStatus === 'recording' && !isMuted && } + {clickToRecord && showAudioStatus && (audioStatus === 'denied' ? ( + + + + ) : audioStatus === 'pending' ? ( + + + ) : ( - - setIsMuted(!isMuted)}> + + setIsMuted(false)} onMouseUp={() => setIsMuted(true)} onTouchStart={() => setIsMuted(false)} onTouchEnd={() => setIsMuted(true)}> {isMuted ? : } - )} + ))} )} {storageEngineFailedToConnect && Storage Disconnected} 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/components/nextButtonTimeout.spec.ts b/src/components/nextButtonTimeout.spec.ts new file mode 100644 index 0000000000..0eacf1a983 --- /dev/null +++ b/src/components/nextButtonTimeout.spec.ts @@ -0,0 +1,58 @@ +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} {unit} without saving this component.', + 4, + )).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, + 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} {unit}.', + })).toBeNull(); + }); + + test('uses the configured warning window', () => { + expect(getAutoAdvanceWarning({ + timer: 3000, + autoAdvanceTime: 5000, + warningTime: 2500, + warningMessage: 'Advancing in {seconds} {unit}.', + })).toEqual({ + remainingTime: 2000, + message: 'Advancing in 2 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..c100731e43 --- /dev/null +++ b/src/components/nextButtonTimeout.ts @@ -0,0 +1,45 @@ +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}') || message.includes('{unit}')) { + return message + .replaceAll('{seconds}', String(secondsRemaining)) + .replaceAll('{unit}', secondsRemaining === 1 ? 'second' : 'seconds'); + } + + 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..8fac4ee3f7 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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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..32b7f22927 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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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 number and `{unit}` to interpolate `second`/`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..9155922756 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} {unit}.', + }, + }, + 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..5f5dc7b504 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 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; /** 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/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 ce56172910..e53f73928a 100644 --- a/src/public/libraries/screen-recording/assets/ScreenRecording.tsx +++ b/src/public/libraries/screen-recording/assets/ScreenRecording.tsx @@ -8,12 +8,12 @@ import { RecordingAudioWaveform } from '../../../../components/interface/Recordi function ScreenRecordingPermission({ setAnswer }: StimulusParams) { const { - recordAudio, + studyHasAudioRecording, recordVideoRef, startScreenCapture: startCapture, stopScreenCapture: stopCapture, isScreenCapturing: screenCapturing, - screenRecordingError: error, + isAudioCapturing: audioCapturing, audioMediaStream, } = useRecordingContext(); @@ -22,13 +22,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 +78,12 @@ function ScreenRecordingPermission({ setAnswer }: StimulusParams) { Screen - {recordAudio && ' and Audio'} + {studyHasAudioRecording && ' and Audio'} {' '} Recording Permission - {recordAudio ? ( + {studyHasAudioRecording ? ( <> {/* Record both screen and audio */}

@@ -113,15 +113,14 @@ 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.

  • Speak {' '} into your microphone to check if audio is working. - {(recordAudio && screenCapturing) ? : } + {audioCapturing ? : }
  • Note: @@ -158,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/routes/utils.tsx b/src/routes/utils.tsx index 8e9ab3f3c9..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,7 +7,6 @@ import { useFlatSequence, useStoreActions, useStoreDispatch, useStoreSelector, } from '../store/store'; import { decryptIndex, encryptIndex } from '../utils/encryptDecryptIndex'; -import { parseTrialOrder } from '../utils/parseTrialOrder'; import { JumpFunctionParameters, JumpFunctionReturnVal } from '../store/types'; import { findFuncBlock } from '../utils/getSequenceFlatMap'; import { useStudyConfig } from '../store/hooks/useStudyConfig'; @@ -21,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) { @@ -35,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; } @@ -59,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(); @@ -67,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); @@ -95,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)}${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)}${window.location.search}`); - return; - } - - if (decryptedFuncIndex !== currentTrialFuncIndex) { - navigate(`/${studyId}/${encryptIndex(currentStep)}/${encryptIndex(currentTrialFuncIndex)}${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') { 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..0e7acb10de 100644 --- a/src/storage/engines/types.ts +++ b/src/storage/engines/types.ts @@ -2,7 +2,7 @@ import localforage from 'localforage'; import throttle from 'lodash.throttle'; import { v4 as uuidv4 } from 'uuid'; import { StudyConfig } from '../../parser/types'; -import { ParticipantMetadata, Sequence } from '../../store/types'; +import { ParticipantMetadata, Sequence, StoredProvenance } from '../../store/types'; import { ParticipantData, ParticipantDataWithStatus } from '../types'; import { hash, isParticipantData } from './utils/storageEngineHelpers'; import { shouldPreferCachedParticipantData } from './utils/participantDataRecovery'; @@ -11,6 +11,10 @@ import { parseConditionParam } from '../../utils/handleConditionLogic'; import { ParticipantTags, Tag, TaglessEditedText, TranscribedAudio, } from '../../analysis/individualStudy/thinkAloud/types'; +import { + normalizeStoredProvenance, + splitProvenanceFromAnswers, +} from '../../store/provenance'; export interface StoredUser { email: string | null, @@ -66,6 +70,11 @@ interface StageData { allStages: StageInfo[]; } +type ModesAndStageData = { + modes: Record; + stageData: StageData; +}; + export interface ConditionData { allConditions: string[]; conditionCounts: Record; @@ -188,6 +197,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 +384,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 +606,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 +694,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}`, @@ -727,6 +760,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; @@ -741,7 +785,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 +794,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 +892,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 +921,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 +938,11 @@ export abstract class StorageEngine { }; if (modes.dataCollectionEnabled) { - await this.persistCurrentParticipantData({ immediate: true }); + if (this.shouldDeferInitialParticipantDataPersistence()) { + this.persistCurrentParticipantData({ immediate: true }).catch(() => undefined); + } else { + await this.persistCurrentParticipantData({ immediate: true }); + } } else { await this.cacheParticipantDataSnapshot(this.participantData, this.currentParticipantId); } @@ -1037,6 +1079,28 @@ 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; + + 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 }); + } + async updateStudyCondition(condition: string | string[]) { await this.verifyStudyDatabase(); @@ -1203,14 +1267,23 @@ export abstract class StorageEngine { return; } + const { + answers: answersWithoutProvenance, + provenanceByIdentifier, + } = splitProvenanceFromAnswers(answers); + const provenanceUploadPromises = Object.entries(provenanceByIdentifier).map( + ([taskName, provenanceGraph]) => this.saveProvenance(provenanceGraph, taskName), + ); + // Update the local copy of the participant data this.participantData = { ...this.participantData, - answers, + answers: answersWithoutProvenance, }; await this.cacheParticipantDataSnapshot(this.participantData, this.currentParticipantId); this.scheduleParticipantDataWrite(this.participantData); + await Promise.all(provenanceUploadPromises); } // Updates the progress data in the sequence assignment @@ -1351,6 +1424,82 @@ export abstract class StorageEngine { return url; } + private async parseProvenanceStorageObject(provenanceObject: unknown): Promise { + if (typeof Blob !== 'undefined' && provenanceObject instanceof Blob) { + try { + return normalizeStoredProvenance(JSON.parse(await provenanceObject.text())); + } catch { + return null; + } + } + + return normalizeStoredProvenance(provenanceObject); + } + + async getProvenance( + task: string, + participantId?: string, + ) { + const targetParticipantId = participantId || this.currentParticipantId; + if (!targetParticipantId) { + throw new Error('Participant not initialized'); + } + + const provenanceObject = await this._getFromStorage( + `provenance/${targetParticipantId}`, + task, + ); + + return this.parseProvenanceStorageObject(provenanceObject); + } + + async deleteProvenance(taskName: string) { + if (!this.currentParticipantId || !this.studyId) { + return; + } + try { + await this._deleteFromStorage(`provenance/${this.currentParticipantId}`, taskName); + } catch (error) { + console.warn(`Failed to delete provenance for task ${taskName}:`, error); + } + } + + async saveProvenance( + provenanceGraph: StoredProvenance | null | undefined, + taskName: string, + ) { + const provenanceToSave = normalizeStoredProvenance(provenanceGraph); + if (!provenanceToSave) { + await this.deleteProvenance(taskName); + return undefined; + } + + return this.trackAssetOperation(`provenance/${taskName}`, async () => { + if (this.studyId === undefined) { + throw new Error('Study ID is not set'); + } + if (!this.currentParticipantId) { + throw new Error('Participant not initialized'); + } + const modes = await this.getModes(this.studyId); + if (!modes.dataCollectionEnabled) { + throw new Error('Data collection is disabled for this study'); + } + + await this._pushToStorage( + `provenance/${this.currentParticipantId}`, + taskName, + provenanceToSave as unknown as StorageObject, + ); + + try { + await this._cacheStorageObject(`provenance/${this.currentParticipantId}`, taskName); + } catch (error) { + console.warn(`Failed to update cache headers for provenance ${taskName}:`, error); + } + }); + } + // Gets the transcript download URL (currently only supported by Firebase) async getTranscriptUrl( task: string, @@ -1516,6 +1665,7 @@ export abstract class StorageEngine { await this._copyDirectory(`${sourceName}/participants`, `${targetName}/participants`); await this._copyDirectory(`${sourceName}/audio`, `${targetName}/audio`); await this._copyDirectory(`${sourceName}/screenRecording`, `${targetName}/screenRecording`); + await this._copyDirectory(`${sourceName}/provenance`, `${targetName}/provenance`); await this._copyDirectory(sourceName, targetName); await this._copyRealtimeData(sourceName, targetName); } @@ -1563,6 +1713,7 @@ export abstract class StorageEngine { await this._deleteDirectory(`${deletionTarget}/participants`); await this._deleteDirectory(`${deletionTarget}/audio`); await this._deleteDirectory(`${deletionTarget}/screenRecording`); + await this._deleteDirectory(`${deletionTarget}/provenance`); await this._deleteDirectory(deletionTarget); await this._deleteRealtimeData(deletionTarget); } @@ -1632,6 +1783,10 @@ export abstract class StorageEngine { `${snapshotName}/screenRecording`, `${originalName}/screenRecording`, ); + await this._copyDirectory( + `${snapshotName}/provenance`, + `${originalName}/provenance`, + ); await this._copyDirectory(snapshotName, originalName); await this._copyRealtimeData(snapshotName, originalName); successNotifications.push({ @@ -1696,6 +1851,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/engines/utils/participantDataRecovery.spec.ts b/src/storage/engines/utils/participantDataRecovery.spec.ts index ee4770bf18..3bab7a74f2 100644 --- a/src/storage/engines/utils/participantDataRecovery.spec.ts +++ b/src/storage/engines/utils/participantDataRecovery.spec.ts @@ -24,12 +24,6 @@ function makeStoredAnswer(identifier: string, endTime: number): StoredAnswer { incorrectAnswers: {}, startTime: endTime - 10, endTime, - provenanceGraph: { - aboveStimulus: undefined, - belowStimulus: undefined, - stimulus: undefined, - sidebar: undefined, - }, windowEvents: [], timedOut: false, helpButtonClickedCount: 0, diff --git a/src/storage/tests/highLevel.spec.ts b/src/storage/tests/highLevel.spec.ts index 91292d6d73..0441101516 100644 --- a/src/storage/tests/highLevel.spec.ts +++ b/src/storage/tests/highLevel.spec.ts @@ -8,7 +8,7 @@ import testConfigSimple from './testConfigSimple.json'; import testConfigSimple2 from './testConfigSimple2.json'; import { generateSequenceArray } from '../../utils/handleRandomSequences'; import { hash } from '../engines/utils/storageEngineHelpers'; -import { Sequence, StoredAnswer } from '../../store/types'; +import { Sequence, StoredAnswer, TrrackedProvenance } from '../../store/types'; import { LocalStorageEngine } from '../engines/LocalStorageEngine'; import { StorageEngine, StorageObject, StorageObjectType } from '../engines/types'; import { filterSequenceByCondition } from '../../utils/handleConditionLogic'; @@ -99,12 +99,6 @@ function makeStoredAnswer(identifier: string, endTime: number): StoredAnswer { incorrectAnswers: {}, startTime: endTime - 10, endTime, - provenanceGraph: { - aboveStimulus: undefined, - belowStimulus: undefined, - stimulus: undefined, - sidebar: undefined, - }, windowEvents: [], timedOut: false, helpButtonClickedCount: 0, @@ -234,6 +228,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 @@ -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; @@ -291,6 +320,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 @@ -318,6 +355,46 @@ 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 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); @@ -445,6 +522,51 @@ 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('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); + 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(); @@ -653,12 +775,6 @@ describe.each([ incorrectAnswers: {}, startTime: 10, endTime: 20, - provenanceGraph: { - sidebar: undefined, - aboveStimulus: undefined, - belowStimulus: undefined, - stimulus: undefined, - }, windowEvents: [], timedOut: false, helpButtonClickedCount: 0, @@ -683,6 +799,92 @@ describe.each([ expect(participantData?.answers).toEqual(secondAnswers); }); + test('saveAnswers strips inline provenance and stores it as a task asset', async () => { + const participantSession = await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata); + const identifier = 'intro_0'; + const provenanceGraph = { + root: 'root', + current: 'root', + nodes: { + root: { + id: 'root', + createdOn: 10, + children: [], + }, + }, + } as unknown as TrrackedProvenance; + const answerWithProvenance = { + ...makeStoredAnswer(identifier, 100), + provenanceGraph: { + aboveStimulus: undefined, + belowStimulus: undefined, + sidebar: undefined, + stimulus: provenanceGraph, + }, + }; + + await storageEngine.saveAnswers({ + [identifier]: answerWithProvenance, + }); + await storageEngine.flushPendingParticipantData(); + + const participantData = await storageEngine.getParticipantData(participantSession.participantId); + expect(participantData).toBeDefined(); + expect('provenanceGraph' in participantData!.answers[identifier]).toBe(false); + + const storedProvenance = await storageEngine.getProvenance(identifier, participantSession.participantId); + expect(storedProvenance).toEqual({ + aboveStimulus: undefined, + belowStimulus: undefined, + sidebar: undefined, + stimulus: provenanceGraph, + }); + }); + + test('saveProvenance with null/undefined deletes existing provenance asset', async () => { + storageEngine = new LocalStorageEngine(true); + await storageEngine.connect(); + await storageEngine.initializeStudyDb(studyId); + sequenceArray = await generateSequenceArray(configSimple); + const participantSession = await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata); + const identifier = 'intro_0'; + + // First, save a provenance asset + const provenanceGraph = { + root: 'root', + current: 'root', + nodes: { + root: { + id: 'root', + createdOn: 10, + children: [], + }, + }, + } as unknown as TrrackedProvenance; + await storageEngine.saveProvenance({ + aboveStimulus: undefined, + belowStimulus: undefined, + sidebar: undefined, + stimulus: provenanceGraph, + }, identifier); + + // Verify it was saved + let storedProvenance = await storageEngine.getProvenance(identifier, participantSession.participantId); + expect(storedProvenance).toEqual({ + aboveStimulus: undefined, + belowStimulus: undefined, + sidebar: undefined, + stimulus: provenanceGraph, + }); + + // Now save with null — should delete the existing asset + await storageEngine.saveProvenance(null, identifier); + + // Verify it was deleted + storedProvenance = await storageEngine.getProvenance(identifier, participantSession.participantId); + expect(storedProvenance).toBeNull(); + }); + test('saveAnswers coalesces to the latest answer and finalizeParticipant persists completion after delayed writes', async () => { storageEngine = new DelayedLocalStorageEngine(true); await storageEngine.connect(); diff --git a/src/storage/types.ts b/src/storage/types.ts index 687deb27c4..48ff13e057 100644 --- a/src/storage/types.ts +++ b/src/storage/types.ts @@ -30,9 +30,6 @@ import { ParticipantMetadata, Sequence, StoredAnswer } from '../store/types'; * }, * "startTime": 1711641174858, * "endTime": 1711641178836, - * "provenanceGraph":{ - * ... - * }, * "windowEvents": [ * ... * ] @@ -40,9 +37,8 @@ import { ParticipantMetadata, Sequence, StoredAnswer } from '../store/types'; * ``` * The keys of this object are the names of the components with an additional underscore and number appended to the end. This is done so that the study creator can discern between not only the components but also between the various instances of the same component when necessary. All times are in **epoch milliseconds**. * - * :::info - * The `"provenanceGraph"` key will only exist if the component is a React component and if it is utilizing Trrack. See [here](../StoredAnswer) for more details. - * ::: + * Provenance graphs exported from Trrack are stored separately from the participant answer object, using the same per-participant, per-task asset storage pattern as audio and screen recordings. + * * * We can see at a high level that we are given the answer that the user submitted, the start time for the component, and the end time. In addition to this, we have a list of window events. You can find more information about the StoredAnswer object [here](../StoredAnswer). */ 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 } ); } diff --git a/src/store/hooks/useNextStep.spec.tsx b/src/store/hooks/useNextStep.spec.tsx index 805f7228d7..81fc77ff5e 100644 --- a/src/store/hooks/useNextStep.spec.tsx +++ b/src/store/hooks/useNextStep.spec.tsx @@ -12,6 +12,7 @@ import { useNextStep } from './useNextStep'; const mockNavigate = vi.fn(); const mockShowNotification = vi.fn(); const mockSaveAnswers = vi.fn(); +const mockSaveProvenance = vi.fn(() => Promise.resolve()); const mockSaveTrialAnswer = vi.fn((payload) => ({ type: 'saveTrialAnswer', payload })); const mockSetReactiveAnswers = vi.fn((payload) => ({ type: 'setReactiveAnswers', payload })); const mockSetMatrixAnswersCheckbox = vi.fn((payload) => ({ type: 'setMatrixAnswersCheckbox', payload })); @@ -26,12 +27,6 @@ let mockStoredAnswer: { incorrectAnswers: Record; startTime: number; endTime: number; - provenanceGraph: { - aboveStimulus: undefined; - belowStimulus: undefined; - stimulus: undefined; - sidebar: undefined; - }; windowEvents: never[]; timedOut: boolean; helpButtonClickedCount: number; @@ -42,6 +37,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 +67,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 +83,7 @@ vi.mock('../store', () => ({ }), useStoreDispatch: () => mockDispatch, useAreResponsesValid: () => true, - useFlatSequence: () => ['intro'], + useFlatSequence: () => mockFlatSequence, })); vi.mock('../../routes/utils', () => ({ @@ -115,6 +96,7 @@ vi.mock('../../storage/storageEngineHooks', () => ({ useStorageEngine: () => ({ storageEngine: { saveAnswers: mockSaveAnswers, + saveProvenance: mockSaveProvenance, }, }), })); @@ -128,17 +110,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), })); @@ -154,6 +137,7 @@ describe('useNextStep', () => { mockNavigate.mockReset(); mockShowNotification.mockReset(); mockSaveAnswers.mockReset(); + mockSaveProvenance.mockClear(); mockSaveTrialAnswer.mockClear(); mockSetReactiveAnswers.mockClear(); mockSetMatrixAnswersCheckbox.mockClear(); @@ -161,6 +145,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', @@ -169,12 +175,6 @@ describe('useNextStep', () => { incorrectAnswers: {}, startTime: 0, endTime: -1, - provenanceGraph: { - aboveStimulus: undefined, - belowStimulus: undefined, - stimulus: undefined, - sidebar: undefined, - }, windowEvents: [], timedOut: false, helpButtonClickedCount: 0, @@ -223,4 +223,103 @@ 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'); + }); + + 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'); + }); + + 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 5365694339..ba73f3d1fa 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); @@ -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,10 +83,9 @@ export function useNextStep() { if (dataCollectionEnabled && (storedAnswer.endTime === -1 || clickedPrevious)) { const toSave = { ...storedAnswer, - answer: collectData ? answer : {}, + answer: answerToPersist, startTime, endTime, - provenanceGraph, windowEvents: currentWindowEvents, timedOut: !collectData, }; @@ -93,13 +93,23 @@ export function useNextStep() { if (storageEngine) { storageEngine.saveAnswers(answersToPersist).catch((error) => { - console.error('Failed to save participant answers', error); + console.error('Failed to save participant response data', error); showNotification({ title: 'Failed to Save Response', message: 'Your response could not be saved. Please check your connection and try again.', color: 'red', }); }); + if (provenanceGraph) { + storageEngine.saveProvenance(provenanceGraph, identifier).catch((error) => { + console.error('Failed to save participant response data', error); + showNotification({ + title: 'Failed to Save Response', + message: 'Your response could not be saved. Please check your connection and try again.', + color: 'red', + }); + }); + } } storeDispatch( @@ -125,13 +135,16 @@ export function useNextStep() { const answersWithNewAnswer = { ...answers, [identifier]: { - answer, + answer: answerToPersist, startTime, endTime, - provenanceGraph, windowEvents: currentWindowEvents, + 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) { @@ -143,10 +156,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); diff --git a/src/store/hooks/usePreviousStep.spec.tsx b/src/store/hooks/usePreviousStep.spec.tsx new file mode 100644 index 0000000000..8225aceb94 --- /dev/null +++ b/src/store/hooks/usePreviousStep.spec.tsx @@ -0,0 +1,141 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { + afterEach, + 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 mockAnswers: Record; +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: mockAnswers, + }), +})); + +function HookHarness() { + capturedHook = usePreviousStep(); + return null; +} + +describe('usePreviousStep', () => { + beforeEach(() => { + mockNavigate.mockReset(); + mockDeleteDynamicBlockAnswers.mockClear(); + mockDispatch.mockReset(); + 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', { + location: { search: '?participantId=p-1' }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('keeps the previous button enabled during analysis replay', () => { + mockIsAnalysis = true; + + renderToStaticMarkup(); + + 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' }, + }); + + renderToStaticMarkup(); + + capturedHook?.goToPreviousStep(); + + expect(mockDispatch).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/study-1/enc-1/enc-0?participantId=p-1'); + }); + + 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' }, + }); + + renderToStaticMarkup(); + + capturedHook?.goToPreviousStep(); + + 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 1096f72ec1..9e2c6ce728 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'; @@ -18,8 +19,10 @@ 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 - const isPreviousDisabled = typeof currentStep !== 'number' || isAnalysis || currentStep <= 0; + // Status of the previous button. If true, the previous button should be disabled + const isPreviousDisabled = typeof currentStep !== 'number' || (currentStep <= 0 && (!funcIndex || decryptIndex(funcIndex) <= 0)); + + const buildSearch = useCallback(() => window.location.search, []); const goToPreviousStep = useCallback(() => { if (typeof currentStep !== 'number') { @@ -31,29 +34,41 @@ 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) { - navigate(`/${studyId}/${encryptIndex(currentStep)}/${encryptIndex(decryptIndex(funcIndex) - 1)}${window.location.search}`); + const previousFuncIndex = decryptIndex(funcIndex) - 1; + navigate(`/${studyId}/${encryptIndex(currentStep)}/${encryptIndex(previousFuncIndex)}${buildSearch()}`); 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()}`); + 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()}`); + return; + } } - }, [currentStep, funcIndex, navigate, studyId, studyConfig, storeDispatch, deleteDynamicBlockAnswers, answers]); + }, [answers, buildSearch, currentStep, deleteDynamicBlockAnswers, funcIndex, isAnalysis, navigate, storeDispatch, studyConfig, studyId]); return { isPreviousDisabled, diff --git a/src/store/hooks/useRecording.ts b/src/store/hooks/useRecording.ts index 97c99a41f7..7919575126 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,9 +288,13 @@ export function useRecording() { }); recorder.start(); + setAudioRecordingError(null); + setIsAudioRecording(true); + }).catch((err) => { + console.error('Error accessing microphone:', err); + setAudioRecordingError('Microphone permission denied'); + setIsAudioRecording(false); }); - - setIsAudioRecording(true); }, [storageEngine, isMuted]); // For study with just audio recording @@ -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 @@ -347,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, @@ -358,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; @@ -393,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; } @@ -483,6 +498,8 @@ export function useRecording() { return { recordVideoRef, studyHasScreenRecording, + studyHasAudioRecording, + currentComponentHasAudioRecording, isMuted, setIsMuted, recordAudio, @@ -491,6 +508,7 @@ export function useRecording() { startScreenRecording, stopScreenRecording, screenRecordingError, + audioRecordingError, isScreenRecording, isAudioRecording, isScreenCapturing, @@ -503,6 +521,13 @@ export function useRecording() { isRejected, isSpeakingWhileMuted, showMutedWarning, + audioStatus: audioRecordingError + ? 'denied' + : isAudioRecording + ? 'recording' + : currentComponentHasAudioRecording + ? 'pending' + : 'idle', }; } diff --git a/src/store/provenance.spec.ts b/src/store/provenance.spec.ts new file mode 100644 index 0000000000..dae7e77998 --- /dev/null +++ b/src/store/provenance.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'vitest'; +import { StoredAnswer } from './types'; +import { + getLegacyStoredAnswerProvenance, + splitProvenanceFromAnswers, +} from './provenance'; + +function makeStoredAnswer(identifier: string): StoredAnswer { + return { + answer: {}, + identifier, + componentName: 'intro', + trialOrder: '0', + incorrectAnswers: {}, + startTime: 1, + endTime: 2, + windowEvents: [], + timedOut: false, + helpButtonClickedCount: 0, + parameters: {}, + correctAnswer: [], + optionOrders: {}, + questionOrders: {}, + }; +} + +describe('provenance helpers', () => { + test('legacy answer provenance reads tolerate null and non-object answers', () => { + expect(getLegacyStoredAnswerProvenance(null)).toBeNull(); + expect(getLegacyStoredAnswerProvenance(undefined)).toBeNull(); + expect(getLegacyStoredAnswerProvenance('not-an-answer')).toBeNull(); + expect(getLegacyStoredAnswerProvenance({ + ...makeStoredAnswer('intro_0'), + provenanceGraph: null, + })).toBeNull(); + }); + + test('splitProvenanceFromAnswers strips null inline provenance without saving a provenance asset', () => { + const answerWithNullProvenance = { + ...makeStoredAnswer('intro_0'), + provenanceGraph: null, + }; + + const { answers, provenanceByIdentifier } = splitProvenanceFromAnswers({ + intro_0: answerWithNullProvenance, + }); + + expect(provenanceByIdentifier).toEqual({}); + expect('provenanceGraph' in answers.intro_0).toBe(false); + }); +}); diff --git a/src/store/provenance.ts b/src/store/provenance.ts new file mode 100644 index 0000000000..cffff528d1 --- /dev/null +++ b/src/store/provenance.ts @@ -0,0 +1,82 @@ +import type { ResponseBlockLocation } from '../parser/types'; +import type { StoredAnswer, StoredProvenance } from './types'; + +export const PROVENANCE_LOCATIONS: ResponseBlockLocation[] = [ + 'aboveStimulus', + 'belowStimulus', + 'sidebar', + 'stimulus', +]; + +export type LegacyStoredAnswerWithProvenance = StoredAnswer & { + provenanceGraph?: unknown; +}; + +export function createEmptyProvenance(): StoredProvenance { + return { + aboveStimulus: undefined, + belowStimulus: undefined, + sidebar: undefined, + stimulus: undefined, + }; +} + +export function normalizeStoredProvenance(provenanceGraph: unknown): StoredProvenance | null { + if (!provenanceGraph || typeof provenanceGraph !== 'object') { + return null; + } + + const provenanceCandidate = provenanceGraph as Partial; + const provenance = createEmptyProvenance(); + let hasProvenance = false; + + PROVENANCE_LOCATIONS.forEach((location) => { + if (provenanceCandidate[location]) { + provenance[location] = provenanceCandidate[location]; + hasProvenance = true; + } + }); + + return hasProvenance ? provenance : null; +} + +export function hasStoredProvenance(provenanceGraph: unknown) { + return normalizeStoredProvenance(provenanceGraph) !== null; +} + +export function getLegacyStoredAnswerProvenance(answer: unknown) { + if (!answer || typeof answer !== 'object') { + return null; + } + + return normalizeStoredProvenance( + (answer as LegacyStoredAnswerWithProvenance).provenanceGraph, + ); +} + +export function stripProvenanceFromStoredAnswer( + answer: StoredAnswer | LegacyStoredAnswerWithProvenance, +): StoredAnswer { + const { provenanceGraph: _provenanceGraph, ...answerWithoutProvenance } = answer as LegacyStoredAnswerWithProvenance; + return answerWithoutProvenance; +} + +export function splitProvenanceFromAnswers(answers: Record) { + const provenanceByIdentifier: Record = {}; + const answersWithoutProvenance: Record = {}; + + Object.entries(answers).forEach(([identifier, answer]) => { + const provenanceGraph = getLegacyStoredAnswerProvenance(answer); + + if (provenanceGraph) { + provenanceByIdentifier[identifier] = provenanceGraph; + } + + answersWithoutProvenance[identifier] = stripProvenanceFromStoredAnswer(answer); + }); + + return { + answers: answersWithoutProvenance, + provenanceByIdentifier, + }; +} diff --git a/src/store/store.tsx b/src/store/store.tsx index 1a4072dfd4..b6356a3333 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); @@ -47,12 +48,6 @@ export async function studyStoreCreator( incorrectAnswers: {}, startTime: 0, endTime: -1, - provenanceGraph: { - aboveStimulus: undefined, - belowStimulus: undefined, - stimulus: undefined, - sidebar: undefined, - }, windowEvents: [], timedOut: false, helpButtonClickedCount: 0, @@ -111,7 +106,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, @@ -146,6 +141,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]) { @@ -169,12 +167,6 @@ export async function studyStoreCreator( trialOrder: `${payload.index}_${payload.funcIndex}`, startTime: 0, endTime: -1, - provenanceGraph: { - aboveStimulus: undefined, - belowStimulus: undefined, - stimulus: undefined, - sidebar: undefined, - }, windowEvents: [], timedOut: false, helpButtonClickedCount: 0, @@ -204,7 +196,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 0aa0a9c00c..27e114ff43 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -21,6 +21,7 @@ export interface ParticipantMetadata { } export type TrrackedProvenance = ProvenanceGraph; +export type StoredProvenance = Record; // timestamp, event type, event data type FocusEvent = [number, 'focus', string]; @@ -43,7 +44,7 @@ export type TrialValidation = Record< belowStimulus: ValidationStatus; sidebar: ValidationStatus; stimulus: ValidationStatus; - provenanceGraph: Record; + provenanceGraph: StoredProvenance; } >; @@ -81,8 +82,6 @@ export interface StoredAnswer { startTime: number; /** Time that the user ended interaction with the component in epoch milliseconds. */ endTime: number; - /** The entire provenance graph exported from a Trrack instance from a React component. This will only be present if you are using React components and you're utilizing [Trrack](https://apps.vdl.sci.utah.edu/trrack) */ - provenanceGraph: Record; /** * A list containing the time (in epoch milliseconds), the action (focus, input, keypress, mousedown, mouseup, mousemove, resize, scroll or visibility), and then either a coordinate pertaining to where the event took place on the screen or string related to such event. Below is an example of the windowEvents list. * @@ -186,6 +185,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; @@ -194,7 +194,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; diff --git a/src/utils/handleDownloadFiles.provenance.spec.ts b/src/utils/handleDownloadFiles.provenance.spec.ts new file mode 100644 index 0000000000..eda803a5ae --- /dev/null +++ b/src/utils/handleDownloadFiles.provenance.spec.ts @@ -0,0 +1,122 @@ +import { + afterEach, describe, expect, test, vi, +} from 'vitest'; +import { downloadParticipantsProvenanceZip } from './handleDownloadFiles'; +import type { StorageEngine } from '../storage/engines/types'; +import type { StoredAnswer } from '../store/types'; + +const mockZipState = vi.hoisted(() => { + const instances: Array<{ + files: Record; + file: (name: string, content: unknown) => unknown; + }> = []; + + class MockJSZip { + files: Record = {}; + + file = vi.fn((name: string, content: unknown) => { + this.files[name] = content; + return this; + }); + + generateAsync = vi.fn(async () => new Blob(['zip'])); + + constructor() { + instances.push(this); + } + } + + return { instances, MockJSZip }; +}); + +vi.mock('jszip', () => ({ + default: mockZipState.MockJSZip, +})); + +function makeStoredAnswer(overrides: Partial = {}): StoredAnswer { + return { + answer: {}, + identifier: 'intro_0', + componentName: 'intro', + trialOrder: '0', + correctAnswer: [], + incorrectAnswers: {}, + startTime: 10, + endTime: 20, + windowEvents: [], + timedOut: false, + helpButtonClickedCount: 0, + parameters: {}, + optionOrders: {}, + questionOrders: {}, + ...overrides, + }; +} + +describe('provenance downloads', () => { + afterEach(() => { + mockZipState.instances.splice(0, mockZipState.instances.length); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + test('downloads provenance as a zip alongside the expected per-answer file name', async () => { + const anchor = { + href: '', + download: '', + click: vi.fn(), + remove: vi.fn(), + } as unknown as HTMLAnchorElement; + const createElement = vi.fn().mockReturnValue(anchor); + const appendChild = vi.fn(); + vi.stubGlobal('document', { + createElement, + body: { + appendChild, + }, + }); + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:zip'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + + const provenanceGraph = { + aboveStimulus: undefined, + belowStimulus: undefined, + sidebar: undefined, + stimulus: { + root: 'root', + nodes: { + root: { + id: 'root', + createdOn: 10, + children: [], + }, + }, + }, + }; + + const storageEngine = { + getProvenance: vi.fn().mockResolvedValue(provenanceGraph), + } as unknown as StorageEngine; + + await downloadParticipantsProvenanceZip({ + storageEngine, + participants: [ + { + participantId: 'p1', + answers: { + intro_0: makeStoredAnswer(), + }, + }, + ], + studyId: 'study-1', + }); + + expect(storageEngine.getProvenance).toHaveBeenCalledWith('intro_0', 'p1'); + expect(mockZipState.instances).toHaveLength(1); + expect(mockZipState.instances[0].files['study-1_p1_intro_0_provenance.json']).toBe( + JSON.stringify(provenanceGraph, null, 2), + ); + expect(anchor.download).toBe('study-1_provenance.zip'); + expect(anchor.click).toHaveBeenCalled(); + }); +}); diff --git a/src/utils/handleDownloadFiles.ts b/src/utils/handleDownloadFiles.ts index a6e96d1541..8bcac482b7 100644 --- a/src/utils/handleDownloadFiles.ts +++ b/src/utils/handleDownloadFiles.ts @@ -1,6 +1,8 @@ import JSZip from 'jszip'; import { StorageEngine } from '../storage/engines/types'; import { StudyConfig } from '../parser/types'; +import { getLegacyStoredAnswerProvenance } from '../store/provenance'; +import type { StoredAnswer } from '../store/types'; export async function handleTaskAudio({ storageEngine, @@ -86,6 +88,42 @@ async function downloadZip(zip: JSZip, fileName: string) { URL.revokeObjectURL(url); } +async function downloadParticipantsProvenance({ + storageEngine, + participantId, + identifier, + namePrefix, + zip, + answer, +}: { + storageEngine: StorageEngine; + participantId: string; + identifier: string; + namePrefix: string; + zip?: JSZip; + answer?: StoredAnswer; +}) { + const provenanceZip = zip || new JSZip(); + + try { + const legacyProvenance = answer ? getLegacyStoredAnswerProvenance(answer) : null; + const provenance = legacyProvenance || await storageEngine.getProvenance(identifier, participantId); + + if (provenance) { + provenanceZip.file( + `${namePrefix}_${participantId}_${identifier}_provenance.json`, + JSON.stringify(provenance, null, 2), + ); + } + + if (!zip) { + downloadZip(provenanceZip, `${namePrefix}_${participantId}_${identifier}_provenance.zip`); + } + } catch (error) { + console.warn(`Failed to fetch provenance for ${identifier}:`, error); + } +} + async function downloadParticipantsAudio({ storageEngine, participantId, @@ -217,6 +255,45 @@ export async function downloadParticipantsScreenRecordingZip({ await downloadZip(zip, `${namePrefix}_screenRecording.zip`); } +export async function downloadParticipantsProvenanceZip({ + storageEngine, + participants, + studyId, + fileName, +}: { + storageEngine: StorageEngine; + participants: Array<{ + participantId: string; + answers: Record; + }>; + studyId: string; + fileName?: string | null; +}) { + const namePrefix = fileName || studyId; + const zip = new JSZip(); + + const provenancePromises = participants.flatMap((participant) => { + const entries = Object.entries(participant.answers) + .filter(([, ans]) => ans.endTime > 0) + .sort(([, a], [, b]) => a.startTime - b.startTime); + + return entries.map(async ([identifier, ans]) => { + await downloadParticipantsProvenance({ + storageEngine, + participantId: participant.participantId, + identifier: ans.identifier || identifier, + namePrefix, + zip, + answer: ans, + }); + }); + }); + + await Promise.all(provenancePromises); + + await downloadZip(zip, `${namePrefix}_provenance.zip`); +} + export async function downloadConfigFile({ studyId, hash, 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]); } 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}`); }); diff --git a/tests/test-component-timeout.spec.ts b/tests/test-component-timeout.spec.ts new file mode 100644 index 0000000000..8fc2e70697 --- /dev/null +++ b/tests/test-component-timeout.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test'; +import { nextClick, resetClientStudyState } from './utils'; + +test('shows the timeout warning and auto-advances to the next component', async ({ page }) => { + await resetClientStudyState(page); + 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); + + 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 expect(page.getByText('Timeout auto-advance test complete.')).toBeVisible(); +}); diff --git a/yarn.lock b/yarn.lock index b616fa8424..0ea944c4bc 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" @@ -6412,9 +6578,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" @@ -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" @@ -7859,15 +8068,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" @@ -8365,6 +8569,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 +8601,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 +8620,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 +8777,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"