Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
136 changes: 123 additions & 13 deletions 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 @@ -17,6 +23,41 @@ type SnapshotAction =
| { type: 'deleteSnapshot', snapshot: string }
| { type: 'deleteLive' };

function getSnapshotStudyId(snapshotKey: string) {
return snapshotKey.slice(snapshotKey.indexOf('-') + 1);
}

function getSnapshotDateKey(snapshotName: string): string | null {
const regex = /-snapshot-(.+)$/;
const match = snapshotName.match(regex);

return match?.[1] ?? null;
}

function getDateFromSnapshotName(snapshotName: string): string | null {
return getSnapshotDateKey(snapshotName)?.replace('T', ' ') ?? null;
}

function compareSnapshotsByDate(
[leftKey]: [string, SnapshotDocContent[string]],
[rightKey]: [string, SnapshotDocContent[string]],
) {
const leftDate = getSnapshotDateKey(leftKey);
const rightDate = getSnapshotDateKey(rightKey);

if (!leftDate && !rightDate) {
return leftKey.localeCompare(rightKey);
}
if (!leftDate) {
return 1;
}
if (!rightDate) {
return -1;
}

return rightDate.localeCompare(leftDate);
}

export function DataManagementItem({ studyId, refresh }: { studyId: string, refresh: () => Promise<ParticipantDataWithStatus[]> }) {
const [modalArchiveOpened, setModalArchiveOpened] = useState<boolean>(false);
const [modalDeleteSnapshotOpened, setModalDeleteSnapshotOpened] = useState<boolean>(false);
Expand All @@ -32,6 +73,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 +93,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' }));

storageEngine.getAllParticipantsData(getSnapshotStudyId(snapshotKey))
.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.
snapshotCountBackfills.current.delete(snapshotKey);
if (!isCancelled) {
setSnapshotCountStatus((previous) => ({ ...previous, [snapshotKey]: 'failed' }));
}
});
});

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

if (!storageEngine) {
return null;
}
Expand Down Expand Up @@ -123,20 +223,24 @@ export function DataManagementItem({ studyId, refresh }: { studyId: string, refr
onConfirm: () => handleRestoreSnapshot(snapshot),
});

const getDateFromSnapshotName = (snapshotName: string): string | null => {
const regex = /-snapshot-(.+)$/;
const match = snapshotName.match(regex);
const fetchParticipants = async (snapshotName: string) => (
await storageEngine.getAllParticipantsData(getSnapshotStudyId(snapshotName))
);

if (match && match[1]) {
const dateStuff = match[1];
return dateStuff.replace('T', ' ');
const renderParticipantCount = (
snapshotKey: string,
participantCounts: SnapshotParticipantCounts | undefined,
countKey: keyof SnapshotParticipantCounts,
) => {
if (participantCounts) {
return participantCounts[countKey];
}

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

const fetchParticipants = async (snapshotName: string) => {
const strippedFilename = snapshotName.slice(snapshotName.indexOf('-') + 1);
return await storageEngine.getAllParticipantsData(strippedFilename);
return 'Loading...';
};

return (
Expand Down Expand Up @@ -230,15 +334,21 @@ 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>
<Table.Tbody>
{Object.entries(snapshots).map(
{Object.entries(snapshots).sort(compareSnapshotsByDate).map(
([key, snapshotItem]) => (
<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
88 changes: 86 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,97 @@ 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 sorts snapshots by newest creation date first', async () => {
mockStorageEngine!.getSnapshots.mockResolvedValue({
'test-study-snapshot-2026-06-09T01:00:00': {
name: 'older-snapshot',
participantCounts: { completed: 1, inProgress: 0, rejected: 0 },
},
'test-study-snapshot-2026-06-10T01:00:00': {
name: 'newer-snapshot',
participantCounts: { completed: 2, inProgress: 0, rejected: 0 },
},
});

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

expect(screen.getAllByText(/-snapshot$/).map((element) => element.textContent)).toEqual([
'newer-snapshot',
'older-snapshot',
]);
});

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
Loading
Loading