Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
366 changes: 256 additions & 110 deletions src/analysis/individualStudy/LiveMonitor/LiveMonitorView.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ vi.mock('@mantine/core', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Button: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>,
Flex: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Modal: ({ children, opened }: { children: ReactNode; opened: boolean }) => (opened ? <div>{children}</div> : null),
NumberInput: ({ onChange }: { onChange?: (value: number) => void }) => <input onChange={() => onChange?.(60)} />,
Switch: ({ onChange }: { onChange?: () => void }) => <input type="checkbox" onChange={onChange} />,
Grid: Object.assign(
({ children }: { children: ReactNode }) => <div>{children}</div>,
{ Col: ({ children }: { children: ReactNode }) => <div>{children}</div> },
Expand All @@ -56,6 +59,10 @@ vi.mock('../../../../storage/engines/FirebaseStorageEngine', () => ({
FirebaseStorageEngine: class { },
}));

vi.mock('../../../../store/hooks/useAuth', () => ({
useAuth: () => ({ user: { isAdmin: true } }),
}));

// ── fixture helpers ───────────────────────────────────────────────────────────

function makeAssignment(overrides: Partial<SequenceAssignment> = {}): SequenceAssignment {
Expand All @@ -67,6 +74,10 @@ function makeAssignment(overrides: Partial<SequenceAssignment> = {}): SequenceAs
});
}

function makeFirebaseEngine(overrides: Parameters<typeof makeStorageEngine>[0]) {
return makeStorageEngine({ ...overrides, getEngine: () => 'firebase' });
}

// ── getFilteredParticipantProgress ────────────────────────────────────────────

describe('getFilteredParticipantProgress', () => {
Expand Down Expand Up @@ -167,13 +178,13 @@ afterEach(() => { cleanup(); vi.restoreAllMocks(); });
describe('LiveMonitorView', () => {
const baseProps = {
studyConfig: {} as Parameters<typeof LiveMonitorView>[0]['studyConfig'],
includedParticipants: ['inProgress', 'completed', 'rejected'],
includedParticipants: ['inProgress', 'completed', 'rejected', 'timedOut'],
selectedStages: ['ALL'],
};

test('renders Live Monitor heading', () => {
test('renders auto-timeout settings without a Firebase engine', () => {
const html = renderToStaticMarkup(<LiveMonitorView {...baseProps} />);
expect(html).toContain('Live Monitor');
expect(html).toContain('Auto-timeout');
});

test('shows 0 counts when no storageEngine provided', () => {
Expand All @@ -182,35 +193,10 @@ describe('LiveMonitorView', () => {
expect(html).toContain('0');
});

test('shows Completed, Active, Rejected badges', () => {
const html = renderToStaticMarkup(<LiveMonitorView {...baseProps} />);
expect(html).toContain('Completed');
expect(html).toContain('Active');
expect(html).toContain('Rejected');
});

test('shows disconnected wifi icon when no storageEngine', () => {
const html = renderToStaticMarkup(<LiveMonitorView {...baseProps} />);
// Without a Firebase engine, useEffect will set status to 'disconnected'
// but renderToStaticMarkup captures initial state ('connecting') — wifioff shown
expect(html).toContain('icon-wifioff');
});

test('shows In Progress, Completed, Rejected section titles', () => {
test('explains that the live monitor requires Firebase', () => {
const html = renderToStaticMarkup(<LiveMonitorView {...baseProps} />);
expect(html).toContain('In Progress');
expect(html).toContain('Completed');
expect(html).toContain('Rejected');
expect(html).toContain('Live participant monitoring is currently available with Firebase');
});

test('sets connectionStatus to disconnected when no storageEngine after effect', async () => {
const { container } = await act(async () => render(
<LiveMonitorView {...baseProps} />,
));
// After effects run, status is 'disconnected' → icon-wifioff shown
expect(container.textContent).toContain('icon-wifioff');
});

test('sets connectionStatus to connected when listener returns a function', async () => {
const mockUnsubscribe = vi.fn();
const mockEngine = {
Expand All @@ -221,7 +207,7 @@ describe('LiveMonitorView', () => {
const { container } = await act(async () => render(
<LiveMonitorView
{...baseProps}
storageEngine={makeStorageEngine(mockEngine)}
storageEngine={makeFirebaseEngine(mockEngine)}
studyId="test-study"
/>,
));
Expand Down Expand Up @@ -337,7 +323,7 @@ describe('LiveMonitorView interactive', () => {
}),
};
const { container } = await act(async () => render(
<LiveMonitorView {...baseProps} storageEngine={makeStorageEngine(mockEngine)} />,
<LiveMonitorView {...baseProps} storageEngine={makeFirebaseEngine(mockEngine)} />,
));
expect(container.textContent).toContain('p-active');
});
Expand All @@ -359,7 +345,7 @@ describe('LiveMonitorView interactive', () => {
}),
};
const { container } = await act(async () => render(
<LiveMonitorView {...baseProps} storageEngine={makeStorageEngine(mockEngine)} />,
<LiveMonitorView {...baseProps} storageEngine={makeFirebaseEngine(mockEngine)} />,
));
expect(container.textContent).toContain('p-done');
expect(container.textContent).toContain('p-rej');
Expand All @@ -372,7 +358,7 @@ describe('LiveMonitorView interactive', () => {
_setupSequenceAssignmentListener: vi.fn(() => undefined),
};
const { container } = await act(async () => render(
<LiveMonitorView {...baseProps} storageEngine={makeStorageEngine(mockEngine)} />,
<LiveMonitorView {...baseProps} storageEngine={makeFirebaseEngine(mockEngine)} />,
));
expect(container.textContent).toContain('icon-wifioff');
});
Expand All @@ -384,7 +370,7 @@ describe('LiveMonitorView interactive', () => {
_setupSequenceAssignmentListener: vi.fn(() => undefined),
};
const { getAllByRole } = await act(async () => render(
<LiveMonitorView {...baseProps} storageEngine={makeStorageEngine(mockEngine)} />,
<LiveMonitorView {...baseProps} storageEngine={makeFirebaseEngine(mockEngine)} />,
));
const reconnectBtn = getAllByRole('button').find((b) => b.textContent?.includes('Reconnect'));
expect(reconnectBtn).toBeDefined();
Expand All @@ -400,7 +386,7 @@ describe('LiveMonitorView interactive', () => {
};
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
const { getAllByRole } = await act(async () => render(
<LiveMonitorView {...baseProps} storageEngine={makeStorageEngine(mockEngine)} />,
<LiveMonitorView {...baseProps} storageEngine={makeFirebaseEngine(mockEngine)} />,
));
const reconnectBtn = getAllByRole('button').find((b) => b.textContent?.includes('Reconnect'));
expect(reconnectBtn).toBeDefined();
Expand All @@ -419,7 +405,7 @@ describe('LiveMonitorView interactive', () => {
}),
};
await act(async () => render(
<LiveMonitorView {...baseProps} storageEngine={makeStorageEngine(mockEngine)} />,
<LiveMonitorView {...baseProps} storageEngine={makeFirebaseEngine(mockEngine)} />,
));
act(() => { window.dispatchEvent(new Event('offline')); });
});
Expand All @@ -431,7 +417,7 @@ describe('LiveMonitorView interactive', () => {
_setupSequenceAssignmentListener: vi.fn(() => undefined),
};
await act(async () => render(
<LiveMonitorView {...baseProps} storageEngine={makeStorageEngine(mockEngine)} />,
<LiveMonitorView {...baseProps} storageEngine={makeFirebaseEngine(mockEngine)} />,
));
await act(async () => { window.dispatchEvent(new Event('online')); });
expect(mockEngine.getAllSequenceAssignments).toHaveBeenCalled();
Expand Down
57 changes: 31 additions & 26 deletions src/analysis/individualStudy/StudyAnalysisTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function StudyAnalysisTabs({ globalConfig }: { globalConfig: GlobalConfig
const [studyConfig, setStudyConfig] = useState<ParsedConfig<StudyConfig> | undefined>(undefined);
const [startupError, setStartupError] = useState<{ error: unknown } | null>(null);

const [includedParticipants, setIncludedParticipants] = useState<string[]>(['completed', 'inProgress', 'rejected']);
const [includedParticipants, setIncludedParticipants] = useState<string[]>(['completed', 'inProgress', 'rejected', 'timedOut']);

const [selectedStages, setSelectedStages] = useState<string[]>(['ALL']);
const [availableStages, setAvailableStages] = useState<{ value: string; label: string }[]>([{ value: 'ALL', label: 'ALL' }]);
Expand Down Expand Up @@ -126,7 +126,11 @@ export function StudyAnalysisTabs({ globalConfig }: { globalConfig: GlobalConfig
);

const participantCounts = useMemo(() => {
if (!expData) return { completed: 0, inProgress: 0, rejected: 0 };
if (!expData) {
return {
completed: 0, inProgress: 0, rejected: 0, timedOut: 0,
};
}
const expList = Object.values(expData);

// Apply config filter before counting
Expand All @@ -151,26 +155,31 @@ export function StudyAnalysisTabs({ globalConfig }: { globalConfig: GlobalConfig
});

return {
completed: conditionFiltered.filter((d) => !d.rejected && d.completed).length,
inProgress: conditionFiltered.filter((d) => !d.rejected && !d.completed).length,
completed: conditionFiltered.filter((d) => !d.rejected && !d.timedOut && d.completed).length,
inProgress: conditionFiltered.filter((d) => !d.rejected && !d.timedOut && !d.completed).length,
rejected: conditionFiltered.filter((d) => d.rejected).length,
timedOut: conditionFiltered.filter((d) => !d.rejected && d.timedOut).length,
};
}, [expData, selectedStages, selectedConfigs, selectedConditions, studyUsesConditions]);

const selectedParticipantCounts = useMemo(() => {
if (selectedParticipants.length === 0) return { completed: 0, inProgress: 0, rejected: 0 };
if (selectedParticipants.length === 0) {
return {
completed: 0, inProgress: 0, rejected: 0, timedOut: 0,
};
}

return {
completed: selectedParticipants.filter((d) => !d.rejected && d.completed).length,
inProgress: selectedParticipants.filter((d) => !d.rejected && !d.completed).length,
completed: selectedParticipants.filter((d) => !d.rejected && !d.timedOut && d.completed).length,
inProgress: selectedParticipants.filter((d) => !d.rejected && !d.timedOut && !d.completed).length,
rejected: selectedParticipants.filter((d) => d.rejected).length,
timedOut: selectedParticipants.filter((d) => !d.rejected && d.timedOut).length,
};
}, [selectedParticipants]);

const currentConfigHash = currentConfigHashValue ?? undefined;
const isFirebaseEngine = storageEngine?.getEngine() === 'firebase';
const codingEnabled = isFirebaseEngine && hasAudioRecording;
const liveMonitorEnabled = isFirebaseEngine;

const currentConfigLabel = useMemo(() => {
if (!currentConfigHash) return undefined;
Expand Down Expand Up @@ -199,11 +208,12 @@ export function StudyAnalysisTabs({ globalConfig }: { globalConfig: GlobalConfig
if (!expData) return [];
const expList = Object.values(expData);

const comp = includedParticipants.includes('completed') ? expList.filter((d) => !d.rejected && d.completed) : [];
const prog = includedParticipants.includes('inProgress') ? expList.filter((d) => !d.rejected && !d.completed) : [];
const comp = includedParticipants.includes('completed') ? expList.filter((d) => !d.rejected && !d.timedOut && d.completed) : [];
const prog = includedParticipants.includes('inProgress') ? expList.filter((d) => !d.rejected && !d.timedOut && !d.completed) : [];
const rej = includedParticipants.includes('rejected') ? expList.filter((d) => d.rejected) : [];
const timedOut = includedParticipants.includes('timedOut') ? expList.filter((d) => !d.rejected && d.timedOut) : [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] This groups late completions with incomplete timeouts because both have timedOut === true. The new completedLate field is therefore lost in the main analysis filter, and older consumers such as tidy downloads, snapshot counts, and participant badges still use only the old three statuses. Please define one canonical status mapping and use it everywhere, keeping rejected, timed out, and completed-late distinct.


const statusFiltered = [...comp, ...prog, ...rej];
const statusFiltered = [...comp, ...prog, ...rej, ...timedOut];

// Apply config filter - if "ALL" is selected, show all participants
const configFiltered = selectedConfigs.includes('ALL')
Expand Down Expand Up @@ -575,6 +585,14 @@ export function StudyAnalysisTabs({ globalConfig }: { globalConfig: GlobalConfig
size="xs"
styles={{ label: { whiteSpace: 'nowrap' } }}
/>
<Checkbox
value="timedOut"
label={selectedParticipants.length > 0
? `Timed Out (${selectedParticipantCounts.timedOut} of ${participantCounts.timedOut})`
: `Timed Out (${participantCounts.timedOut})`}
size="xs"
styles={{ label: { whiteSpace: 'nowrap' } }}
/>
</Group>
</Checkbox.Group>
</Flex>
Expand Down Expand Up @@ -609,14 +627,7 @@ export function StudyAnalysisTabs({ globalConfig }: { globalConfig: GlobalConfig
<Tabs.Tab value="tagging" leftSection={<IconTags size={16} />} disabled={!codingEnabled} style={{ justifyContent: 'flex-start' }}>Coding</Tabs.Tab>
</span>
</Tooltip>
<Tooltip
label="Live Monitor is only available when using Firebase"
disabled={liveMonitorEnabled}
>
<span>
<Tabs.Tab value="live-monitor" leftSection={<IconDashboard size={16} />} disabled={!liveMonitorEnabled} style={{ justifyContent: 'flex-start' }}>Live Monitor</Tabs.Tab>
</span>
</Tooltip>
<Tabs.Tab value="live-monitor" leftSection={<IconDashboard size={16} />} style={{ justifyContent: 'flex-start' }}>Live Monitor</Tabs.Tab>
<Tabs.Tab value="config" leftSection={<IconFileCode size={16} />} style={{ justifyContent: 'flex-start' }}>Config</Tabs.Tab>
<Tabs.Tab value="stages" leftSection={<IconSettings size={16} />} disabled={!user.isAdmin} style={{ justifyContent: 'flex-start' }}>Stage Management</Tabs.Tab>
<Tabs.Tab value="manage" leftSection={<IconSettings size={16} />} disabled={!user.isAdmin} style={{ justifyContent: 'flex-start' }}>Manage</Tabs.Tab>
Expand Down Expand Up @@ -652,13 +663,7 @@ export function StudyAnalysisTabs({ globalConfig }: { globalConfig: GlobalConfig
)}
</Tabs.Panel>
<Tabs.Panel style={{ flex: 1, minHeight: 0, overflow: 'auto' }} value="live-monitor" pt="xs">
{studyConfig && liveMonitorEnabled
? <LiveMonitorView studyConfig={studyConfig} storageEngine={storageEngine} studyId={canonicalStudyId ?? undefined} includedParticipants={includedParticipants} selectedStages={selectedStages} />
: (
<Center>
<Text c="dimmed">Live Monitor is only available when using Firebase</Text>
</Center>
)}
{studyConfig && <LiveMonitorView studyConfig={studyConfig} storageEngine={storageEngine} studyId={canonicalStudyId ?? undefined} includedParticipants={includedParticipants} selectedStages={selectedStages} />}
</Tabs.Panel>
<Tabs.Panel style={{ flex: 1, minHeight: 0, overflow: 'auto' }} value="config" pt="xs">
{studyConfig && <ConfigView visibleParticipants={visibleParticipants} studyId={canonicalStudyId ?? undefined} currentConfigHash={currentConfigHash} />}
Expand Down
28 changes: 19 additions & 9 deletions src/analysis/individualStudy/management/StageManagementItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ type StageParticipantStatusCounts = Record<string, { completed: number; inProgre

export function getStageParticipantStatusCounts(participants: ParticipantDataWithStatus[]) {
return participants.reduce<StageParticipantStatusCounts>((counts, participant) => {
if (participant.rejected) {
if (participant.rejected || participant.timedOut) {
return counts;
}

Expand Down Expand Up @@ -130,12 +130,14 @@ function getBetweenSubjectsCombinationStatusCounts(
betweenSubjectsFactors: BetweenSubjectsFactor[],
) {
return participants.reduce((counts, participant) => {
const matchesCombination = !participant.rejected && participantMatchesBetweenSubjectsCombination(
participant,
stageName,
combination,
betweenSubjectsFactors,
);
const matchesCombination = !participant.rejected
&& !participant.timedOut
&& participantMatchesBetweenSubjectsCombination(
participant,
stageName,
combination,
betweenSubjectsFactors,
);
if (!matchesCombination) {
return counts;
}
Expand Down Expand Up @@ -411,6 +413,7 @@ function BetweenSubjectsCombinationTable({
participants.filter((participant) => (
!participant.completed
&& !participant.rejected
&& !participant.timedOut
&& participantMatchesBetweenSubjectsCombination(
participant,
stage.stageName,
Expand Down Expand Up @@ -442,6 +445,7 @@ function BetweenSubjectsCombinationTable({
...(showParticipantLimits ? [{
accessorKey: 'desiredParticipants',
header: 'Total / Maximum',
size: 240,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] These table sizing and padding changes are unrelated to auto-timeout behavior. Please remove this styling churn or move it to a separate change so the feature diff stays focused and easier to validate.

Cell: ({ row }: { row: { original: BetweenSubjectsCombinationRow } }) => renderDesiredParticipantsCell(
row.original,
stage,
Expand Down Expand Up @@ -490,10 +494,12 @@ function BetweenSubjectsCombinationTable({
},
mantineTableContainerProps: { style: { maxWidth: '100%', overflowX: 'auto' } },
mantineTableProps: { style: { minWidth: 'max-content', width: '100%' } },
mantineTableBodyRowProps: { style: { height: 44 } },
mantineTableBodyCellProps: ({ column, row }) => {
const compactCellStyle = { paddingBlock: 4 };
const factorIndex = betweenSubjectsFactors.findIndex((factor) => factor.factorName === column.id);
if (factorIndex === -1) {
return {};
return { style: compactCellStyle };
}

const factor = betweenSubjectsFactors[factorIndex];
Expand All @@ -503,6 +509,7 @@ function BetweenSubjectsCombinationTable({

return {
style: {
...compactCellStyle,
backgroundColor: getDistinctColorShade(factorIndex, levelIndex, factor.levels.length),
},
};
Expand Down Expand Up @@ -1030,7 +1037,10 @@ export function StageManagementItem({ studyId, studyConfig }: { studyId: string;
aria-label={`Review ${participantCounts.inProgress} in-progress participant${participantCounts.inProgress === 1 ? '' : 's'}`}
color="dark"
onClick={() => handleReviewInProgress(participants.filter((participant) => (
participant.stage === stage.stageName && !participant.completed && !participant.rejected
participant.stage === stage.stageName
&& !participant.completed
&& !participant.rejected
&& !participant.timedOut
)), `Showing only in-progress participants in the ${stage.stageName} stage — not all in-progress participants in the study.`)}
p={0}
size="compact-xs"
Expand Down
4 changes: 3 additions & 1 deletion src/analysis/individualStudy/stats/tests/StatsView.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ vi.mock('../../summary/OverviewStats', () => ({
vi.mock('../../summary/utils', () => ({
getOverviewStats: vi.fn(() => ({
participantCounts: {
total: 5, completed: 3, inProgress: 1, rejected: 1,
total: 5, completed: 3, inProgress: 1, rejected: 1, timedOut: 0,
},
startDate: null,
endDate: null,
Expand Down Expand Up @@ -112,6 +112,8 @@ const mockParticipant: ParticipantDataWithStatus = {
userAgent: '', resolution: { width: 0, height: 0 }, language: '', ip: '',
},
completed: true,
timedOut: false,
completedLate: false,
rejected: false,
participantTags: [],
stage: 'DEFAULT',
Expand Down
Loading
Loading