Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ describe('InstrumentRecordsController', () => {
);
});

it('should summarize the records of the requested group, scoped to the caller ability', async () => {
const summary = [{ lastCollectedAt: new Date('2025-01-01'), recordCount: 2, subjectId: 'subject-1' }];
instrumentRecordsService.summarizeBySubject.mockResolvedValueOnce(summary);
await expect(instrumentRecordsController.summarizeBySubject(ability, 'group-1')).resolves.toBe(summary);
expect(instrumentRecordsService.summarizeBySubject).toHaveBeenCalledWith({ groupId: 'group-1' }, { ability });
});

it('should update the record with the unwrapped data, scoped to the caller ability', async () => {
await instrumentRecordsController.updateById('record-1', { data: { score: 2 } }, ability);
expect(instrumentRecordsService.updateById).toHaveBeenCalledWith('record-1', { score: 2 }, { ability });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1182,4 +1182,86 @@ describe('InstrumentRecordsService', () => {
});
});
});

describe('summarizeBySubject', () => {
const ADMIN = createAppAbility([{ action: 'manage', subject: 'all' }]);

it('should verify the group exists before summarizing it, so an unknown group is a 404 rather than an empty result', async () => {
instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([]);

await instrumentRecordsService.summarizeBySubject({ groupId: 'group-1' }, { ability: ADMIN });

expect(groupsService.findById).toHaveBeenCalledWith('group-1');
});

// Mongodb omits a grouping expression that resolves to nothing from `_id`, and `$project` then
// drops the field from the row entirely. `$ifNull` is what keeps the row shape fixed, so the
// fold can be written against it rather than guarding for an absent field.
it('should normalise every grouping key with $ifNull, so a missing field arrives as null rather than absent', async () => {
instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([]);

await instrumentRecordsService.summarizeBySubject({}, { ability: ADMIN });

const [{ pipeline }] = instrumentRecordModel.aggregateRaw.mock.lastCall as [{ pipeline: any[] }];
expect(pipeline.find((stage) => stage.$group).$group._id).toStrictEqual({
groupId: { $ifNull: ['$groupId', null] },
subjectId: { $ifNull: ['$subjectId', null] }
});
});

it('should key the summary on the subject, so the subject hub can read a row per subject', async () => {
instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([
{ groupId: 'group-1', lastCollectedAt: '2025-02-02T00:00:00.000Z', recordCount: 4, subjectId: 'subject-1' }
]);

const [summary] = await instrumentRecordsService.summarizeBySubject({}, { ability: ADMIN });

expect(summary).toStrictEqual({
lastCollectedAt: new Date('2025-02-02T00:00:00.000Z'),
recordCount: 4,
subjectId: 'subject-1'
});
});

it("should drop rows from a group the caller cannot read, so a shared subject does not leak another group's count", async () => {
const ability = createAppAbility([
{ action: 'read', conditions: { groupId: 'group-1' }, subject: 'InstrumentRecord' }
]);
instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([
{ groupId: 'group-1', lastCollectedAt: null, recordCount: 2, subjectId: 'subject-1' },
{ groupId: 'group-2', lastCollectedAt: null, recordCount: 7, subjectId: 'subject-1' }
]);

const [summary] = await instrumentRecordsService.summarizeBySubject({}, { ability });

expect(summary).toMatchObject({ recordCount: 2, subjectId: 'subject-1' });
});

// A subject seen in two readable groups is one row in the table, so the counts add and the date
// is the later of the two rather than whichever arrived last.
it('should fold a subject appearing in several readable groups into one row', async () => {
instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([
{ groupId: 'group-1', lastCollectedAt: '2025-01-01T00:00:00.000Z', recordCount: 2, subjectId: 'subject-1' },
{ groupId: 'group-2', lastCollectedAt: '2025-03-03T00:00:00.000Z', recordCount: 3, subjectId: 'subject-1' }
]);

const summaries = await instrumentRecordsService.summarizeBySubject({}, { ability: ADMIN });

expect(summaries).toStrictEqual([
{
lastCollectedAt: new Date('2025-03-03T00:00:00.000Z'),
recordCount: 5,
subjectId: 'subject-1'
}
]);
});

it('should skip a row whose subject could not be resolved, rather than keying a summary on null', async () => {
instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([
{ groupId: 'group-1', lastCollectedAt: null, recordCount: 2, subjectId: null }
]);

expect(await instrumentRecordsService.summarizeBySubject({}, { ability: ADMIN })).toStrictEqual([]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ export class InstrumentRecordsController {
return this.instrumentRecordsService.linearModel({ groupId, instrumentId }, { ability });
}

@ApiOperation({ description: 'Per-subject record counts', summary: 'Summarize Records By Subject' })
@Get('summary/by-subject')
@RouteAccess({ action: 'read', subject: 'InstrumentRecord' })
summarizeBySubject(@CurrentUser('ability') ability: AppAbility, @Query('groupId') groupId?: string) {
return this.instrumentRecordsService.summarizeBySubject({ groupId }, { ability });
}

@ApiOperation({ summary: 'Update Instrument Record' })
@Patch(':id')
@RouteAccess({ action: 'update', subject: 'InstrumentRecord' })
Expand Down
85 changes: 85 additions & 0 deletions apps/api/src/instrument-records/instrument-records.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
InstrumentRecordQueryParams,
InstrumentRecordsExport,
LinearRegressionResults,
SubjectRecordSummary,
UploadInstrumentRecordsData
} from '@opendatacapture/schemas/instrument-records';
import { Prisma } from '@prisma/client';
Expand Down Expand Up @@ -352,6 +353,90 @@ export class InstrumentRecordsService {
return results;
}

/**
* Per-subject record counts and latest collection date, for the subject hub's listing.
*
* Aggregation groups by group and subject rather than by subject alone, and the final fold happens
* here in JS, because the per-row `groupId` is what the ability check needs. The grouped set is
* bounded by subjects, not by records.
*/
async summarizeBySubject(
{ groupId }: { groupId?: string } = {},
{ ability }: Required<Pick<EntityOperationOptions, 'ability'>>
): Promise<SubjectRecordSummary[]> {
if (groupId) {
await this.groupsService.findById(groupId);
}

const pipeline = [
{
$match: {
// records created before the file instrument feature do not have the field at all
$or: [{ pending: { $exists: false } }, { pending: null }, { pending: false }],
...(groupId ? { $expr: { $eq: ['$groupId', { $toObjectId: groupId }] } } : {})
}
},
{
// `$ifNull` is load-bearing, not defensive. A grouping expression that resolves to nothing —
// `groupId` on a record belonging to no group — is omitted from `_id` entirely rather than
// stored as null, and `$project` then omits the field from the row too. Normalising here is
// what lets the fold below treat the shape as fixed instead of remembering a key can simply
// be missing.
$group: {
_id: {
groupId: { $ifNull: ['$groupId', null] },
subjectId: { $ifNull: ['$subjectId', null] }
},
lastCollectedAt: { $max: '$date' },
recordCount: { $sum: 1 }
}
},
{
$project: {
_id: 0,
groupId: { $toString: '$_id.groupId' },
lastCollectedAt: { $dateToString: { date: '$lastCollectedAt' } },
recordCount: 1,
subjectId: '$_id.subjectId'
}
}
];

const rows = (await this.instrumentRecordModel.aggregateRaw({ pipeline })) as unknown as {
groupId: null | string;
lastCollectedAt: null | string;
recordCount: number;
subjectId: null | string;
}[];

const summaries = new Map<string, { lastCollectedAt: Date | null; recordCount: number }>();
for (const row of rows) {
// Raw rows carry no model name, so CASL would resolve them as `Object` and match only `manage all`
if (!ability.can('read', forcedAppSubject('InstrumentRecord', { groupId: row.groupId }))) {
continue;
}
if (row.subjectId === null) {
continue;
}
let summary = summaries.get(row.subjectId);
if (!summary) {
summary = { lastCollectedAt: null, recordCount: 0 };
summaries.set(row.subjectId, summary);
}
summary.recordCount += row.recordCount;
const lastCollectedAt = row.lastCollectedAt ? new Date(row.lastCollectedAt) : null;
if (lastCollectedAt && (!summary.lastCollectedAt || lastCollectedAt > summary.lastCollectedAt)) {
summary.lastCollectedAt = lastCollectedAt;
}
}

return Array.from(summaries, ([subjectId, summary]) => ({
lastCollectedAt: summary.lastCollectedAt,
recordCount: summary.recordCount,
subjectId
}));
}

async updateById(id: string, data: unknown[] | { [key: string]: unknown }, { ability }: EntityOperationOptions = {}) {
const instrumentRecord = await this.instrumentRecordModel.findFirst({
where: { id }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@ import React, { useState } from 'react';

import { toBasicISOString } from '@douglasneuroinformatics/libjs';
import { Button, Checkbox, DataTable, FileDropzone, Tabs, TextArea } from '@douglasneuroinformatics/libui/components';
import type { TanstackTable } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { cn } from '@douglasneuroinformatics/libui/utils';
import { BULK_ASSIGNMENT_MAX_SUBJECTS } from '@opendatacapture/schemas/assignment';
import type { Subject } from '@opendatacapture/schemas/subject';
import { removeSubjectIdScope } from '@opendatacapture/subject-utils';
import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react';

import { SortableHeader } from '@/components/SortableHeader';
import {
ACCEPTED_FILE_EXTENSIONS,
assertFileSize,
Expand All @@ -34,25 +32,6 @@ type PickerRow = {
subject: string;
};

/**
* A clickable column label. `DataTableHead` renders whatever the column supplies, so the sort
* affordance lives here rather than coming from the table.
*/
const SortableHeader = ({ column, label }: { column: TanstackTable.Column<PickerRow>; label: string }) => {
const sorted = column.getIsSorted();
const Icon = sorted === 'asc' ? ChevronUpIcon : sorted === 'desc' ? ChevronDownIcon : ChevronsUpDownIcon;
return (
<button
className="hover:text-foreground flex items-center gap-1 transition-colors"
type="button"
onClick={() => column.toggleSorting()}
>
{label}
<Icon className={cn('h-3.5 w-3.5', !sorted && 'opacity-40')} />
</button>
);
};

type SourceStepProps = {
onBack?: () => void;
onParsed: (parsed: BulkParseResult) => void;
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/components/ColorTagCell/ColorTagCell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
type ColorTagCellProps = {
color: string;
'data-testid'?: string;
label: string;
};

/**
* A table cell tagging a nominal value with a colour: a dot carrying the colour, and the name beside
* it in ordinary ink.
*
* The colour is always redundant — the label is right there — which is what keeps it readable for a
* colourblind user and why the text itself is never tinted. The label clips rather than wrapping, so
* one long value cannot make every row in the table taller.
*/
export const ColorTagCell = ({ color, 'data-testid': testId, label }: ColorTagCellProps) => (
<span className="flex items-center gap-1.5 overflow-hidden" data-testid={testId} title={label}>
{/* The hairline ring is what lets a pastel fill stay visible against a light surface, where a
bare dot of it would sit near 1.5:1 and effectively disappear. */}
<span
className="h-2 w-2 shrink-0 rounded-full ring-1 ring-black/20 dark:ring-white/25"
style={{ backgroundColor: color }}
/>
<span className="truncate">{label}</span>
</span>
);
1 change: 1 addition & 0 deletions apps/web/src/components/ColorTagCell/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ColorTagCell';
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import React, { useState } from 'react';

import { toBasicISOString } from '@douglasneuroinformatics/libjs';
import { Button, Card, Checkbox, DataTable, Dialog } from '@douglasneuroinformatics/libui/components';
import type { TanstackTable } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { cn } from '@douglasneuroinformatics/libui/utils';
import type { Assignment } from '@opendatacapture/schemas/assignment';
import type { UnilingualInstrumentInfo } from '@opendatacapture/schemas/instrument';
import { removeSubjectIdScope } from '@opendatacapture/subject-utils';
import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon, CircleAlertIcon, CircleDotIcon } from 'lucide-react';
import { CircleAlertIcon, CircleDotIcon } from 'lucide-react';

import { SortableHeader } from '@/components/SortableHeader';
import { useAssignmentsQuery } from '@/hooks/useAssignmentsQuery';
import { useDeleteBulkAssignmentsMutation } from '@/hooks/useDeleteBulkAssignmentsMutation';
import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery';
Expand All @@ -23,21 +22,6 @@ type AssignmentRow = {
subject: string;
};

const SortableHeader = ({ column, label }: { column: TanstackTable.Column<AssignmentRow>; label: string }) => {
const sorted = column.getIsSorted();
const Icon = sorted === 'asc' ? ChevronUpIcon : sorted === 'desc' ? ChevronDownIcon : ChevronsUpDownIcon;
return (
<button
className="hover:text-foreground flex items-center gap-1 transition-colors"
type="button"
onClick={() => column.toggleSorting()}
>
{label}
<Icon className={cn('h-3.5 w-3.5', !sorted && 'opacity-40')} />
</button>
);
};

const StatusCell = ({ status }: { status: 'EXPIRED' | 'OUTSTANDING' }) => {
const { t } = useTranslation();
if (status === 'OUTSTANDING') {
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/components/SortableHeader/SortableHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { TanstackTable } from '@douglasneuroinformatics/libui/components';
import { cn } from '@douglasneuroinformatics/libui/utils';
import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react';

type SortableHeaderProps<TRow> = {
column: TanstackTable.Column<TRow>;
label: string;
};

/**
* A clickable column label. `DataTableHead` renders whatever the column supplies, so the sort
* affordance lives here rather than coming from the table.
*/
export const SortableHeader = <TRow,>({ column, label }: SortableHeaderProps<TRow>) => {
const sorted = column.getIsSorted();
const Icon = sorted === 'asc' ? ChevronUpIcon : sorted === 'desc' ? ChevronDownIcon : ChevronsUpDownIcon;
return (
<button
className="hover:text-foreground flex items-center gap-1 transition-colors"
type="button"
onClick={() => column.toggleSorting()}
>
{label}
<Icon className={cn('h-3.5 w-3.5 shrink-0', !sorted && 'opacity-40')} />
</button>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import { SortableHeader } from '@/components/SortableHeader';

type Row = { id: string };

const columnOf = (sorted: 'asc' | 'desc' | false, toggleSorting = vi.fn()) =>
({ getIsSorted: () => sorted, toggleSorting }) as never;

describe('SortableHeader', () => {
it('should render the column label', () => {
render(<SortableHeader<Row> column={columnOf(false)} label="Instrument" />);
expect(screen.getByRole('button', { name: /Instrument/u })).toBeTruthy();
});

it('should ask the column to toggle its sort when clicked', () => {
const toggleSorting = vi.fn();
render(<SortableHeader<Row> column={columnOf(false, toggleSorting)} label="Records" />);

fireEvent.click(screen.getByRole('button', { name: /Records/u }));

expect(toggleSorting).toHaveBeenCalledTimes(1);
});

// The icon is the only thing distinguishing the three states, so it has to change with them.
it('should show a distinct icon for unsorted, ascending and descending', () => {
const iconOf = (sorted: 'asc' | 'desc' | false) => {
const { container, unmount } = render(<SortableHeader<Row> column={columnOf(sorted)} label="Subjects" />);
const path = container.querySelector('svg')?.innerHTML ?? '';
unmount();
return path;
};

const [unsorted, ascending, descending] = [iconOf(false), iconOf('asc'), iconOf('desc')];
expect(new Set([ascending, descending, unsorted]).size).toBe(3);
});

// Dimming is what tells a user which column the table is actually ordered by.
it('should dim the icon only while the column is unsorted', () => {
const { container: unsorted } = render(<SortableHeader<Row> column={columnOf(false)} label="A" />);
expect(unsorted.querySelector('svg')?.getAttribute('class')).toContain('opacity-40');

const { container: sorted } = render(<SortableHeader<Row> column={columnOf('asc')} label="B" />);
expect(sorted.querySelector('svg')?.getAttribute('class')).not.toContain('opacity-40');
});
});
Loading
Loading