Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,5 @@ supabase/volumes/*
!supabase/volumes/db/
supabase/volumes/db/data
!supabase/volumes/api/

coverage/
6 changes: 0 additions & 6 deletions src/analysis/individualStudy/summary/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 28 additions & 4 deletions src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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));
Expand Down Expand Up @@ -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']);

Expand Down Expand Up @@ -345,13 +370,12 @@ export function ThinkAloudFooter({
const [timeString, setTimeString] = useState<string>('');

const provenanceLegendEntries = useMemo(() => {
const answer = participant?.answers[currentTrial];
if (!answer?.provenanceGraph) {
if (!provenanceGraph) {
return new Map<string, { label: string; color: string }>();
}

return buildProvenanceLegendEntries(Object.values(answer.provenanceGraph));
}, [participant, currentTrial]);
return buildProvenanceLegendEntries(Object.values(provenanceGraph));
}, [provenanceGraph]);

const tasksList = useMemo(() => orderedAnswers
.filter((answer) => answer.identifier && answer.componentName)
Expand Down
68 changes: 53 additions & 15 deletions src/components/audioAnalysis/AudioProvenanceVis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -62,6 +64,12 @@ export function AudioProvenanceVis({
} = useReplayContext();

const { storageEngine } = useStorageEngine();
const legacyProvenanceGraph = useMemo(
() => getLegacyStoredAnswerProvenance(answers[taskName]),
[answers, taskName],
);
const [storedProvenanceGraph, setStoredProvenanceGraph] = useState<StoredProvenance | null>(legacyProvenanceGraph);
const provenanceGraph = storedProvenanceGraph ?? legacyProvenanceGraph;

const [analysisHasAudio, _setAnalysisHasAudio] = useState(true);

Expand Down Expand Up @@ -97,8 +105,36 @@ export function AudioProvenanceVis({

const trrackForTrial = useRef<Trrack<object, string> | 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 });
Expand Down Expand Up @@ -176,48 +212,50 @@ 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) {
return;
}

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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -353,13 +391,13 @@ export function AudioProvenanceVis({
</Box>
) : null}

{xScale && taskName && answers[taskName]?.provenanceGraph
{xScale && taskName && provenanceGraph
? (
<TaskProvenanceTimeline
xScale={xScale}
trialName={taskName}
currentNode={currentGlobalNode?.name || ''}
answers={answers}
provenanceGraph={provenanceGraph}
width={waveSurferWidth || (width - margin.left - margin.right)}
height={25}
margin={margin}
Expand Down
49 changes: 19 additions & 30 deletions src/components/audioAnalysis/TaskProvenanceTimeline.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useMemo } from 'react';
import * as d3 from 'd3';
import { ParticipantData } from '../../storage/types';
import { TaskProvenanceNodes } from './TaskProvenanceNodes';
import type { StoredProvenance } from '../../store/types';
import { PROVENANCE_LOCATIONS } from '../../store/provenance';

export function TaskProvenanceTimeline({
xScale,
answers,
provenanceGraph,
width,
height,
currentNode,
Expand All @@ -14,7 +15,7 @@ export function TaskProvenanceTimeline({
margin,
}: {
xScale: d3.ScaleLinear<number, number>;
answers: ParticipantData['answers'];
provenanceGraph: StoredProvenance;
width: number;
height: number;
currentNode: string | null;
Expand All @@ -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 (
<TaskProvenanceNodes
key={name + provenanceArea}
height={height}
currentNode={currentNode}
xScale={newXScale}
provenance={graph}
/>
);
}
return null;
},
() => PROVENANCE_LOCATIONS.map((provenanceArea) => {
const graph = provenanceGraph[provenanceArea];
if (graph) {
return (
<TaskProvenanceNodes
key={`${trialName}-${provenanceArea}`}
height={height}
currentNode={currentNode}
xScale={newXScale}
provenance={graph}
/>
);

return provenanceGraphComponents;
}),
[currentNode, height, answers, trialName, newXScale],
}
return null;
}),
[currentNode, height, provenanceGraph, trialName, newXScale],
);

return (
Expand Down
33 changes: 31 additions & 2 deletions src/components/downloader/DownloadButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ParticipantDataWithStatus[]>);

Expand All @@ -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<ParticipantDataWithStatus[]>([]);
const [loadingProvenance, setLoadingProvenance] = useState(false);
const [loadingAudio, setLoadingAudio] = useState(false);
const [loadingScreenRecording, setLoadingScreenRecording] = useState(false);
const { storageEngine } = useStorageEngine();
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -98,6 +116,17 @@ export function DownloadButtons({
<IconTableExport />
</Button>
</Tooltip>
<Tooltip label={`${tooltipText} provenance as ZIP`}>
<Button
variant="light"
disabled={visibleParticipants.length === 0 && typeof visibleParticipants !== 'function'}
onClick={handleDownloadProvenance}
px={4}
loading={loadingProvenance}
>
<IconDownload />
</Button>
</Tooltip>
{hasAudio && (
<Tooltip label={`${tooltipText} audio & transcripts as ZIP`}>
<Button
Expand Down
Loading
Loading