diff --git a/src/analysis/individualStudy/management/DataManagementItem.tsx b/src/analysis/individualStudy/management/DataManagementItem.tsx index fa31f36fda..e8327ddff6 100644 --- a/src/analysis/individualStudy/management/DataManagementItem.tsx +++ b/src/analysis/individualStudy/management/DataManagementItem.tsx @@ -1,7 +1,9 @@ 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'; @@ -9,6 +11,10 @@ import { showNotification, RevisitNotification } from '../../../utils/notificati 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 } @@ -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 }) { const [modalArchiveOpened, setModalArchiveOpened] = useState(false); const [modalDeleteSnapshotOpened, setModalDeleteSnapshotOpened] = useState(false); @@ -32,6 +73,10 @@ export function DataManagementItem({ studyId, refresh }: { studyId: string, refr const [loading, setLoading] = useState(false); const [snapshotListLoading, setSnapshotListLoading] = useState(false); + const [snapshotCountStatus, setSnapshotCountStatus] = useState>({}); + const snapshotCountBackfills = useRef(new Set()); + const snapshotCountBackfillQueue = useRef(Promise.resolve()); + const snapshotCountBackfillGeneration = useRef(0); const { storageEngine } = useStorageEngine(); @@ -50,6 +95,83 @@ export function DataManagementItem({ studyId, refresh }: { studyId: string, refr refreshSnapshots(); }, [refreshSnapshots]); + useEffect(() => () => { + snapshotCountBackfillGeneration.current += 1; + }, []); + + useEffect(() => { + snapshotCountBackfillGeneration.current += 1; + snapshotCountBackfills.current.clear(); + setSnapshotCountStatus({}); + }, [storageEngine, studyId]); + + useEffect(() => { + if (!storageEngine) { + return undefined; + } + + const backfillGeneration = snapshotCountBackfillGeneration.current; + + Object.entries(snapshots).forEach(([snapshotKey, snapshotItem]) => { + if (snapshotItem.participantCounts || snapshotCountBackfills.current.has(snapshotKey)) { + return; + } + + snapshotCountBackfills.current.add(snapshotKey); + setSnapshotCountStatus((previous) => ({ ...previous, [snapshotKey]: 'loading' })); + + snapshotCountBackfillQueue.current = snapshotCountBackfillQueue.current + .catch(() => undefined) + .then(async () => { + if (snapshotCountBackfillGeneration.current !== backfillGeneration) { + return; + } + + const participants = await storageEngine.getAllParticipantsData(getSnapshotStudyId(snapshotKey)); + const participantCounts = calculateSnapshotParticipantCounts(participants); + + if (snapshotCountBackfillGeneration.current !== backfillGeneration) { + return; + } + + await storageEngine.updateSnapshotParticipantCounts(studyId, snapshotKey, participantCounts); + + if (snapshotCountBackfillGeneration.current !== backfillGeneration) { + 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); + if (snapshotCountBackfillGeneration.current !== backfillGeneration) { + return; + } + snapshotCountBackfills.current.delete(snapshotKey); + setSnapshotCountStatus((previous) => ({ ...previous, [snapshotKey]: 'failed' })); + }); + }); + + return undefined; + }, [snapshots, storageEngine, studyId]); + if (!storageEngine) { return null; } @@ -123,20 +245,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 ( @@ -230,15 +356,21 @@ export function DataManagementItem({ studyId, refresh }: { studyId: string, refr Snapshot Name Date Created + Completed + In Progress + Rejected Actions - {Object.entries(snapshots).map( + {Object.entries(snapshots).sort(compareSnapshotsByDate).map( ([key, snapshotItem]) => ( {snapshotItem.name} {getDateFromSnapshotName(key)} + {renderParticipantCount(key, snapshotItem.participantCounts, 'completed')} + {renderParticipantCount(key, snapshotItem.participantCounts, 'inProgress')} + {renderParticipantCount(key, snapshotItem.participantCounts, 'rejected')} diff --git a/src/analysis/individualStudy/management/tests/ManageView.spec.tsx b/src/analysis/individualStudy/management/tests/ManageView.spec.tsx index ac4fcadf68..72e0e829ce 100644 --- a/src/analysis/individualStudy/management/tests/ManageView.spec.tsx +++ b/src/analysis/individualStudy/management/tests/ManageView.spec.tsx @@ -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, @@ -25,6 +25,7 @@ let mockStorageEngine: { restoreSnapshot: ReturnType; removeSnapshotOrLive: ReturnType; getAllParticipantsData: ReturnType; + updateSnapshotParticipantCounts: ReturnType; } | undefined; vi.mock('../../../../storage/storageEngineHooks', () => ({ @@ -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', () => { @@ -334,15 +336,156 @@ 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( []} />); }); 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( []} />); + }); + + 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( []} />); + }); + + 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 backfills missing snapshot participant counts one at a time', async () => { + let resolveFirstBackfill: (participants: unknown[]) => void = () => { }; + const firstBackfill = new Promise((resolve) => { + resolveFirstBackfill = resolve; + }); + let resolveFirstUpdate: () => void = () => { }; + const firstUpdate = new Promise((resolve) => { + resolveFirstUpdate = resolve; + }); + mockStorageEngine!.getSnapshots.mockResolvedValue({ + 'dev-test-study-snapshot-2026T01:00': { name: 'first-snapshot' }, + 'dev-test-study-snapshot-2026T02:00': { name: 'second-snapshot' }, + }); + mockStorageEngine!.getAllParticipantsData.mockImplementation((snapshotStudyId: string) => { + if (snapshotStudyId === 'test-study-snapshot-2026T01:00') { + return firstBackfill; + } + + return Promise.resolve([{ completed: false, rejected: false }]); + }); + mockStorageEngine!.updateSnapshotParticipantCounts.mockImplementation((_, snapshotName: string) => { + if (snapshotName === 'dev-test-study-snapshot-2026T01:00') { + return firstUpdate; + } + + return Promise.resolve(); + }); + + await act(async () => { + render( []} />); + }); + + await waitFor(() => { + expect(mockStorageEngine!.getAllParticipantsData).toHaveBeenCalledTimes(1); + }); + expect(mockStorageEngine!.getAllParticipantsData).toHaveBeenCalledWith('test-study-snapshot-2026T01:00'); + expect(mockStorageEngine!.updateSnapshotParticipantCounts).not.toHaveBeenCalled(); + + await act(async () => { + resolveFirstBackfill([{ completed: true, rejected: false }]); + await firstBackfill; + }); + + await waitFor(() => { + expect(mockStorageEngine!.updateSnapshotParticipantCounts).toHaveBeenCalledTimes(1); + }); + expect(mockStorageEngine!.getAllParticipantsData).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveFirstUpdate(); + await firstUpdate; + }); + + await waitFor(() => { + expect(mockStorageEngine!.getAllParticipantsData).toHaveBeenCalledTimes(2); + }); + expect(mockStorageEngine!.getAllParticipantsData).toHaveBeenCalledWith('test-study-snapshot-2026T02:00'); + }); + + 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( []} />); + }); + + 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' }, diff --git a/src/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts index 4fcf857623..ac7c8af4be 100644 --- a/src/storage/engines/FirebaseStorageEngine.ts +++ b/src/storage/engines/FirebaseStorageEngine.ts @@ -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; @@ -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) { @@ -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()) { @@ -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 }, ); } diff --git a/src/storage/engines/LocalStorageEngine.ts b/src/storage/engines/LocalStorageEngine.ts index 372bcf3530..48695b5752 100644 --- a/src/storage/engines/LocalStorageEngine.ts +++ b/src/storage/engines/LocalStorageEngine.ts @@ -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({ @@ -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(metadataKey) || {}; if (!metadata[directoryName]) { - metadata[directoryName] = { name: directoryName }; + metadata[directoryName] = participantCounts + ? { name: directoryName, participantCounts } + : { name: directoryName }; await this.studyDatabase.setItem(metadataKey, metadata); } } @@ -353,10 +360,26 @@ export class LocalStorageEngine extends StorageEngine { const snapshotsKey = `${this.collectionPrefix}${studyId}/snapshots`; const snapshots = await this.studyDatabase.getItem(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(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`); + } + } } diff --git a/src/storage/engines/SupabaseStorageEngine.ts b/src/storage/engines/SupabaseStorageEngine.ts index a30d9c8cbe..0dc9c47bd0 100644 --- a/src/storage/engines/SupabaseStorageEngine.ts +++ b/src/storage/engines/SupabaseStorageEngine.ts @@ -4,6 +4,7 @@ import { REVISIT_MODE, SequenceAssignment, SnapshotDocContent, StorageObject, StorageObjectType, StoredUser, CloudStorageEngine, cleanupModes, } from './types'; +import { SnapshotParticipantCounts } from './utils/snapshotParticipantCounts'; export class SupabaseStorageEngine extends CloudStorageEngine { private supabase = createClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_ANON_KEY); @@ -683,10 +684,16 @@ export class SupabaseStorageEngine extends CloudStorageEngine { } } - protected async _addDirectoryNameToSnapshots(directoryName: string, studyId: string) { + protected async _addDirectoryNameToSnapshots( + directoryName: string, + studyId: string, + participantCounts?: SnapshotParticipantCounts, + ) { const snapshots = await this.getSnapshots(studyId); if (!snapshots[directoryName]) { - snapshots[directoryName] = { name: directoryName }; + snapshots[directoryName] = participantCounts + ? { name: directoryName, participantCounts } + : { name: directoryName }; await this.supabase .from('revisit') .upsert({ @@ -714,7 +721,25 @@ export class SupabaseStorageEngine extends CloudStorageEngine { protected async _changeDirectoryNameInSnapshots(oldName: string, newName: string, studyId: string) { const snapshots = await this.getSnapshots(studyId); if (snapshots[oldName]) { - snapshots[oldName] = { name: newName }; + snapshots[oldName] = { ...snapshots[oldName], name: newName }; + await this.supabase + .from('revisit') + .upsert({ + studyId: `${this.collectionPrefix}${studyId}`, + docId: 'snapshots', + data: snapshots, + }); + } + } + + protected async _updateSnapshotParticipantCounts( + snapshotName: string, + studyId: string, + participantCounts: SnapshotParticipantCounts, + ) { + const snapshots = await this.getSnapshots(studyId); + if (snapshots[snapshotName]) { + snapshots[snapshotName] = { ...snapshots[snapshotName], participantCounts }; await this.supabase .from('revisit') .upsert({ diff --git a/src/storage/engines/types.ts b/src/storage/engines/types.ts index 8249d0efca..707776bf8a 100644 --- a/src/storage/engines/types.ts +++ b/src/storage/engines/types.ts @@ -15,6 +15,10 @@ import { normalizeStoredProvenance, splitProvenanceFromAnswers, } from '../../store/provenance'; +import { + SnapshotParticipantCounts, + calculateSnapshotParticipantCounts, +} from './utils/snapshotParticipantCounts'; export interface StoredUser { email: string | null, @@ -125,8 +129,11 @@ export type ActionResponse = | ActionResponseSuccess | ActionResponseFailed; -// Represents a snapshot name item with an original name and an optional alternate (renamed) name. -export type SnapshotDocContent = Record; +// Represents snapshot metadata keyed by the snapshot storage directory name. +export type SnapshotDocContent = Record; export type FinalizeParticipantResult = { status: 'complete' | 'retry' | 'error'; @@ -306,7 +313,11 @@ export abstract class StorageEngine { protected abstract _deleteRealtimeData(path: string): Promise; // Adds a directory name to the metadata. This is used by createSnapshot - protected abstract _addDirectoryNameToSnapshots(directoryName: string, studyId: string): Promise; + protected abstract _addDirectoryNameToSnapshots( + directoryName: string, + studyId: string, + participantCounts?: SnapshotParticipantCounts, + ): Promise; // Removes a snapshot from the metadata. This is used by removeSnapshotOrLive protected abstract _removeDirectoryNameFromSnapshots(directoryName: string, studyId: string): Promise; @@ -314,6 +325,13 @@ export abstract class StorageEngine { // Updates a snapshot in the metadata. This is used by renameSnapshot protected abstract _changeDirectoryNameInSnapshots(oldName: string, newName: string, studyId: string): Promise; + // Updates participant status count metadata for an existing snapshot. + protected abstract _updateSnapshotParticipantCounts( + snapshotName: string, + studyId: string, + participantCounts: SnapshotParticipantCounts, + ): Promise; + /* * THROTTLED METHODS * These methods are used to throttle the calls to the storage engine's methods that can be called frequently. @@ -954,7 +972,7 @@ export abstract class StorageEngine { // Gets all participant IDs for the given studyId async getAllParticipantIds(studyId?: string) { - const studyIdToUse = this.studyId || studyId; + const studyIdToUse = studyId ?? this.studyId; if (studyIdToUse === undefined) { throw new Error('Study ID is not set'); } @@ -1731,6 +1749,7 @@ export abstract class StorageEngine { const formattedDate = `${year}-${month}-${date}T${hours}:${minutes}:${seconds}`; const targetName = `${this.collectionPrefix}${studyId}-snapshot-${formattedDate}`; + const participantCounts = calculateSnapshotParticipantCounts(await this.getAllParticipantsData(studyId)); if (this.getEngine() === 'localStorage') { await this._copyDirectory(`${sourceName}/`, `${targetName}/`); @@ -1743,7 +1762,7 @@ export abstract class StorageEngine { await this._copyDirectory(sourceName, targetName); await this._copyRealtimeData(sourceName, targetName); } - await this._addDirectoryNameToSnapshots(targetName, studyId); + await this._addDirectoryNameToSnapshots(targetName, studyId, participantCounts); const createSnapshotSuccessNotifications: RevisitNotification[] = []; if (deleteData) { @@ -1914,6 +1933,14 @@ export abstract class StorageEngine { }; } } + + async updateSnapshotParticipantCounts( + studyId: string, + snapshotName: string, + participantCounts: SnapshotParticipantCounts, + ) { + await this._updateSnapshotParticipantCounts(snapshotName, studyId, participantCounts); + } } export type UserManagementData = { authentication?: { isEnabled: boolean }; adminUsers?: { adminUsersList: StoredUser[] } }; diff --git a/src/storage/engines/utils/snapshotParticipantCounts.ts b/src/storage/engines/utils/snapshotParticipantCounts.ts new file mode 100644 index 0000000000..f71e147289 --- /dev/null +++ b/src/storage/engines/utils/snapshotParticipantCounts.ts @@ -0,0 +1,17 @@ +import type { ParticipantDataWithStatus } from '../../types'; + +export type SnapshotParticipantCounts = { + completed: number; + inProgress: number; + rejected: number; +}; + +export function calculateSnapshotParticipantCounts( + participants: ParticipantDataWithStatus[], +): SnapshotParticipantCounts { + return { + completed: participants.filter((participant) => participant.completed && !participant.rejected).length, + inProgress: participants.filter((participant) => !participant.completed && !participant.rejected).length, + rejected: participants.filter((participant) => participant.rejected).length, + }; +} diff --git a/src/tests/utils.ts b/src/tests/utils.ts index be28faac40..c608fab21d 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -99,6 +99,8 @@ class TestStorageEngine extends StorageEngine { protected _changeDirectoryNameInSnapshots = vi.fn(async () => { }); + protected _updateSnapshotParticipantCounts = vi.fn(async () => { }); + // Public stubs used by tests getAllSequenceAssignments = vi.fn(async () => []);