Skip to content

Commit 0ce4258

Browse files
authored
Merge pull request #1283 from revisit-studies/ew/quanlitative_code
Add qualitative coding download functionality for JSON and CSV
2 parents e9efbd6 + 0ef9150 commit 0ce4258

7 files changed

Lines changed: 264 additions & 22 deletions

File tree

src/analysis/tests/thinkAloudUtils.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,25 @@ describe.each([
4343
expect(initial).toEqual({ participantTags: [], taskTags: {} });
4444
});
4545

46+
test('participant/task tag reads only create storage records when requested', async () => {
47+
const participant = await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata);
48+
const storagePath = `audio/transcriptAndTags/${ownerKey}/${participant.participantId}`;
49+
50+
const readOnlyDefault = await storageEngine.getParticipantAndTaskTags(ownerKey, participant.participantId);
51+
expect(readOnlyDefault).toEqual({ participantTags: [], taskTags: {} });
52+
53+
// @ts-expect-error using protected method for testing
54+
const afterReadOnlyDefault = await storageEngine._getFromStorage(storagePath, 'participantTags');
55+
expect(afterReadOnlyDefault).toBeNull();
56+
57+
const createIfMissingDefault = await storageEngine.getParticipantAndTaskTags(ownerKey, participant.participantId, true);
58+
expect(createIfMissingDefault).toEqual({ participantTags: [], taskTags: {} });
59+
60+
// @ts-expect-error using protected method for testing
61+
const afterCreateIfMissing = await storageEngine._getFromStorage(storagePath, 'participantTags');
62+
expect(afterCreateIfMissing).toEqual({ participantTags: [], taskTags: {} });
63+
});
64+
4665
test('get participant/task tag defaults is stable across repeated reads', async () => {
4766
const participant = await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata);
4867

src/components/downloader/DownloadButtons.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,27 @@ import { DownloadTidy, download } from './DownloadTidy';
1010
import { ParticipantDataWithStatus } from '../../storage/types';
1111
import { useStorageEngine } from '../../storage/storageEngineHooks';
1212
import { downloadParticipantsAudioZip, downloadParticipantsProvenanceZip, downloadParticipantsScreenRecordingZip } from '../../utils/handleDownloadFiles';
13+
import { useAuth } from '../../store/hooks/useAuth';
14+
import type { StorageEngine } from '../../storage/engines/types';
15+
import { getParticipantQualitativeCodes } from './qualitativeCodes';
16+
import type { DownloadedQualitativeCodes } from './qualitativeCodes';
1317

1418
type ParticipantDataFetcher = ParticipantDataWithStatus[] | (() => Promise<ParticipantDataWithStatus[]>);
19+
type ParticipantDataWithQualitativeCodes = ParticipantDataWithStatus & { qualitativeCodes: DownloadedQualitativeCodes };
20+
21+
async function attachQualitativeCodesToParticipants(
22+
participants: ParticipantDataWithStatus[],
23+
storageEngine: StorageEngine | undefined,
24+
authEmail: string,
25+
): Promise<ParticipantDataWithQualitativeCodes[]> {
26+
return Promise.all(participants.map(async (participant) => {
27+
const qualitativeCodes = await getParticipantQualitativeCodes(storageEngine, authEmail, participant);
28+
return {
29+
...participant,
30+
qualitativeCodes,
31+
};
32+
}));
33+
}
1534

1635
export function DownloadButtons({
1736
visibleParticipants, studyId, gap, fileName, hasAudio, hasScreenRecording,
@@ -22,6 +41,7 @@ export function DownloadButtons({
2241
const [loadingAudio, setLoadingAudio] = useState(false);
2342
const [loadingScreenRecording, setLoadingScreenRecording] = useState(false);
2443
const { storageEngine } = useStorageEngine();
44+
const auth = useAuth();
2545

2646
const fetchParticipants = async () => {
2747
const currParticipants = typeof visibleParticipants === 'function' ? await visibleParticipants() : visibleParticipants;
@@ -30,8 +50,13 @@ export function DownloadButtons({
3050

3151
const handleDownloadJSON = async () => {
3252
const currParticipants = await fetchParticipants();
53+
const participantsWithQualitativeCodes = await attachQualitativeCodesToParticipants(
54+
currParticipants,
55+
storageEngine,
56+
auth.user.user?.email || 'temp',
57+
);
3358
const currFileName = fileName ? `${fileName}.json` : `${studyId}_all.json`;
34-
download(JSON.stringify(currParticipants, null, 2), currFileName);
59+
download(JSON.stringify(participantsWithQualitativeCodes, null, 2), currFileName);
3560
};
3661

3762
const handleOpenTidy = async () => {

src/components/downloader/DownloadTidy.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ import { StorageEngine } from '../../storage/engines/types';
2323
import { useStorageEngine } from '../../storage/storageEngineHooks';
2424
import { FirebaseStorageEngine } from '../../storage/engines/FirebaseStorageEngine';
2525
import { useAsync } from '../../store/hooks/useAsync';
26+
import { useAuth } from '../../store/hooks/useAuth';
2627
import { getCleanedDuration } from '../../utils/getCleanedDuration';
2728
import { showNotification } from '../../utils/notifications';
2829
import { studyComponentToIndividualComponent } from '../../utils/handleComponentInheritance';
2930
import { parseConditionParam } from '../../utils/handleConditionLogic';
31+
import { getAnswerIdentifier, getParticipantQualitativeCodes } from './qualitativeCodes';
32+
import type { DownloadedQualitativeCodes } from './qualitativeCodes';
3033

3134
const OPTIONAL_COMMON_PROPS = [
3235
'condition',
@@ -50,6 +53,8 @@ const OPTIONAL_COMMON_PROPS = [
5053
'responseMax',
5154
'configHash',
5255
'metaData',
56+
'participantTags',
57+
'taskTags',
5358
] as const;
5459

5560
const REQUIRED_PROPS = [
@@ -105,23 +110,25 @@ function participantDataToRows(
105110
properties: Property[],
106111
studyConfig?: StudyConfig,
107112
transcripts?: Record<string, string | null>,
113+
qualitativeCodes?: DownloadedQualitativeCodes,
108114
): [TidyRow[], string[]] {
109115
const percentComplete = ((Object.entries(participant.answers).filter(([_, entry]) => entry.endTime !== -1).length / (Object.entries(participant.answers).length)) * 100).toFixed(2);
110116
const newHeaders = new Set<string>();
111117
const participantConditions = parseConditionParam(participant.conditions ?? participant.searchParams?.condition);
112118
const conditionValue = participantConditions.length > 0 ? participantConditions.join(',') : 'default';
113119
const metaData = JSON.stringify(participant.metadata);
120+
const participantQualitativeTags = JSON.stringify(qualitativeCodes?.participantTags ?? []);
114121

115122
return [[
116-
{
123+
...(properties.includes('participantTags') ? [{
117124
participantId: participant.participantId,
118125
trialId: 'participantTags',
119126
trialOrder: null,
120127
responseId: 'participantTags',
121-
answer: JSON.stringify(participant.participantTags),
128+
answer: participantQualitativeTags,
122129
...(properties.includes('condition') ? { condition: conditionValue } : {}),
123130
...(properties.includes('stage') ? { stage: participant.stage } : {}),
124-
},
131+
}] : []),
125132
...(properties.includes('metaData') ? [{
126133
participantId: participant.participantId,
127134
trialId: 'metaData',
@@ -137,6 +144,8 @@ function participantDataToRows(
137144
const { trialOrder } = trialAnswer;
138145
const trialConfig = studyConfig?.components?.[trialId];
139146
const completeComponent = trialConfig && studyConfig ? studyComponentToIndividualComponent(trialConfig, studyConfig) : undefined;
147+
const identifier = getAnswerIdentifier(trialAnswer);
148+
const taskQualitativeTags = JSON.stringify(qualitativeCodes?.taskTags[identifier] ?? []);
140149

141150
const duration = trialAnswer.endTime === -1 ? undefined : trialAnswer.endTime - trialAnswer.startTime;
142151
const cleanedDuration = getCleanedDuration(trialAnswer);
@@ -189,8 +198,10 @@ function participantDataToRows(
189198
if (properties.includes('answer')) {
190199
tidyRow.answer = typeof value === 'object' ? JSON.stringify(value) : value;
191200
}
201+
if (properties.includes('taskTags')) {
202+
tidyRow.taskTags = taskQualitativeTags;
203+
}
192204
if (properties.includes('transcript')) {
193-
const identifier = trialAnswer.identifier || `${trialId}_${trialOrder}`;
194205
tidyRow.transcript = transcripts?.[`${participant.participantId}_${identifier}`] ?? undefined;
195206
}
196207
if (properties.includes('correctAnswer')) {
@@ -261,6 +272,7 @@ export async function getTableData(
261272
storageEngine: StorageEngine | undefined,
262273
studyId: string,
263274
hasAudio?: boolean,
275+
authEmail = 'temp',
264276
) {
265277
if (!storageEngine) {
266278
return { header: [], rows: [], missingConfigCount: 0 };
@@ -280,7 +292,7 @@ export async function getTableData(
280292
.filter((answer) => ((answer?.endTime ?? -1) > 0) || ((answer?.startTime ?? -1) > 0))
281293
.map((answer) => ({ answer, participantId: p.participantId })));
282294
const tasks = allAnswers.map(({ answer, participantId }) => async () => {
283-
const identifier = answer.identifier || `${answer.componentName}_${answer.trialOrder}`;
295+
const identifier = getAnswerIdentifier(answer);
284296
const key = `${participantId}_${identifier}`;
285297

286298
try {
@@ -300,14 +312,19 @@ export async function getTableData(
300312
});
301313
const header = combinedProperties
302314
.filter((p) => p !== 'condition' || hasCondition)
303-
.filter((p) => p !== 'metaData');
315+
.filter((p) => p !== 'metaData')
316+
.filter((p) => p !== 'participantTags');
304317
const allData = await Promise.all(data.map(async (participant) => {
305318
const participantConfig = allConfigs[participant.participantConfigHash];
319+
const qualitativeCodes = selectedProperties.includes('participantTags') || selectedProperties.includes('taskTags')
320+
? await getParticipantQualitativeCodes(storageEngine, authEmail, participant)
321+
: undefined;
306322
const partDataToRows = await participantDataToRows(
307323
participant,
308324
combinedProperties,
309325
hasStoredStudyConfig(participantConfig) ? participantConfig : undefined,
310326
transcripts,
327+
qualitativeCodes,
311328
);
312329

313330
return partDataToRows;
@@ -351,6 +368,7 @@ export function DownloadTidy({
351368
const [isDownloading, setIsDownloading] = useState(false);
352369
const [downloadProgress, setDownloadProgress] = useState(0);
353370
const [showTranscriptWarning, setShowTranscriptWarning] = useState(false);
371+
const auth = useAuth();
354372

355373
const [selectedProperties, setSelectedProperties] = useState<Array<OptionalProperty>>([
356374
'condition',
@@ -369,7 +387,7 @@ export function DownloadTidy({
369387
]);
370388

371389
const { storageEngine } = useStorageEngine();
372-
const { value: tableData, status: tableDataStatus, error: tableError } = useAsync(getTableData, [selectedProperties, data, storageEngine, studyId, hasAudio]);
390+
const { value: tableData, status: tableDataStatus, error: tableError } = useAsync(getTableData, [selectedProperties, data, storageEngine, studyId, hasAudio, auth.user.user?.email || 'temp']);
373391
const isFirebase = storageEngine?.getEngine() === 'firebase';
374392
const transcriptAvailable = isFirebase && !!hasAudio;
375393
const selectedParticipantCount = data.length;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { ParticipantTags, Tag } from '../../analysis/individualStudy/thinkAloud/types';
2+
import type { StorageEngine } from '../../storage/engines/types';
3+
import type { ParticipantDataWithStatus } from '../../storage/types';
4+
import type { StoredAnswer } from '../../store/types';
5+
import { parseTrialOrder } from '../../utils/parseTrialOrder';
6+
7+
export type DownloadedTag = Pick<Tag, 'id' | 'name'>;
8+
9+
export interface DownloadedQualitativeCodes {
10+
participantTags: DownloadedTag[];
11+
taskTags: Record<string, DownloadedTag[]>;
12+
}
13+
14+
const removeTagColor = ({ id, name }: Tag): DownloadedTag => ({ id, name });
15+
16+
export const createEmptyQualitativeCodes = (): DownloadedQualitativeCodes => ({
17+
participantTags: [],
18+
taskTags: {},
19+
});
20+
21+
export function getAnswerIdentifier(answer: Pick<StoredAnswer, 'componentName' | 'identifier' | 'trialOrder'>) {
22+
return answer.identifier || `${answer.componentName}_${answer.trialOrder}`;
23+
}
24+
25+
export function normalizeQualitativeCodes(
26+
qualitativeCodes: ParticipantTags | undefined,
27+
participant: ParticipantDataWithStatus,
28+
): DownloadedQualitativeCodes {
29+
const storedTaskTags = Object.fromEntries(
30+
Object.entries(qualitativeCodes?.taskTags ?? {}).map(([identifier, tags]) => [identifier, tags.map(removeTagColor)]),
31+
);
32+
const taskTags: DownloadedQualitativeCodes['taskTags'] = {};
33+
34+
Object.values(participant.answers).sort((a, b) => {
35+
const aOrder = parseTrialOrder(a.trialOrder);
36+
const bOrder = parseTrialOrder(b.trialOrder);
37+
const aStep = aOrder.step ?? Number.MAX_SAFE_INTEGER;
38+
const bStep = bOrder.step ?? Number.MAX_SAFE_INTEGER;
39+
const stepDiff = aStep - bStep;
40+
41+
if (stepDiff !== 0) {
42+
return stepDiff;
43+
}
44+
45+
return (aOrder.funcIndex ?? -1) - (bOrder.funcIndex ?? -1);
46+
}).forEach((answer) => {
47+
const identifier = getAnswerIdentifier(answer);
48+
taskTags[identifier] = storedTaskTags[identifier] ?? [];
49+
});
50+
51+
Object.entries(storedTaskTags).forEach(([identifier, tags]) => {
52+
taskTags[identifier] ??= tags;
53+
});
54+
55+
return {
56+
participantTags: qualitativeCodes?.participantTags.map(removeTagColor) ?? [],
57+
taskTags,
58+
};
59+
}
60+
61+
export async function getParticipantQualitativeCodes(
62+
storageEngine: StorageEngine | undefined,
63+
authEmail: string,
64+
participant: ParticipantDataWithStatus,
65+
) {
66+
if (!storageEngine) {
67+
return normalizeQualitativeCodes(undefined, participant);
68+
}
69+
70+
try {
71+
const qualitativeCodes = await storageEngine.getParticipantAndTaskTags(authEmail, participant.participantId, false);
72+
return normalizeQualitativeCodes(qualitativeCodes, participant);
73+
} catch {
74+
return normalizeQualitativeCodes(undefined, participant);
75+
}
76+
}

src/components/downloader/tests/DownloadButtons.spec.tsx

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,27 @@ import { DownloadButtons } from '../DownloadButtons';
1010
import { download } from '../DownloadTidy';
1111
import { downloadParticipantsAudioZip, downloadParticipantsScreenRecordingZip } from '../../../utils/handleDownloadFiles';
1212
import type { ParticipantDataWithStatus } from '../../../storage/types';
13+
import { makeStoredAnswer } from '../../../tests/utils';
1314

1415
// ── mocks ─────────────────────────────────────────────────────────────────────
1516

17+
const mockState = vi.hoisted(() => ({
18+
storageEngine: {
19+
getEngine: vi.fn(() => 'firebase'),
20+
getParticipantAndTaskTags: vi.fn(),
21+
},
22+
}));
23+
1624
vi.mock('../../../storage/storageEngineHooks', () => ({
17-
useStorageEngine: () => ({ storageEngine: { getEngine: () => 'firebase' } }),
25+
useStorageEngine: () => ({ storageEngine: mockState.storageEngine }),
26+
}));
27+
28+
vi.mock('../../../store/hooks/useAuth', () => ({
29+
useAuth: () => ({
30+
user: {
31+
user: { email: 'analyst@example.com' },
32+
},
33+
}),
1834
}));
1935

2036
vi.mock('@mantine/hooks', () => ({
@@ -59,6 +75,11 @@ const baseProps = {
5975
visibleParticipants: [] as ParticipantDataWithStatus[],
6076
};
6177

78+
beforeEach(() => {
79+
mockState.storageEngine.getEngine.mockReturnValue('firebase');
80+
mockState.storageEngine.getParticipantAndTaskTags.mockResolvedValue({ participantTags: [], taskTags: {} });
81+
});
82+
6283
// ── tests ─────────────────────────────────────────────────────────────────────
6384

6485
describe('DownloadButtons', () => {
@@ -169,6 +190,43 @@ describe('DownloadButtons click handlers', () => {
169190
);
170191
});
171192

193+
test('JSON button includes TA participant and task qualitative codes', async () => {
194+
const participantTag = { id: 'participant-code', name: 'Hesitation', color: '#ff0000' };
195+
const taskTag = { id: 'task-code', name: 'Chart Reading', color: '#0000ff' };
196+
const participantWithAnswers = {
197+
...participant,
198+
answers: {
199+
trial_1: makeStoredAnswer({ identifier: 'trial_1', trialOrder: '1' }),
200+
trial_0: makeStoredAnswer({ identifier: 'trial_0', trialOrder: '0' }),
201+
},
202+
};
203+
mockState.storageEngine.getParticipantAndTaskTags.mockResolvedValueOnce({
204+
participantTags: [participantTag],
205+
taskTags: {
206+
trial_1: [],
207+
trial_0: [taskTag],
208+
},
209+
});
210+
211+
await act(async () => {
212+
render(<DownloadButtons studyId="test-study" visibleParticipants={[participantWithAnswers]} />);
213+
});
214+
215+
const jsonBtn = screen.getByText('json-icon').closest('button')!;
216+
await act(async () => { fireEvent.click(jsonBtn); });
217+
218+
expect(mockState.storageEngine.getParticipantAndTaskTags).toHaveBeenCalledWith('analyst@example.com', 'p1', false);
219+
const exportedJson = vi.mocked(download).mock.calls[0][0];
220+
expect(JSON.parse(exportedJson)[0].qualitativeCodes).toEqual({
221+
participantTags: [{ id: participantTag.id, name: participantTag.name }],
222+
taskTags: {
223+
trial_0: [{ id: taskTag.id, name: taskTag.name }],
224+
trial_1: [],
225+
},
226+
});
227+
expect(Object.keys(JSON.parse(exportedJson)[0].qualitativeCodes.taskTags)).toEqual(['trial_0', 'trial_1']);
228+
});
229+
172230
test('JSON button uses custom fileName when provided', async () => {
173231
await act(async () => {
174232
render(<DownloadButtons studyId="test-study" visibleParticipants={[participant]} fileName="custom" />);

0 commit comments

Comments
 (0)