Skip to content

Commit 3af76fb

Browse files
Separating provenance data from answers (#1230)
* moving provenance * ensuring fallback works * Persist provenance as separate assets * Add provenance bulk download export * Ignore coverage output * Fix type error in test --------- Co-authored-by: Jack Wilburn <jackwilburn@tutanota.com>
1 parent 26214e5 commit 3af76fb

18 files changed

Lines changed: 667 additions & 114 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,5 @@ supabase/volumes/*
3737
!supabase/volumes/db/
3838
supabase/volumes/db/data
3939
!supabase/volumes/api/
40+
41+
coverage/

src/analysis/individualStudy/summary/utils.test.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,6 @@ function createMockAnswer(overrides: {
9999
incorrectAnswers: {},
100100
startTime: overrides.startTime,
101101
endTime: overrides.endTime,
102-
provenanceGraph: {
103-
sidebar: undefined,
104-
aboveStimulus: undefined,
105-
belowStimulus: undefined,
106-
stimulus: undefined,
107-
},
108102
windowEvents: [],
109103
timedOut: false,
110104
helpButtonClickedCount: 0,

src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
buildProvenanceLegendEntries,
3737
} from '../../../components/audioAnalysis/provenanceColors';
3838
import { revisitPageId, syncChannel } from '../../../utils/syncReplay';
39+
import { getLegacyStoredAnswerProvenance } from '../../../store/provenance';
3940
import { buildTaskNavigationTarget } from './taskNavigation';
4041

4142
const margin = {
@@ -50,6 +51,29 @@ function getParticipantData(trrackId: string | undefined, storageEngine: Storage
5051
return null;
5152
}
5253

54+
function getLegacyProvenance(answer: unknown) {
55+
return getLegacyStoredAnswerProvenance(answer);
56+
}
57+
58+
async function getTaskProvenance(
59+
storageEngine: StorageEngine | undefined,
60+
participantId: string,
61+
currentTrial: string,
62+
answer: unknown,
63+
) {
64+
const legacyProvenance = getLegacyProvenance(answer);
65+
66+
if (!storageEngine || !participantId || !currentTrial) {
67+
return legacyProvenance;
68+
}
69+
70+
try {
71+
return await storageEngine.getProvenance(currentTrial, participantId) ?? legacyProvenance;
72+
} catch {
73+
return legacyProvenance;
74+
}
75+
}
76+
5377
async function getParticipantTags(authEmail: string, trrackId: string | undefined, studyId: string, storageEngine: StorageEngine | undefined) {
5478
if (storageEngine && trrackId) {
5579
return (await storageEngine.getAllParticipantAndTaskTags(authEmail, trrackId));
@@ -84,6 +108,7 @@ export function ThinkAloudFooter({
84108
const participantId = useMemo(() => searchParams.get('participantId') || '', [searchParams]);
85109

86110
const { value: participant } = useAsync(getParticipantData, [participantId, storageEngine]);
111+
const { value: provenanceGraph } = useAsync(getTaskProvenance, [storageEngine, participantId, currentTrial, participant?.answers[currentTrial]]);
87112

88113
const { value: taskTags, execute: pullTags } = useAsync(getTags, [storageEngine, 'task']);
89114

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

347372
const provenanceLegendEntries = useMemo(() => {
348-
const answer = participant?.answers[currentTrial];
349-
if (!answer?.provenanceGraph) {
373+
if (!provenanceGraph) {
350374
return new Map<string, { label: string; color: string }>();
351375
}
352376

353-
return buildProvenanceLegendEntries(Object.values(answer.provenanceGraph));
354-
}, [participant, currentTrial]);
377+
return buildProvenanceLegendEntries(Object.values(provenanceGraph));
378+
}, [provenanceGraph]);
355379

356380
const tasksList = useMemo(() => orderedAnswers
357381
.filter((answer) => answer.identifier && answer.componentName)

src/components/audioAnalysis/AudioProvenanceVis.tsx

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import { parseTrialOrder } from '../../utils/parseTrialOrder';
2626
import { useUpdateProvenance } from './useUpdateProvenance';
2727
import { useReplayContext } from '../../store/hooks/useReplay';
2828
import { syncChannel, syncEmitter } from '../../utils/syncReplay';
29+
import type { StoredProvenance } from '../../store/types';
30+
import { getLegacyStoredAnswerProvenance } from '../../store/provenance';
2931

3032
const margin = {
3133
left: 20, top: 0, right: 20, bottom: 0,
@@ -62,6 +64,12 @@ export function AudioProvenanceVis({
6264
} = useReplayContext();
6365

6466
const { storageEngine } = useStorageEngine();
67+
const legacyProvenanceGraph = useMemo(
68+
() => getLegacyStoredAnswerProvenance(answers[taskName]),
69+
[answers, taskName],
70+
);
71+
const [storedProvenanceGraph, setStoredProvenanceGraph] = useState<StoredProvenance | null>(legacyProvenanceGraph);
72+
const provenanceGraph = storedProvenanceGraph ?? legacyProvenanceGraph;
6573

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

@@ -97,8 +105,36 @@ export function AudioProvenanceVis({
97105

98106
const trrackForTrial = useRef<Trrack<object, string> | null>(null);
99107

108+
useEffect(() => {
109+
let canceled = false;
110+
111+
async function fetchProvenance() {
112+
if (!taskName || !participantId || !storageEngine) {
113+
setStoredProvenanceGraph(legacyProvenanceGraph);
114+
return;
115+
}
116+
117+
try {
118+
const storedProvenance = await storageEngine.getProvenance(taskName, participantId);
119+
if (!canceled) {
120+
setStoredProvenanceGraph(storedProvenance ?? legacyProvenanceGraph);
121+
}
122+
} catch {
123+
if (!canceled) {
124+
setStoredProvenanceGraph(legacyProvenanceGraph);
125+
}
126+
}
127+
}
128+
129+
fetchProvenance();
130+
131+
return () => {
132+
canceled = true;
133+
};
134+
}, [legacyProvenanceGraph, participantId, storageEngine, taskName]);
135+
100136
const _setCurrentResponseNodes = useEvent((node: string | null, location: ResponseBlockLocation) => {
101-
const graph = answers[taskName]?.provenanceGraph[location];
137+
const graph = provenanceGraph?.[location];
102138
if (graph && node) {
103139
if (!currentGlobalNode || graph.nodes[node].createdOn > currentGlobalNode.time || playTime < currentGlobalNode.time) {
104140
setCurrentGlobalNode({ name: node || '', time: graph.nodes[node].createdOn });
@@ -176,48 +212,50 @@ export function AudioProvenanceVis({
176212
};
177213
}, [answers, context, navigate, participantId, routerLocation.search, setSearchParams, studyId]);
178214

179-
useUpdateProvenance('aboveStimulus', playTime, answers[taskName]?.provenanceGraph.aboveStimulus, currentResponseNodes.aboveStimulus, _setCurrentResponseNodes, saveProvenance);
215+
useUpdateProvenance('aboveStimulus', playTime, provenanceGraph?.aboveStimulus, currentResponseNodes.aboveStimulus, _setCurrentResponseNodes, saveProvenance);
180216

181-
useUpdateProvenance('belowStimulus', playTime, answers[taskName]?.provenanceGraph.belowStimulus, currentResponseNodes.belowStimulus, _setCurrentResponseNodes, saveProvenance);
217+
useUpdateProvenance('belowStimulus', playTime, provenanceGraph?.belowStimulus, currentResponseNodes.belowStimulus, _setCurrentResponseNodes, saveProvenance);
182218

183-
useUpdateProvenance('sidebar', playTime, answers[taskName]?.provenanceGraph.sidebar, currentResponseNodes.sidebar, _setCurrentResponseNodes, saveProvenance);
219+
useUpdateProvenance('sidebar', playTime, provenanceGraph?.sidebar, currentResponseNodes.sidebar, _setCurrentResponseNodes, saveProvenance);
184220

185221
// Create an instance of trrack to ensure getState works, incase the saved state is not a full state node.
186222
useEffect(() => {
187-
if (taskName && answers[taskName]?.provenanceGraph) {
223+
trrackForTrial.current = null;
224+
225+
if (taskName && provenanceGraph) {
188226
const reg = Registry.create();
189227

190228
const trrack = initializeTrrack({ registry: reg, initialState: {} });
191229

192-
if (answers[taskName]?.provenanceGraph.stimulus) {
193-
trrack.importObject(structuredClone(answers[taskName]?.provenanceGraph!.stimulus));
230+
if (provenanceGraph.stimulus) {
231+
trrack.importObject(structuredClone(provenanceGraph.stimulus));
194232

195233
trrackForTrial.current = trrack;
196234
}
197235
}
198-
}, [answers, taskName]);
236+
}, [provenanceGraph, taskName]);
199237

200238
const _setCurrentNode = useCallback((node: string | undefined) => {
201239
if (!node) {
202240
return;
203241
}
204242

205243
if (taskName && trrackForTrial.current && context === 'provenanceVis' && saveProvenance) {
206-
saveProvenance({ prov: trrackForTrial.current.getState(answers[taskName]?.provenanceGraph.stimulus?.nodes[node]), location: 'stimulus' });
244+
saveProvenance({ prov: trrackForTrial.current.getState(provenanceGraph?.stimulus?.nodes[node]), location: 'stimulus' });
207245

208246
trrackForTrial.current.to(node);
209247
}
210248

211249
_setCurrentResponseNodes(node, 'stimulus');
212250
setCurrentNode(node);
213-
}, [taskName, context, _setCurrentResponseNodes, saveProvenance, answers]);
251+
}, [taskName, context, _setCurrentResponseNodes, saveProvenance, provenanceGraph]);
214252

215253
// use effect to control the current provenance node based on the changing playtime.
216254
useEffect(() => {
217-
if (!taskName || !trrackForTrial.current || !answers[taskName]?.provenanceGraph) {
255+
if (!taskName || !trrackForTrial.current || !provenanceGraph) {
218256
return;
219257
}
220-
const provGraph = answers[taskName]?.provenanceGraph;
258+
const provGraph = provenanceGraph;
221259

222260
if (!provGraph.stimulus) {
223261
return;
@@ -249,7 +287,7 @@ export function AudioProvenanceVis({
249287
if (tempNode.id !== currentNode) {
250288
_setCurrentNode(tempNode.id);
251289
}
252-
}, [_setCurrentNode, currentNode, participantId, playTime, taskName, answers]);
290+
}, [_setCurrentNode, currentNode, participantId, playTime, taskName, provenanceGraph]);
253291

254292
useEffect(() => {
255293
if (duration === 0) {
@@ -353,13 +391,13 @@ export function AudioProvenanceVis({
353391
</Box>
354392
) : null}
355393

356-
{xScale && taskName && answers[taskName]?.provenanceGraph
394+
{xScale && taskName && provenanceGraph
357395
? (
358396
<TaskProvenanceTimeline
359397
xScale={xScale}
360398
trialName={taskName}
361399
currentNode={currentGlobalNode?.name || ''}
362-
answers={answers}
400+
provenanceGraph={provenanceGraph}
363401
width={waveSurferWidth || (width - margin.left - margin.right)}
364402
height={25}
365403
margin={margin}

src/components/audioAnalysis/TaskProvenanceTimeline.tsx

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { useMemo } from 'react';
22
import * as d3 from 'd3';
3-
import { ParticipantData } from '../../storage/types';
43
import { TaskProvenanceNodes } from './TaskProvenanceNodes';
4+
import type { StoredProvenance } from '../../store/types';
5+
import { PROVENANCE_LOCATIONS } from '../../store/provenance';
56

67
export function TaskProvenanceTimeline({
78
xScale,
8-
answers,
9+
provenanceGraph,
910
width,
1011
height,
1112
currentNode,
@@ -14,7 +15,7 @@ export function TaskProvenanceTimeline({
1415
margin,
1516
}: {
1617
xScale: d3.ScaleLinear<number, number>;
17-
answers: ParticipantData['answers'];
18+
provenanceGraph: StoredProvenance;
1819
width: number;
1920
height: number;
2021
currentNode: string | null;
@@ -34,34 +35,22 @@ export function TaskProvenanceTimeline({
3435
);
3536

3637
const provenanceNodes = useMemo(
37-
() => Object.entries(answers)
38-
.filter((entry) => (trialName ? trialName === entry[0] : true))
39-
.map((entry) => {
40-
const [name, answer] = entry;
41-
42-
const provenanceGraphComponents = Object.keys(answer.provenanceGraph).map(
43-
(provenanceArea) => {
44-
const graph = answer.provenanceGraph[
45-
provenanceArea as keyof typeof answer.provenanceGraph
46-
];
47-
if (graph) {
48-
return (
49-
<TaskProvenanceNodes
50-
key={name + provenanceArea}
51-
height={height}
52-
currentNode={currentNode}
53-
xScale={newXScale}
54-
provenance={graph}
55-
/>
56-
);
57-
}
58-
return null;
59-
},
38+
() => PROVENANCE_LOCATIONS.map((provenanceArea) => {
39+
const graph = provenanceGraph[provenanceArea];
40+
if (graph) {
41+
return (
42+
<TaskProvenanceNodes
43+
key={`${trialName}-${provenanceArea}`}
44+
height={height}
45+
currentNode={currentNode}
46+
xScale={newXScale}
47+
provenance={graph}
48+
/>
6049
);
61-
62-
return provenanceGraphComponents;
63-
}),
64-
[currentNode, height, answers, trialName, newXScale],
50+
}
51+
return null;
52+
}),
53+
[currentNode, height, provenanceGraph, trialName, newXScale],
6554
);
6655

6756
return (

src/components/downloader/DownloadButtons.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ import {
22
Button, Group, Tooltip,
33
} from '@mantine/core';
44
import {
5-
IconDatabaseExport, IconDeviceDesktopDown, IconMusicDown, IconTableExport,
5+
IconDatabaseExport, IconDeviceDesktopDown, IconDownload, IconMusicDown, IconTableExport,
66
} from '@tabler/icons-react';
77
import { useState } from 'react';
88
import { useDisclosure } from '@mantine/hooks';
99
import { DownloadTidy, download } from './DownloadTidy';
1010
import { ParticipantDataWithStatus } from '../../storage/types';
1111
import { useStorageEngine } from '../../storage/storageEngineHooks';
12-
import { downloadParticipantsAudioZip, downloadParticipantsScreenRecordingZip } from '../../utils/handleDownloadFiles';
12+
import { downloadParticipantsAudioZip, downloadParticipantsProvenanceZip, downloadParticipantsScreenRecordingZip } from '../../utils/handleDownloadFiles';
1313

1414
type ParticipantDataFetcher = ParticipantDataWithStatus[] | (() => Promise<ParticipantDataWithStatus[]>);
1515

@@ -18,6 +18,7 @@ export function DownloadButtons({
1818
}: { visibleParticipants: ParticipantDataFetcher; studyId: string, gap?: string, fileName?: string | null; hasAudio?: boolean; hasScreenRecording?: boolean; }) {
1919
const [openDownload, { open, close }] = useDisclosure(false);
2020
const [participants, setParticipants] = useState<ParticipantDataWithStatus[]>([]);
21+
const [loadingProvenance, setLoadingProvenance] = useState(false);
2122
const [loadingAudio, setLoadingAudio] = useState(false);
2223
const [loadingScreenRecording, setLoadingScreenRecording] = useState(false);
2324
const { storageEngine } = useStorageEngine();
@@ -56,6 +57,23 @@ export function DownloadButtons({
5657
}
5758
};
5859

60+
const handleDownloadProvenance = async () => {
61+
setLoadingProvenance(true);
62+
63+
try {
64+
const currParticipants = await fetchParticipants();
65+
if (!storageEngine) return;
66+
await downloadParticipantsProvenanceZip({
67+
storageEngine,
68+
participants: currParticipants,
69+
studyId,
70+
fileName,
71+
});
72+
} finally {
73+
setLoadingProvenance(false);
74+
}
75+
};
76+
5977
const handleDownloadScreenRecording = async () => {
6078
setLoadingScreenRecording(true);
6179

@@ -98,6 +116,17 @@ export function DownloadButtons({
98116
<IconTableExport />
99117
</Button>
100118
</Tooltip>
119+
<Tooltip label={`${tooltipText} provenance as ZIP`}>
120+
<Button
121+
variant="light"
122+
disabled={visibleParticipants.length === 0 && typeof visibleParticipants !== 'function'}
123+
onClick={handleDownloadProvenance}
124+
px={4}
125+
loading={loadingProvenance}
126+
>
127+
<IconDownload />
128+
</Button>
129+
</Tooltip>
101130
{hasAudio && (
102131
<Tooltip label={`${tooltipText} audio & transcripts as ZIP`}>
103132
<Button

0 commit comments

Comments
 (0)