Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
89 changes: 88 additions & 1 deletion src/analysis/individualStudy/management/DataManagementItem.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import {
Text, LoadingOverlay, Box, Title, Flex, Modal, TextInput, Button, Tooltip, Space, Table,
} from '@mantine/core';
import { useCallback, useEffect, useState } from 'react';
import {
useCallback, useEffect, useRef, useState,
} from 'react';
import { IconTrashX, IconRefresh, IconPencil } from '@tabler/icons-react';
import { openConfirmModal } from '@mantine/modals';
import { useStorageEngine } from '../../../storage/storageEngineHooks';
import { showNotification, RevisitNotification } from '../../../utils/notifications';
import { DownloadButtons } from '../../../components/downloader/DownloadButtons';
import { ActionResponse, SnapshotDocContent } from '../../../storage/engines/types';
import { ParticipantDataWithStatus } from '../../../storage/types';
import {
SnapshotParticipantCounts,
calculateSnapshotParticipantCounts,
} from '../../../storage/engines/utils/snapshotParticipantCounts';

type SnapshotAction =
| { type: 'create', archive: boolean }
Expand All @@ -32,6 +38,8 @@ export function DataManagementItem({ studyId, refresh }: { studyId: string, refr

const [loading, setLoading] = useState<boolean>(false);
const [snapshotListLoading, setSnapshotListLoading] = useState<boolean>(false);
const [snapshotCountStatus, setSnapshotCountStatus] = useState<Record<string, 'loading' | 'failed'>>({});
const snapshotCountBackfills = useRef(new Set<string>());

const { storageEngine } = useStorageEngine();

Expand All @@ -50,6 +58,63 @@ export function DataManagementItem({ studyId, refresh }: { studyId: string, refr
refreshSnapshots();
}, [refreshSnapshots]);

useEffect(() => {
if (!storageEngine) {
return undefined;
}

let isCancelled = false;

Object.entries(snapshots).forEach(([snapshotKey, snapshotItem]) => {
if (snapshotItem.participantCounts || snapshotCountBackfills.current.has(snapshotKey)) {
return;
}

snapshotCountBackfills.current.add(snapshotKey);
setSnapshotCountStatus((previous) => ({ ...previous, [snapshotKey]: 'loading' }));
const strippedFilename = snapshotKey.slice(snapshotKey.indexOf('-') + 1);

storageEngine.getAllParticipantsData(strippedFilename)
.then(async (participants) => {
const participantCounts = calculateSnapshotParticipantCounts(participants);
await storageEngine.updateSnapshotParticipantCounts(studyId, snapshotKey, participantCounts);

if (isCancelled) {
return;
}

setSnapshots((previousSnapshots) => {
if (!previousSnapshots[snapshotKey]) {
return previousSnapshots;
}

return {
...previousSnapshots,
[snapshotKey]: {
...previousSnapshots[snapshotKey],
participantCounts,
},
};
});
setSnapshotCountStatus((previous) => {
const remaining = { ...previous };
delete remaining[snapshotKey];
return remaining;
});
})
.catch((error) => {
console.error(`Failed to backfill participant counts for snapshot ${snapshotKey}:`, error);
Comment thread
jaykim1213 marked this conversation as resolved.
if (!isCancelled) {
setSnapshotCountStatus((previous) => ({ ...previous, [snapshotKey]: 'failed' }));
}
});
});

return () => {
isCancelled = true;
};
}, [snapshots, storageEngine, studyId]);

if (!storageEngine) {
return null;
}
Expand Down Expand Up @@ -139,6 +204,22 @@ export function DataManagementItem({ studyId, refresh }: { studyId: string, refr
return await storageEngine.getAllParticipantsData(strippedFilename);
};

const renderParticipantCount = (
snapshotKey: string,
participantCounts: SnapshotParticipantCounts | undefined,
countKey: keyof SnapshotParticipantCounts,
) => {
if (participantCounts) {
return participantCounts[countKey];
}

if (snapshotCountStatus[snapshotKey] === 'failed') {
return 'Unavailable';
}

return 'Loading...';
};

return (
<>
<Title order={4} mb="sm">Data Management</Title>
Expand Down Expand Up @@ -230,6 +311,9 @@ export function DataManagementItem({ studyId, refresh }: { studyId: string, refr
<Table.Tr>
<Table.Th>Snapshot Name</Table.Th>
<Table.Th>Date Created</Table.Th>
<Table.Th>Completed</Table.Th>
<Table.Th>In Progress</Table.Th>
<Table.Th>Rejected</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
Expand All @@ -239,6 +323,9 @@ export function DataManagementItem({ studyId, refresh }: { studyId: string, refr
<Table.Tr key={key}>
<Table.Td>{snapshotItem.name}</Table.Td>
<Table.Td>{getDateFromSnapshotName(key)}</Table.Td>
<Table.Td>{renderParticipantCount(key, snapshotItem.participantCounts, 'completed')}</Table.Td>
<Table.Td>{renderParticipantCount(key, snapshotItem.participantCounts, 'inProgress')}</Table.Td>
<Table.Td>{renderParticipantCount(key, snapshotItem.participantCounts, 'rejected')}</Table.Td>
<Table.Td>
<Flex>
<Tooltip label="Rename">
Expand Down
66 changes: 64 additions & 2 deletions src/analysis/individualStudy/management/tests/ManageView.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import {
render, act, cleanup, screen, fireEvent,
render, act, cleanup, screen, fireEvent, waitFor,
} from '@testing-library/react';
import {
afterEach, beforeEach, describe, expect, test, vi,
Expand All @@ -25,6 +25,7 @@ let mockStorageEngine: {
restoreSnapshot: ReturnType<typeof vi.fn>;
removeSnapshotOrLive: ReturnType<typeof vi.fn>;
getAllParticipantsData: ReturnType<typeof vi.fn>;
updateSnapshotParticipantCounts: ReturnType<typeof vi.fn>;
} | undefined;

vi.mock('../../../../storage/storageEngineHooks', () => ({
Expand Down Expand Up @@ -119,6 +120,7 @@ const makeEngine = () => ({
restoreSnapshot: vi.fn().mockResolvedValue(successResponse),
removeSnapshotOrLive: vi.fn().mockResolvedValue(successResponse),
getAllParticipantsData: vi.fn().mockResolvedValue([]),
updateSnapshotParticipantCounts: vi.fn().mockResolvedValue(undefined),
});

describe('ManageView', () => {
Expand Down Expand Up @@ -334,15 +336,75 @@ describe('ManageView', () => {

test('DataManagementItem renders snapshot table rows when snapshots exist', async () => {
mockStorageEngine!.getSnapshots.mockResolvedValue({
'test-study-snapshot-2026T01:00': { name: 'my-snapshot' },
'test-study-snapshot-2026T01:00': {
name: 'my-snapshot',
participantCounts: { completed: 4, inProgress: 2, rejected: 1 },
},
});
await act(async () => {
render(<DataManagementItem studyId="test-study" refresh={async () => []} />);
});
expect(screen.getByText('my-snapshot')).toBeDefined();
expect(screen.getByText('Completed')).toBeDefined();
expect(screen.getByText('In Progress')).toBeDefined();
expect(screen.getByText('Rejected')).toBeDefined();
expect(screen.getByText('4')).toBeDefined();
expect(screen.getByText('2')).toBeDefined();
expect(screen.getByText('1')).toBeDefined();
expect(screen.getByText('DownloadButtons')).toBeDefined();
});

test('DataManagementItem backfills missing snapshot participant counts', async () => {
mockStorageEngine!.getSnapshots.mockResolvedValue({
'dev-test-study-snapshot-2026T01:00': { name: 'my-snapshot' },
});
mockStorageEngine!.getAllParticipantsData.mockResolvedValue([
{ completed: true, rejected: false },
{ completed: true, rejected: { reason: 'quality', timestamp: 1 } },
{ completed: false, rejected: false },
{ completed: false, rejected: false },
{ completed: false, rejected: { reason: 'duplicate', timestamp: 2 } },
]);

await act(async () => {
render(<DataManagementItem studyId="test-study" refresh={async () => []} />);
});

await waitFor(() => {
expect(mockStorageEngine!.updateSnapshotParticipantCounts).toHaveBeenCalledWith(
'test-study',
'dev-test-study-snapshot-2026T01:00',
{ completed: 1, inProgress: 2, rejected: 2 },
);
});
expect(mockStorageEngine!.getAllParticipantsData).toHaveBeenCalledWith('test-study-snapshot-2026T01:00');
expect(screen.getByText('1')).toBeDefined();
expect(screen.getAllByText('2').length).toBe(2);
});

test('DataManagementItem keeps snapshot actions available when count backfill fails', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
mockStorageEngine!.getSnapshots.mockResolvedValue({
'dev-test-study-snapshot-2026T01:00': { name: 'snap-one' },
});
mockStorageEngine!.getAllParticipantsData.mockRejectedValue(new Error('snapshot unavailable'));

await act(async () => {
render(<DataManagementItem studyId="test-study" refresh={async () => []} />);
});

await waitFor(() => {
expect(screen.getAllByText('Unavailable').length).toBe(3);
});
expect(screen.getByRole('button', { name: 'Rename snapshot snap-one' })).toBeDefined();
expect(screen.getByRole('button', { name: 'Restore snapshot snap-one' })).toBeDefined();
expect(screen.getByRole('button', { name: 'Delete snapshot snap-one' })).toBeDefined();
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to backfill participant counts for snapshot dev-test-study-snapshot-2026T01:00:',
expect.any(Error),
);
});

test('DataManagementItem getDateFromSnapshotName returns null for key without snapshot pattern', async () => {
mockStorageEngine!.getSnapshots.mockResolvedValue({
'plain-key': { name: 'no-date-snap' },
Expand Down
50 changes: 42 additions & 8 deletions src/storage/engines/FirebaseStorageEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
cleanupModes,
} from './types';
import { EditedText, TaglessEditedText } from '../../analysis/individualStudy/thinkAloud/types';
import { SnapshotParticipantCounts } from './utils/snapshotParticipantCounts';

export class FirebaseStorageEngine extends CloudStorageEngine {
private RECAPTCHAV3TOKEN = import.meta.env.VITE_RECAPTCHAV3TOKEN;
Expand Down Expand Up @@ -739,12 +740,19 @@ export class FirebaseStorageEngine extends CloudStorageEngine {
}

// Function to add collection name to metadata
protected async _addDirectoryNameToSnapshots(directoryName: string) {
protected async _addDirectoryNameToSnapshots(
directoryName: string,
studyId: string,
participantCounts?: SnapshotParticipantCounts,
) {
try {
const snapshotDoc = doc(this.firestore, `${this.collectionPrefix}${this.studyId}`, 'snapshots');
const snapshotDoc = doc(this.firestore, `${this.collectionPrefix}${studyId}`, 'snapshots');
const snapshotMetadata = participantCounts
? { name: directoryName, participantCounts }
: { name: directoryName };
await setDoc(
snapshotDoc,
{ [directoryName]: { name: directoryName } } as SnapshotDocContent,
{ [directoryName]: snapshotMetadata } as SnapshotDocContent,
{ merge: true },
);
} catch (error) {
Expand All @@ -753,9 +761,9 @@ export class FirebaseStorageEngine extends CloudStorageEngine {
}
}

protected async _removeDirectoryNameFromSnapshots(directoryName: string) {
protected async _removeDirectoryNameFromSnapshots(directoryName: string, studyId: string) {
try {
const snapshotDoc = doc(this.firestore, `${this.collectionPrefix}${this.studyId}`, 'snapshots');
const snapshotDoc = doc(this.firestore, `${this.collectionPrefix}${studyId}`, 'snapshots');
const snapshotData = await getDoc(snapshotDoc);

if (snapshotData.exists()) {
Expand All @@ -775,11 +783,37 @@ export class FirebaseStorageEngine extends CloudStorageEngine {
}
}

protected async _changeDirectoryNameInSnapshots(oldName: string, newName: string) {
const snapshotDoc = doc(this.firestore, `${this.collectionPrefix}${this.studyId}`, 'snapshots');
protected async _changeDirectoryNameInSnapshots(oldName: string, newName: string, studyId: string) {
const snapshotDoc = doc(this.firestore, `${this.collectionPrefix}${studyId}`, 'snapshots');
const snapshotData = await getDoc(snapshotDoc);
const existingMetadata = snapshotData.exists()
? (snapshotData.data() as SnapshotDocContent)[oldName] ?? { name: oldName }
: { name: oldName };
await setDoc(
snapshotDoc,
{ [oldName]: { ...existingMetadata, name: newName } },
{ merge: true },
);
}

protected async _updateSnapshotParticipantCounts(
snapshotName: string,
studyId: string,
participantCounts: SnapshotParticipantCounts,
) {
const snapshotDoc = doc(this.firestore, `${this.collectionPrefix}${studyId}`, 'snapshots');
const snapshotData = await getDoc(snapshotDoc);
const existingMetadata = snapshotData.exists()
? (snapshotData.data() as SnapshotDocContent)[snapshotName]
: undefined;

if (!existingMetadata) {
throw new Error(`Snapshot with name ${snapshotName} does not exist`);
}

await setDoc(
snapshotDoc,
{ [oldName]: { name: newName } },
{ [snapshotName]: { ...existingMetadata, participantCounts } },
{ merge: true },
);
}
Expand Down
29 changes: 26 additions & 3 deletions src/storage/engines/LocalStorageEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import localforage from 'localforage';
import {
REVISIT_MODE, SequenceAssignment, SnapshotDocContent, StorageEngine, StorageObject, StorageObjectType, cleanupModes,
} from './types';
import { SnapshotParticipantCounts } from './utils/snapshotParticipantCounts';

export class LocalStorageEngine extends StorageEngine {
private studyDatabase = localforage.createInstance({
Expand Down Expand Up @@ -328,12 +329,18 @@ export class LocalStorageEngine extends StorageEngine {
await this._deleteDirectory(path);
}

protected async _addDirectoryNameToSnapshots(directoryName: string, studyId: string) {
protected async _addDirectoryNameToSnapshots(
directoryName: string,
studyId: string,
participantCounts?: SnapshotParticipantCounts,
) {
await this.verifyStudyDatabase();
const metadataKey = `${this.collectionPrefix}${studyId}/snapshots`;
const metadata = await this.studyDatabase.getItem<SnapshotDocContent>(metadataKey) || {};
if (!metadata[directoryName]) {
metadata[directoryName] = { name: directoryName };
metadata[directoryName] = participantCounts
? { name: directoryName, participantCounts }
: { name: directoryName };
await this.studyDatabase.setItem(metadataKey, metadata);
}
}
Expand All @@ -353,10 +360,26 @@ export class LocalStorageEngine extends StorageEngine {
const snapshotsKey = `${this.collectionPrefix}${studyId}/snapshots`;
const snapshots = await this.studyDatabase.getItem<SnapshotDocContent>(snapshotsKey) || {};
if (snapshots[key]) {
snapshots[key] = { name: newName };
snapshots[key] = { ...snapshots[key], name: newName };
await this.studyDatabase.setItem(snapshotsKey, snapshots);
} else {
throw new Error(`Snapshot with name ${key} does not exist`);
}
}

protected async _updateSnapshotParticipantCounts(
snapshotName: string,
studyId: string,
participantCounts: SnapshotParticipantCounts,
) {
await this.verifyStudyDatabase();
const snapshotsKey = `${this.collectionPrefix}${studyId}/snapshots`;
const snapshots = await this.studyDatabase.getItem<SnapshotDocContent>(snapshotsKey) || {};
if (snapshots[snapshotName]) {
snapshots[snapshotName] = { ...snapshots[snapshotName], participantCounts };
await this.studyDatabase.setItem(snapshotsKey, snapshots);
} else {
throw new Error(`Snapshot with name ${snapshotName} does not exist`);
}
}
}
Loading
Loading