diff --git a/.gitignore b/.gitignore index 26dd272055..0289daf5b5 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ supabase/volumes/* !supabase/volumes/db/ supabase/volumes/db/data !supabase/volumes/api/ + +coverage/ diff --git a/src/analysis/individualStudy/summary/utils.test.ts b/src/analysis/individualStudy/summary/utils.test.ts index 57a21812a7..32fb79a54a 100644 --- a/src/analysis/individualStudy/summary/utils.test.ts +++ b/src/analysis/individualStudy/summary/utils.test.ts @@ -99,12 +99,6 @@ function createMockAnswer(overrides: { incorrectAnswers: {}, startTime: overrides.startTime, endTime: overrides.endTime, - provenanceGraph: { - sidebar: undefined, - aboveStimulus: undefined, - belowStimulus: undefined, - stimulus: undefined, - }, windowEvents: [], timedOut: false, helpButtonClickedCount: 0, diff --git a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx index 7dc7b78c1e..52d431e27b 100644 --- a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx +++ b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx @@ -36,6 +36,7 @@ import { buildProvenanceLegendEntries, } from '../../../components/audioAnalysis/provenanceColors'; import { revisitPageId, syncChannel } from '../../../utils/syncReplay'; +import { getLegacyStoredAnswerProvenance } from '../../../store/provenance'; import { buildTaskNavigationTarget } from './taskNavigation'; const margin = { @@ -50,6 +51,29 @@ function getParticipantData(trrackId: string | undefined, storageEngine: Storage return null; } +function getLegacyProvenance(answer: unknown) { + return getLegacyStoredAnswerProvenance(answer); +} + +async function getTaskProvenance( + storageEngine: StorageEngine | undefined, + participantId: string, + currentTrial: string, + answer: unknown, +) { + const legacyProvenance = getLegacyProvenance(answer); + + if (!storageEngine || !participantId || !currentTrial) { + return legacyProvenance; + } + + try { + return await storageEngine.getProvenance(currentTrial, participantId) ?? legacyProvenance; + } catch { + return legacyProvenance; + } +} + async function getParticipantTags(authEmail: string, trrackId: string | undefined, studyId: string, storageEngine: StorageEngine | undefined) { if (storageEngine && trrackId) { return (await storageEngine.getAllParticipantAndTaskTags(authEmail, trrackId)); @@ -84,6 +108,7 @@ export function ThinkAloudFooter({ const participantId = useMemo(() => searchParams.get('participantId') || '', [searchParams]); const { value: participant } = useAsync(getParticipantData, [participantId, storageEngine]); + const { value: provenanceGraph } = useAsync(getTaskProvenance, [storageEngine, participantId, currentTrial, participant?.answers[currentTrial]]); const { value: taskTags, execute: pullTags } = useAsync(getTags, [storageEngine, 'task']); @@ -345,13 +370,12 @@ export function ThinkAloudFooter({ const [timeString, setTimeString] = useState(''); const provenanceLegendEntries = useMemo(() => { - const answer = participant?.answers[currentTrial]; - if (!answer?.provenanceGraph) { + if (!provenanceGraph) { return new Map(); } - return buildProvenanceLegendEntries(Object.values(answer.provenanceGraph)); - }, [participant, currentTrial]); + return buildProvenanceLegendEntries(Object.values(provenanceGraph)); + }, [provenanceGraph]); const tasksList = useMemo(() => orderedAnswers .filter((answer) => answer.identifier && answer.componentName) diff --git a/src/components/audioAnalysis/AudioProvenanceVis.tsx b/src/components/audioAnalysis/AudioProvenanceVis.tsx index 23586ee22b..456dbc281a 100644 --- a/src/components/audioAnalysis/AudioProvenanceVis.tsx +++ b/src/components/audioAnalysis/AudioProvenanceVis.tsx @@ -26,6 +26,8 @@ import { parseTrialOrder } from '../../utils/parseTrialOrder'; import { useUpdateProvenance } from './useUpdateProvenance'; import { useReplayContext } from '../../store/hooks/useReplay'; import { syncChannel, syncEmitter } from '../../utils/syncReplay'; +import type { StoredProvenance } from '../../store/types'; +import { getLegacyStoredAnswerProvenance } from '../../store/provenance'; const margin = { left: 20, top: 0, right: 20, bottom: 0, @@ -62,6 +64,12 @@ export function AudioProvenanceVis({ } = useReplayContext(); const { storageEngine } = useStorageEngine(); + const legacyProvenanceGraph = useMemo( + () => getLegacyStoredAnswerProvenance(answers[taskName]), + [answers, taskName], + ); + const [storedProvenanceGraph, setStoredProvenanceGraph] = useState(legacyProvenanceGraph); + const provenanceGraph = storedProvenanceGraph ?? legacyProvenanceGraph; const [analysisHasAudio, _setAnalysisHasAudio] = useState(true); @@ -97,8 +105,36 @@ export function AudioProvenanceVis({ const trrackForTrial = useRef | null>(null); + useEffect(() => { + let canceled = false; + + async function fetchProvenance() { + if (!taskName || !participantId || !storageEngine) { + setStoredProvenanceGraph(legacyProvenanceGraph); + return; + } + + try { + const storedProvenance = await storageEngine.getProvenance(taskName, participantId); + if (!canceled) { + setStoredProvenanceGraph(storedProvenance ?? legacyProvenanceGraph); + } + } catch { + if (!canceled) { + setStoredProvenanceGraph(legacyProvenanceGraph); + } + } + } + + fetchProvenance(); + + return () => { + canceled = true; + }; + }, [legacyProvenanceGraph, participantId, storageEngine, taskName]); + const _setCurrentResponseNodes = useEvent((node: string | null, location: ResponseBlockLocation) => { - const graph = answers[taskName]?.provenanceGraph[location]; + const graph = provenanceGraph?.[location]; if (graph && node) { if (!currentGlobalNode || graph.nodes[node].createdOn > currentGlobalNode.time || playTime < currentGlobalNode.time) { setCurrentGlobalNode({ name: node || '', time: graph.nodes[node].createdOn }); @@ -176,26 +212,28 @@ export function AudioProvenanceVis({ }; }, [answers, context, navigate, participantId, routerLocation.search, setSearchParams, studyId]); - useUpdateProvenance('aboveStimulus', playTime, answers[taskName]?.provenanceGraph.aboveStimulus, currentResponseNodes.aboveStimulus, _setCurrentResponseNodes, saveProvenance); + useUpdateProvenance('aboveStimulus', playTime, provenanceGraph?.aboveStimulus, currentResponseNodes.aboveStimulus, _setCurrentResponseNodes, saveProvenance); - useUpdateProvenance('belowStimulus', playTime, answers[taskName]?.provenanceGraph.belowStimulus, currentResponseNodes.belowStimulus, _setCurrentResponseNodes, saveProvenance); + useUpdateProvenance('belowStimulus', playTime, provenanceGraph?.belowStimulus, currentResponseNodes.belowStimulus, _setCurrentResponseNodes, saveProvenance); - useUpdateProvenance('sidebar', playTime, answers[taskName]?.provenanceGraph.sidebar, currentResponseNodes.sidebar, _setCurrentResponseNodes, saveProvenance); + useUpdateProvenance('sidebar', playTime, provenanceGraph?.sidebar, currentResponseNodes.sidebar, _setCurrentResponseNodes, saveProvenance); // Create an instance of trrack to ensure getState works, incase the saved state is not a full state node. useEffect(() => { - if (taskName && answers[taskName]?.provenanceGraph) { + trrackForTrial.current = null; + + if (taskName && provenanceGraph) { const reg = Registry.create(); const trrack = initializeTrrack({ registry: reg, initialState: {} }); - if (answers[taskName]?.provenanceGraph.stimulus) { - trrack.importObject(structuredClone(answers[taskName]?.provenanceGraph!.stimulus)); + if (provenanceGraph.stimulus) { + trrack.importObject(structuredClone(provenanceGraph.stimulus)); trrackForTrial.current = trrack; } } - }, [answers, taskName]); + }, [provenanceGraph, taskName]); const _setCurrentNode = useCallback((node: string | undefined) => { if (!node) { @@ -203,21 +241,21 @@ export function AudioProvenanceVis({ } if (taskName && trrackForTrial.current && context === 'provenanceVis' && saveProvenance) { - saveProvenance({ prov: trrackForTrial.current.getState(answers[taskName]?.provenanceGraph.stimulus?.nodes[node]), location: 'stimulus' }); + saveProvenance({ prov: trrackForTrial.current.getState(provenanceGraph?.stimulus?.nodes[node]), location: 'stimulus' }); trrackForTrial.current.to(node); } _setCurrentResponseNodes(node, 'stimulus'); setCurrentNode(node); - }, [taskName, context, _setCurrentResponseNodes, saveProvenance, answers]); + }, [taskName, context, _setCurrentResponseNodes, saveProvenance, provenanceGraph]); // use effect to control the current provenance node based on the changing playtime. useEffect(() => { - if (!taskName || !trrackForTrial.current || !answers[taskName]?.provenanceGraph) { + if (!taskName || !trrackForTrial.current || !provenanceGraph) { return; } - const provGraph = answers[taskName]?.provenanceGraph; + const provGraph = provenanceGraph; if (!provGraph.stimulus) { return; @@ -249,7 +287,7 @@ export function AudioProvenanceVis({ if (tempNode.id !== currentNode) { _setCurrentNode(tempNode.id); } - }, [_setCurrentNode, currentNode, participantId, playTime, taskName, answers]); + }, [_setCurrentNode, currentNode, participantId, playTime, taskName, provenanceGraph]); useEffect(() => { if (duration === 0) { @@ -353,13 +391,13 @@ export function AudioProvenanceVis({ ) : null} - {xScale && taskName && answers[taskName]?.provenanceGraph + {xScale && taskName && provenanceGraph ? ( ; - answers: ParticipantData['answers']; + provenanceGraph: StoredProvenance; width: number; height: number; currentNode: string | null; @@ -34,34 +35,22 @@ export function TaskProvenanceTimeline({ ); const provenanceNodes = useMemo( - () => Object.entries(answers) - .filter((entry) => (trialName ? trialName === entry[0] : true)) - .map((entry) => { - const [name, answer] = entry; - - const provenanceGraphComponents = Object.keys(answer.provenanceGraph).map( - (provenanceArea) => { - const graph = answer.provenanceGraph[ - provenanceArea as keyof typeof answer.provenanceGraph - ]; - if (graph) { - return ( - - ); - } - return null; - }, + () => PROVENANCE_LOCATIONS.map((provenanceArea) => { + const graph = provenanceGraph[provenanceArea]; + if (graph) { + return ( + ); - - return provenanceGraphComponents; - }), - [currentNode, height, answers, trialName, newXScale], + } + return null; + }), + [currentNode, height, provenanceGraph, trialName, newXScale], ); return ( diff --git a/src/components/downloader/DownloadButtons.tsx b/src/components/downloader/DownloadButtons.tsx index 1de8c858b9..4899893715 100644 --- a/src/components/downloader/DownloadButtons.tsx +++ b/src/components/downloader/DownloadButtons.tsx @@ -2,14 +2,14 @@ import { Button, Group, Tooltip, } from '@mantine/core'; import { - IconDatabaseExport, IconDeviceDesktopDown, IconMusicDown, IconTableExport, + IconDatabaseExport, IconDeviceDesktopDown, IconDownload, IconMusicDown, IconTableExport, } from '@tabler/icons-react'; import { useState } from 'react'; import { useDisclosure } from '@mantine/hooks'; import { DownloadTidy, download } from './DownloadTidy'; import { ParticipantDataWithStatus } from '../../storage/types'; import { useStorageEngine } from '../../storage/storageEngineHooks'; -import { downloadParticipantsAudioZip, downloadParticipantsScreenRecordingZip } from '../../utils/handleDownloadFiles'; +import { downloadParticipantsAudioZip, downloadParticipantsProvenanceZip, downloadParticipantsScreenRecordingZip } from '../../utils/handleDownloadFiles'; type ParticipantDataFetcher = ParticipantDataWithStatus[] | (() => Promise); @@ -18,6 +18,7 @@ export function DownloadButtons({ }: { visibleParticipants: ParticipantDataFetcher; studyId: string, gap?: string, fileName?: string | null; hasAudio?: boolean; hasScreenRecording?: boolean; }) { const [openDownload, { open, close }] = useDisclosure(false); const [participants, setParticipants] = useState([]); + const [loadingProvenance, setLoadingProvenance] = useState(false); const [loadingAudio, setLoadingAudio] = useState(false); const [loadingScreenRecording, setLoadingScreenRecording] = useState(false); const { storageEngine } = useStorageEngine(); @@ -56,6 +57,23 @@ export function DownloadButtons({ } }; + const handleDownloadProvenance = async () => { + setLoadingProvenance(true); + + try { + const currParticipants = await fetchParticipants(); + if (!storageEngine) return; + await downloadParticipantsProvenanceZip({ + storageEngine, + participants: currParticipants, + studyId, + fileName, + }); + } finally { + setLoadingProvenance(false); + } + }; + const handleDownloadScreenRecording = async () => { setLoadingScreenRecording(true); @@ -98,6 +116,17 @@ export function DownloadButtons({ + + + {hasAudio && (