diff --git a/apps/api/src/instrument-records/__tests__/export-worker.test.ts b/apps/api/src/instrument-records/__tests__/export-worker.test.ts index 5d3e1fbf9..d2d85b065 100644 --- a/apps/api/src/instrument-records/__tests__/export-worker.test.ts +++ b/apps/api/src/instrument-records/__tests__/export-worker.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'events'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { InitData, ParentMessage, RecordType } from '../thread-types'; +import type { InitPayload, ParentMessage, RecordType } from '../thread-types'; class MockParentPort extends EventEmitter { postMessage = vi.fn(); @@ -14,7 +14,10 @@ class MockParentPort extends EventEmitter { const RUNTIME_INTERNAL_SPECIFIER = '#runtime/v1/@opendatacapture/runtime-internal/index.js'; -const initData: InitData = [{ edition: 2, id: 'instrument-1', name: 'Happiness Questionnaire' }]; +const initData: InitPayload = { + instruments: [{ edition: 2, id: 'instrument-1', name: 'Happiness Questionnaire' }], + seriesNames: { 'series-1': 'Happiness Series' } +}; const createRecord = (overrides: Partial = {}): RecordType => ({ computedMeasures: { score: 85 }, @@ -22,6 +25,7 @@ const createRecord = (overrides: Partial = {}): RecordType => ({ groupId: 'group-1', id: 'record-1', instrumentId: 'instrument-1', + seriesInstrumentId: null, session: { date: '2025-01-01', id: 'session-1', @@ -42,6 +46,8 @@ const expectedRow = { instrumentEdition: 2, instrumentName: 'Happiness Questionnaire', measure: 'score', + seriesId: null, + seriesName: null, sessionDate: '2025-01-01', sessionId: 'session-1', sessionType: 'IN_PERSON', @@ -102,6 +108,28 @@ describe('export-worker', () => { expect(parentPort.postMessage).toHaveBeenCalledWith({ success: true }); }); + // The id is the stable identifier an analysis keys on; the name is carried beside it only because + // a bare id in a CSV is unreadable. + it('should carry both the series id and its resolved title on a record a series orchestrated', async () => { + const parentPort = await loadInitializedWorker(); + parentPort.send({ data: [createRecord({ seriesInstrumentId: 'series-1' })], type: 'BEGIN_CHUNK_PROCESSING' }); + expect(parentPort.postMessage).toHaveBeenLastCalledWith({ + data: [{ ...expectedRow, seriesId: 'series-1', seriesName: 'Happiness Series' }], + success: true + }); + }); + + // A series deleted since its records were collected still has an id on them, and the export has to + // report what it knows rather than failing the whole download. + it('should report the series id with a null title when no title was resolved for it', async () => { + const parentPort = await loadInitializedWorker(); + parentPort.send({ data: [createRecord({ seriesInstrumentId: 'series-missing' })], type: 'BEGIN_CHUNK_PROCESSING' }); + expect(parentPort.postMessage).toHaveBeenLastCalledWith({ + data: [{ ...expectedRow, seriesId: 'series-missing', seriesName: null }], + success: true + }); + }); + it('should throw on an unknown message type, so a protocol mismatch is not silently ignored', async () => { const parentPort = (await loadWorker())!; expect(() => parentPort.emit('message', { type: 'UNKNOWN' })).toThrow('Unexpected message type: UNKNOWN'); diff --git a/apps/api/src/instrument-records/__tests__/instrument-records.controller.test.ts b/apps/api/src/instrument-records/__tests__/instrument-records.controller.test.ts index 6353ca2f3..4d793332d 100644 --- a/apps/api/src/instrument-records/__tests__/instrument-records.controller.test.ts +++ b/apps/api/src/instrument-records/__tests__/instrument-records.controller.test.ts @@ -44,9 +44,24 @@ describe('InstrumentRecordsController', () => { it('should pass every query filter to find, scoped to the caller ability', async () => { const minDate = new Date('2025-01-01'); - await instrumentRecordsController.find(ability, 'FORM', minDate, 'group-1', 'instrument-1', 'subject-1'); + await instrumentRecordsController.find( + ability, + 'FORM', + minDate, + 'group-1', + 'instrument-1', + 'series-1', + 'subject-1' + ); expect(instrumentRecordsService.find).toHaveBeenCalledWith( - { groupId: 'group-1', instrumentId: 'instrument-1', kind: 'FORM', minDate, subjectId: 'subject-1' }, + { + groupId: 'group-1', + instrumentId: 'instrument-1', + kind: 'FORM', + minDate, + seriesInstrumentId: 'series-1', + subjectId: 'subject-1' + }, { ability } ); }); @@ -79,6 +94,21 @@ describe('InstrumentRecordsController', () => { expect(instrumentRecordsService.summarizeBySubject).toHaveBeenCalledWith({ groupId: 'group-1' }, { ability }); }); + it('should summarize the records per instrument, scoped to the caller ability', async () => { + const summary = [{ instrumentId: 'instrument-1', lastCollectedAt: null, recordCount: 2, subjectCount: 1 }]; + instrumentRecordsService.summarizeByInstrument.mockResolvedValueOnce(summary); + await expect(instrumentRecordsController.summarizeByInstrument(ability, 'group-1')).resolves.toBe(summary); + expect(instrumentRecordsService.summarizeByInstrument).toHaveBeenCalledWith({ groupId: 'group-1' }, { ability }); + }); + + // A series owns no records under its own id, so its totals come from a separate aggregation. + it('should summarize the records per series, scoped to the caller ability', async () => { + const summary = [{ instrumentId: 'series-1', lastCollectedAt: null, recordCount: 4, subjectCount: 2 }]; + instrumentRecordsService.summarizeBySeries.mockResolvedValueOnce(summary); + await expect(instrumentRecordsController.summarizeBySeries(ability, 'group-1')).resolves.toBe(summary); + expect(instrumentRecordsService.summarizeBySeries).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 }); diff --git a/apps/api/src/instrument-records/__tests__/instrument-records.service.test.ts b/apps/api/src/instrument-records/__tests__/instrument-records.service.test.ts index 1a18a1bba..61e0d06f2 100644 --- a/apps/api/src/instrument-records/__tests__/instrument-records.service.test.ts +++ b/apps/api/src/instrument-records/__tests__/instrument-records.service.test.ts @@ -676,6 +676,27 @@ describe('InstrumentRecordsService', () => { expect(groupsService.findById).toHaveBeenCalledBefore(instrumentRecordModel.findMany); }); + // Asking a scalar instrument for what it orchestrated is a mistake rather than an empty result, + // so it is refused instead of silently returning nothing. + it('should reject a seriesInstrumentId that names an instrument which is not a series', async () => { + instrumentsService.findById.mockResolvedValueOnce({ id: 'form-1', kind: 'FORM' } as any); + + await expect(instrumentRecordsService.find({ seriesInstrumentId: 'form-1' })).rejects.toBeInstanceOf( + UnprocessableEntityException + ); + expect(instrumentRecordModel.findMany).not.toHaveBeenCalled(); + }); + + it('should query the records a series orchestrated', async () => { + instrumentsService.findById.mockResolvedValueOnce({ id: 'series-1', kind: 'SERIES' } as any); + + await instrumentRecordsService.find({ seriesInstrumentId: 'series-1' }); + + expect(instrumentRecordModel.findMany.mock.lastCall?.[0].where.AND).toContainEqual({ + seriesInstrumentId: 'series-1' + }); + }); + it('should restrict the records to instruments of the requested kind', async () => { instrumentsService.find.mockResolvedValueOnce([{ id: 'form-1' }, { id: 'form-2' }]); @@ -735,7 +756,7 @@ describe('InstrumentRecordsService', () => { await instrumentRecordsService.find({ subjectId: 'subject-1' }); expect(sessionModel.findMany).toHaveBeenCalledWith({ - select: { id: true, user: { select: { username: true } } }, + select: { id: true, type: true, user: { select: { username: true } } }, where: { AND: [{}, { id: { in: ['session-1', 'session-2'] } }] } }); }); @@ -751,7 +772,7 @@ describe('InstrumentRecordsService', () => { await instrumentRecordsService.find({ subjectId: 'subject-1' }, { ability }); expect(sessionModel.findMany).toHaveBeenCalledWith({ - select: { id: true, user: { select: { username: true } } }, + select: { id: true, type: true, user: { select: { username: true } } }, where: { AND: [accessibleQuery(ability, 'read', 'Session'), { id: { in: ['session-1'] } }] } }); }); @@ -786,6 +807,7 @@ describe('InstrumentRecordsService', () => { groupId: '123', id: 'record-1', instrumentId: 'instrument-1', + seriesInstrumentId: null, session: { date: '2023-01-01', id: 'session-1', @@ -915,6 +937,47 @@ describe('InstrumentRecordsService', () => { expect(matchStage.$match.$expr).toStrictEqual({ $eq: ['$groupId', { $toObjectId: 'group-1' }] }); }); + // A series carries no language-independent name, so the worker is handed the titles resolved + // here rather than the ids — an export naming only ids says nothing about what collected a row. + it('should resolve the title of every series the records reference before handing them to the worker', async () => { + const record = { + computedMeasures: { score: 85 }, + date: '2023-01-01', + groupId: 'group-1', + id: 'record-1', + instrumentId: 'instrument-1', + seriesInstrumentId: 'series-1', + session: { date: '2023-01-01', id: 'session-1', type: 'IN_PERSON' as const, user: null }, + subject: { age: 20, groupIds: ['group-1'], id: 'subject-1', sex: 'MALE' } + } satisfies RecordType; + instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([record]); + instrumentsService.findById + .mockResolvedValueOnce({ id: 'instrument-1', internal: { edition: 1, name: 'X' } } as any) + .mockResolvedValueOnce({ + details: { title: 'Happiness Series' }, + id: 'series-1', + kind: 'SERIES', + language: 'en' + } as any); + const worker = useFakeWorker(); + + const result = instrumentRecordsService.exportRecords( + {}, + { ability: createAppAbility([{ action: 'manage', subject: 'all' }]) } + ); + await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalledOnce()); + + expect(worker.postMessage).toHaveBeenCalledWith({ + data: { + instruments: [{ edition: 1, id: 'instrument-1', name: 'X' }], + seriesNames: { 'series-1': 'Happiness Series' } + }, + type: 'INIT' + }); + worker.emit('error', new Error('settle')); + await expect(result).rejects.toThrow('settle'); + }); + describe('when the worker fails', () => { const record = { computedMeasures: { score: 85 }, @@ -922,6 +985,7 @@ describe('InstrumentRecordsService', () => { groupId: 'group-1', id: 'record-1', instrumentId: 'instrument-1', + seriesInstrumentId: null, session: { date: '2023-01-01', id: 'session-1', type: 'IN_PERSON' as const, user: null }, subject: { age: 20, groupIds: ['group-1'], id: 'subject-1', sex: 'MALE' } } satisfies RecordType; @@ -1205,13 +1269,20 @@ describe('InstrumentRecordsService', () => { const [{ pipeline }] = instrumentRecordModel.aggregateRaw.mock.lastCall as [{ pipeline: any[] }]; expect(pipeline.find((stage) => stage.$group).$group._id).toStrictEqual({ groupId: { $ifNull: ['$groupId', null] }, + key: { $ifNull: ['$subjectId', 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' } + { + groupId: 'group-1', + key: 'subject-1', + lastCollectedAt: '2025-02-02T00:00:00.000Z', + recordCount: 4, + subjectId: 'subject-1' + } ]); const [summary] = await instrumentRecordsService.summarizeBySubject({}, { ability: ADMIN }); @@ -1228,8 +1299,8 @@ describe('InstrumentRecordsService', () => { { 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' } + { groupId: 'group-1', key: 'subject-1', lastCollectedAt: null, recordCount: 2, subjectId: 'subject-1' }, + { groupId: 'group-2', key: 'subject-1', lastCollectedAt: null, recordCount: 7, subjectId: 'subject-1' } ]); const [summary] = await instrumentRecordsService.summarizeBySubject({}, { ability }); @@ -1241,8 +1312,20 @@ describe('InstrumentRecordsService', () => { // 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' } + { + groupId: 'group-1', + key: 'subject-1', + lastCollectedAt: '2025-01-01T00:00:00.000Z', + recordCount: 2, + subjectId: 'subject-1' + }, + { + groupId: 'group-2', + key: 'subject-1', + lastCollectedAt: '2025-03-03T00:00:00.000Z', + recordCount: 3, + subjectId: 'subject-1' + } ]); const summaries = await instrumentRecordsService.summarizeBySubject({}, { ability: ADMIN }); @@ -1258,10 +1341,98 @@ describe('InstrumentRecordsService', () => { 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 } + { groupId: 'group-1', key: null, lastCollectedAt: null, recordCount: 2, subjectId: null } ]); expect(await instrumentRecordsService.summarizeBySubject({}, { ability: ADMIN })).toStrictEqual([]); }); }); + + describe('summarizeByInstrument', () => { + const ADMIN = createAppAbility([{ action: 'manage', subject: 'all' }]); + + it('should group by the instrument, so the hub can read a row per instrument', async () => { + instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([]); + + await instrumentRecordsService.summarizeByInstrument({}, { ability: ADMIN }); + + const [{ pipeline }] = instrumentRecordModel.aggregateRaw.mock.lastCall as [{ pipeline: any[] }]; + expect(pipeline.find((stage) => stage.$group).$group._id.key).toStrictEqual({ + $ifNull: ['$instrumentId', null] + }); + }); + + // The count is of subjects, not records, which is the whole reason the pipeline groups by subject + // and folds here: one subject completing an instrument four times is still one subject. + it('should count a subject once however many records they contributed', async () => { + instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([ + { groupId: 'group-1', key: 'instrument-1', lastCollectedAt: null, recordCount: 4, subjectId: 'subject-1' }, + { groupId: 'group-1', key: 'instrument-1', lastCollectedAt: null, recordCount: 1, subjectId: 'subject-2' } + ]); + + const [summary] = await instrumentRecordsService.summarizeByInstrument({}, { ability: ADMIN }); + + expect(summary).toStrictEqual({ + instrumentId: 'instrument-1', + lastCollectedAt: null, + recordCount: 5, + subjectCount: 2 + }); + }); + + // Summing per-group counts would report this subject twice. Unioning the ids is what makes the + // count exact for a caller who can read both groups. + it('should count a subject shared by two readable groups once', async () => { + instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([ + { groupId: 'group-1', key: 'instrument-1', lastCollectedAt: null, recordCount: 1, subjectId: 'subject-1' }, + { groupId: 'group-2', key: 'instrument-1', lastCollectedAt: null, recordCount: 1, subjectId: 'subject-1' } + ]); + + const [summary] = await instrumentRecordsService.summarizeByInstrument({}, { ability: ADMIN }); + + expect(summary).toMatchObject({ recordCount: 2, subjectCount: 1 }); + }); + + // A record written before subjects were required carries no subject, and counting `null` as one + // would report a subject nobody can open. + it('should count no subject for a row naming none, while still counting its records', async () => { + instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([ + { groupId: 'group-1', key: 'instrument-1', lastCollectedAt: null, recordCount: 2, subjectId: null } + ]); + + const [summary] = await instrumentRecordsService.summarizeByInstrument({}, { ability: ADMIN }); + + expect(summary).toMatchObject({ recordCount: 2, subjectCount: 0 }); + }); + }); + + describe('summarizeBySeries', () => { + const ADMIN = createAppAbility([{ action: 'manage', subject: 'all' }]); + + it('should group by the series instrument, so a series row spans the instruments it composes', async () => { + instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([]); + + await instrumentRecordsService.summarizeBySeries({}, { ability: ADMIN }); + + const [{ pipeline }] = instrumentRecordModel.aggregateRaw.mock.lastCall as [{ pipeline: any[] }]; + expect(pipeline.find((stage) => stage.$group).$group._id.key).toStrictEqual({ + $ifNull: ['$seriesInstrumentId', null] + }); + }); + + // Grouping by series buckets every individually collected record under a null key. Those are not + // a series and must not become a row, or the hub would show one labelled nothing. + it('should drop the null bucket holding every record collected outside a series', async () => { + instrumentRecordModel.aggregateRaw.mockResolvedValueOnce([ + { groupId: 'group-1', key: null, lastCollectedAt: null, recordCount: 90, subjectId: 'subject-1' }, + { groupId: 'group-1', key: 'series-1', lastCollectedAt: null, recordCount: 2, subjectId: 'subject-1' } + ]); + + const summaries = await instrumentRecordsService.summarizeBySeries({}, { ability: ADMIN }); + + expect(summaries).toStrictEqual([ + { instrumentId: 'series-1', lastCollectedAt: null, recordCount: 2, subjectCount: 1 } + ]); + }); + }); }); diff --git a/apps/api/src/instrument-records/export-worker.js b/apps/api/src/instrument-records/export-worker.js index 0bf932a8c..a542e5793 100644 --- a/apps/api/src/instrument-records/export-worker.js +++ b/apps/api/src/instrument-records/export-worker.js @@ -50,12 +50,19 @@ function expandData(listEntry) { */ let initData; +/** + * Internal cache mapping a series instrument id to its title. + * @type {{ [id: string]: string }} + */ +let seriesNames = {}; + /** * Initializes the worker with instrument metadata. - * * @param {Array<{id: string, edition: number, name: string}>} data - The initialization payload. + * @param {import('./thread-types').InitPayload} data - The initialization payload. */ function handleInit(data) { - initData = new Map(data.map((instrument) => [instrument.id, instrument])); + initData = new Map(data.instruments.map((instrument) => [instrument.id, instrument])); + seriesNames = data.seriesNames; parentPort?.postMessage({ success: true }); } @@ -87,6 +94,9 @@ function handleChunkComplete(_data) { const rows = []; + const seriesId = record.seriesInstrumentId ?? null; + const seriesName = seriesId ? (seriesNames[seriesId] ?? null) : null; + for (const [measureKey, measureValue] of Object.entries(record.computedMeasures)) { if (measureValue == null) continue; @@ -96,6 +106,8 @@ function handleChunkComplete(_data) { instrumentEdition: instrument.edition, instrumentName: instrument.name, measure: measureKey, + seriesId, + seriesName, sessionDate: record.session.date, sessionId: record.session.id, sessionType: record.session.type, @@ -118,6 +130,8 @@ function handleChunkComplete(_data) { instrumentEdition: instrument.edition, instrumentName: instrument.name, measure: `${measureKey} - ${entry.measure}`, + seriesId, + seriesName, sessionDate: record.session.date, sessionId: record.session.id, sessionType: record.session.type, diff --git a/apps/api/src/instrument-records/instrument-records.controller.ts b/apps/api/src/instrument-records/instrument-records.controller.ts index e6ff8bbc3..4fcb2449f 100644 --- a/apps/api/src/instrument-records/instrument-records.controller.ts +++ b/apps/api/src/instrument-records/instrument-records.controller.ts @@ -62,9 +62,13 @@ export class InstrumentRecordsController { minDate?: Date, @Query('groupId') groupId?: string, @Query('instrumentId') instrumentId?: string, + @Query('seriesInstrumentId') seriesInstrumentId?: string, @Query('subjectId') subjectId?: string ) { - return this.instrumentRecordsService.find({ groupId, instrumentId, kind, minDate, subjectId }, { ability }); + return this.instrumentRecordsService.find( + { groupId, instrumentId, kind, minDate, seriesInstrumentId, subjectId }, + { ability } + ); } @ApiOperation({ summary: 'Delete Record' }) @@ -94,6 +98,20 @@ export class InstrumentRecordsController { return this.instrumentRecordsService.linearModel({ groupId, instrumentId }, { ability }); } + @ApiOperation({ description: 'Per-instrument record and subject counts', summary: 'Summarize Records By Instrument' }) + @Get('summary/by-instrument') + @RouteAccess({ action: 'read', subject: 'InstrumentRecord' }) + summarizeByInstrument(@CurrentUser('ability') ability: AppAbility, @Query('groupId') groupId?: string) { + return this.instrumentRecordsService.summarizeByInstrument({ groupId }, { ability }); + } + + @ApiOperation({ description: 'Per-series record counts', summary: 'Summarize Records By Series' }) + @Get('summary/by-series') + @RouteAccess({ action: 'read', subject: 'InstrumentRecord' }) + summarizeBySeries(@CurrentUser('ability') ability: AppAbility, @Query('groupId') groupId?: string) { + return this.instrumentRecordsService.summarizeBySeries({ groupId }, { ability }); + } + @ApiOperation({ description: 'Per-subject record counts', summary: 'Summarize Records By Subject' }) @Get('summary/by-subject') @RouteAccess({ action: 'read', subject: 'InstrumentRecord' }) diff --git a/apps/api/src/instrument-records/instrument-records.service.ts b/apps/api/src/instrument-records/instrument-records.service.ts index 877a17474..51727b735 100644 --- a/apps/api/src/instrument-records/instrument-records.service.ts +++ b/apps/api/src/instrument-records/instrument-records.service.ts @@ -14,12 +14,14 @@ import { ServiceUnavailableException, UnprocessableEntityException } from '@nestjs/common'; +import { translateInstrument } from '@opendatacapture/instrument-utils'; import type { Json, ScalarInstrument } from '@opendatacapture/runtime-core'; import type { CreateInstrumentRecordData, InstrumentRecord, InstrumentRecordQueryParams, InstrumentRecordsExport, + InstrumentRecordSummary, LinearRegressionResults, SubjectRecordSummary, UploadInstrumentRecordsData @@ -41,7 +43,29 @@ import { UsersService } from '@/users/users.service'; import { InstrumentMeasuresService } from './instrument-measures.service'; -import type { BeginChunkProcessingMessage, InitData, InitMessage, RecordType, WorkerMessage } from './thread-types'; +import type { + BeginChunkProcessingMessage, + InitInstruments, + InitMessage, + RecordType, + WorkerMessage +} from './thread-types'; + +type RecordSummaryFold = Map }>; + +/** + * Projects a fold keyed by instrument id into the wire shape, replacing the subject id set with its + * size. Shared by the per-instrument and per-series summaries, which differ only in what they keyed + * the fold on. + */ +function toInstrumentSummaries(summaries: RecordSummaryFold): InstrumentRecordSummary[] { + return Array.from(summaries, ([instrumentId, summary]) => ({ + instrumentId, + lastCollectedAt: summary.lastCollectedAt, + recordCount: summary.recordCount, + subjectCount: summary.subjectIds.size + })); +} @Injectable() export class InstrumentRecordsService { @@ -201,6 +225,8 @@ export class InstrumentRecordsService { const instruments = new Map(instrumentsArray.map((instrument) => [instrument.id, instrument])); + const seriesNames = await this.resolveSeriesNames(records); + const numWorkers = Math.min(cpus().length, Math.ceil(records.length / 100)); // Use up to CPU count, chunk size 100 const chunkSize = Math.ceil(records.length / numWorkers); const chunks = []; @@ -209,7 +235,7 @@ export class InstrumentRecordsService { chunks.push(records.slice(i, i + chunkSize)); } - const availableInstrumentArray: InitData = instruments + const availableInstrumentArray: InitInstruments = instruments .values() .toArray() .map((item) => { @@ -223,7 +249,10 @@ export class InstrumentRecordsService { const workerPromises = chunks.map((chunk) => { return new Promise((resolve, reject) => { const worker = new Worker(join(import.meta.dirname, 'export-worker.js')); - worker.postMessage({ data: availableInstrumentArray, type: 'INIT' } satisfies InitMessage); + worker.postMessage({ + data: { instruments: availableInstrumentArray, seriesNames }, + type: 'INIT' + } satisfies InitMessage); // The worker only ever acknowledges INIT with `{ success: true }`; a failure inside it arrives // as an `error` event instead, handled below. @@ -252,7 +281,7 @@ export class InstrumentRecordsService { } async find( - { groupId, instrumentId, kind, minDate, subjectId }: InstrumentRecordQueryParams, + { groupId, instrumentId, kind, minDate, seriesInstrumentId, subjectId }: InstrumentRecordQueryParams, { ability }: EntityOperationOptions = {} ): Promise { if (groupId) { @@ -261,6 +290,14 @@ export class InstrumentRecordsService { if (instrumentId) { await this.instrumentsService.findById(instrumentId); } + if (seriesInstrumentId) { + const series = await this.instrumentsService.findById(seriesInstrumentId); + if (series.kind !== 'SERIES') { + throw new UnprocessableEntityException( + `Instrument '${seriesInstrumentId}' is not a series instrument, so it orchestrates no records` + ); + } + } const instrumentKindIds = await this.instrumentsService .find({ kind }) @@ -276,6 +313,7 @@ export class InstrumentRecordsService { { groupId }, { instrumentId }, { instrumentId: { in: instrumentKindIds } }, + { seriesInstrumentId }, accessibleQuery(ability, 'read', 'InstrumentRecord'), { subjectId }, // records created before the file instrument feature do not have the pending field at all, @@ -285,14 +323,15 @@ export class InstrumentRecordsService { } }); - // Only the per-subject view renders the username column. /dashboard fetches every record in the - // group and reads `instrumentId` alone, so labelling there would buy a second query with an `$in` - // as long as the group's record list for a field that is thrown away. - if (!subjectId) { + // The per-subject, per-instrument and per-series views render the collection method and username + // columns; /dashboard fetches every record in the group and reads `instrumentId` alone, so + // labelling there would buy a second query with an `$in` as long as the group's record list for + // fields it throws away. Any of these filters bounds the set enough to be worth joining. + if (!subjectId && !instrumentId && !seriesInstrumentId) { return records; } - return this.withSessionUsernames(records, ability); + return this.withSessionMetadata(records, ability); } async findById(id: string, { ability }: EntityOperationOptions = {}) { @@ -354,82 +393,34 @@ export class InstrumentRecordsService { } /** - * Per-subject record counts and latest collection date, for the subject hub's listing. + * Per-instrument collection statistics for the instrument 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. + * See `summarizeRecords` for why the fold happens in JS rather than in the aggregation. */ - async summarizeBySubject( + async summarizeByInstrument( { groupId }: { groupId?: string } = {}, { ability }: Required> - ): Promise { - 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; - }[]; + ): Promise { + return toInstrumentSummaries(await this.summarizeRecords('instrumentId', groupId, ability)); + } - const summaries = new Map(); - 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; - } - } + /** + * Per-series collection statistics: everything a series orchestrated, across the scalar + * instruments it composes. This is what makes a series row in the instrument hub worth opening. + */ + async summarizeBySeries( + { groupId }: { groupId?: string } = {}, + { ability }: Required> + ): Promise { + return toInstrumentSummaries(await this.summarizeRecords('seriesInstrumentId', groupId, ability)); + } + /** Per-subject collection statistics for the subject hub's listing. */ + async summarizeBySubject( + { groupId }: { groupId?: string } = {}, + { ability }: Required> + ): Promise { + const summaries = await this.summarizeRecords('subjectId', groupId, ability); return Array.from(summaries, ([subjectId, summary]) => ({ lastCollectedAt: summary.lastCollectedAt, recordCount: summary.recordCount, @@ -643,13 +634,15 @@ export class InstrumentRecordsService { format: '%Y-%m-%d' } }, - groupId: { - $toString: '$groupId' - }, + // `$ifNull` for the same reason as in `summarizeRecords`: `$toString` of a missing field is + // itself missing, so a record belonging to no group would drop `groupId` from the row + // entirely and arrive as undefined behind a type that says it is a string. + groupId: { $toString: { $ifNull: ['$groupId', null] } }, id: { $toString: '$_id' }, instrumentId: 1, + seriesInstrumentId: { $ifNull: ['$seriesInstrumentId', null] }, session: { date: { $dateToString: { @@ -696,20 +689,136 @@ export class InstrumentRecordsService { .map((record) => ({ ...record })); } + /** + * Map every series instrument referenced by these records to its title. + * + * A series instrument has no `internal.name`, so unlike the scalar instruments beside it in the + * export there is no language-independent identifier to use — the title is translated here, to + * `en`, because a background worker has no request language. The stable identifier an analysis + * should key on is the accompanying `seriesId`. + */ + private async resolveSeriesNames(records: RecordType[]): Promise<{ [id: string]: string }> { + const seriesIds = new Set( + records.flatMap((record) => (record.seriesInstrumentId ? [record.seriesInstrumentId] : [])) + ); + const entries = await Promise.all( + seriesIds.values().map(async (id) => { + const instrument = await this.instrumentsService.findById(id); + return [id, translateInstrument(instrument, 'en').details.title] as const; + }) + ); + return Object.fromEntries(entries); + } + private serializeData(data: unknown) { return JSON.parse(JSON.stringify(data, replacer)) as unknown; } /** - * Label each record with the user who conducted its session, so clients can show that column - * without fetching every session in the group. + * Record counts, distinct subjects and the latest collection date, keyed by `by`. + * + * Aggregation groups by group, key and subject rather than by the key alone, and the final fold + * happens here in JS, for two reasons: the per-row `groupId` is what the ability check needs, and + * unioning subject ids across groups is what makes a distinct subject count exact for an unscoped + * caller instead of a sum that counts a shared subject twice. The grouped set is bounded by + * subjects × keys, not by records. + */ + private async summarizeRecords( + by: 'instrumentId' | 'seriesInstrumentId' | 'subjectId', + groupId: string | undefined, + ability: AppAbility + ): Promise { + 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 — + // `seriesInstrumentId` on a record collected outside any series, `groupId` on one 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 every consumer below + // treat the shape as fixed instead of remembering that a key can simply be missing. + $group: { + _id: { + groupId: { $ifNull: ['$groupId', null] }, + key: { $ifNull: [`$${by}`, null] }, + subjectId: { $ifNull: ['$subjectId', null] } + }, + lastCollectedAt: { $max: '$date' }, + recordCount: { $sum: 1 } + } + }, + { + $project: { + _id: 0, + groupId: { $toString: '$_id.groupId' }, + key: '$_id.key', + lastCollectedAt: { $dateToString: { date: '$lastCollectedAt' } }, + recordCount: 1, + subjectId: '$_id.subjectId' + } + } + ]; + + /** + * Every field is present because the pipeline normalises each grouping key with `$ifNull`; the + * nullable ones are genuinely null rather than absent. Keep that guarantee if the pipeline + * changes, or the optionality has to come back here and at every use below. + */ + const rows = (await this.instrumentRecordModel.aggregateRaw({ pipeline })) as unknown as { + groupId: null | string; + key: null | string; + lastCollectedAt: null | string; + recordCount: number; + subjectId: null | string; + }[]; + + const summaries: RecordSummaryFold = new Map(); + 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; + } + // Grouping by series buckets every record collected outside one under a null key. Those are + // not a series and have no row to belong to. + if (row.key === null) { + continue; + } + let summary = summaries.get(row.key); + if (!summary) { + summary = { lastCollectedAt: null, recordCount: 0, subjectIds: new Set() }; + summaries.set(row.key, summary); + } + summary.recordCount += row.recordCount; + if (row.subjectId !== null) { + summary.subjectIds.add(row.subjectId); + } + const lastCollectedAt = row.lastCollectedAt ? new Date(row.lastCollectedAt) : null; + if (lastCollectedAt && (!summary.lastCollectedAt || lastCollectedAt > summary.lastCollectedAt)) { + summary.lastCollectedAt = lastCollectedAt; + } + } + return summaries; + } + + /** + * Label each record with its session's data collection method and the user who conducted it, so + * clients can show those columns without fetching every session in the group. * * Deliberately a second scoped query rather than an `include` on the relation: `session` is * declared required, so Prisma aborts the whole query with "Inconsistent query result" if any one * record's session has since been deleted. Looking the sessions up by id degrades to a missing - * username for that record instead of a failed request. + * type and username for that record instead of a failed request. */ - private async withSessionUsernames( + private async withSessionMetadata( records: PrismaInstrumentRecord[], ability?: AppAbility ): Promise { @@ -717,7 +826,7 @@ export class InstrumentRecordsService { return []; } const sessions = await this.sessionModel.findMany({ - select: { id: true, user: { select: { username: true } } }, + select: { id: true, type: true, user: { select: { username: true } } }, where: { AND: [ // Depends on the caller holding some `read Session` rule: given none at all, this throws a @@ -728,10 +837,13 @@ export class InstrumentRecordsService { ] } }); - const usernameBySessionId = new Map(sessions.map((session) => [session.id, session.user?.username ?? null])); - return records.map((record) => ({ - ...record, - session: { user: { username: usernameBySessionId.get(record.sessionId) ?? null } } - })); + const sessionById = new Map(sessions.map((session) => [session.id, session])); + return records.map((record) => { + const session = sessionById.get(record.sessionId); + return { + ...record, + session: { type: session?.type ?? null, user: { username: session?.user?.username ?? null } } + }; + }); } } diff --git a/apps/api/src/instrument-records/thread-types.ts b/apps/api/src/instrument-records/thread-types.ts index d4ddfb90e..c5f7b73ae 100644 --- a/apps/api/src/instrument-records/thread-types.ts +++ b/apps/api/src/instrument-records/thread-types.ts @@ -3,9 +3,12 @@ import type { InstrumentRecordsExport } from '@opendatacapture/schemas/instrumen export type RecordType = { computedMeasures: null | { [key: string]: unknown }; date: string; - groupId: string; + /** Null for a record belonging to no group; the pipeline normalises a missing field to null. */ + groupId: null | string; id: string; instrumentId: string; + /** Null for a record collected outside any series */ + seriesInstrumentId: null | string; session: { date: string; id: string; @@ -22,14 +25,20 @@ export type RecordType = { }; }; -export type InitData = { +export type InitInstruments = { edition: number; id: string; name: string; }[]; +export type InitPayload = { + instruments: InitInstruments; + /** Series instrument id to its title, since a series carries no language-independent name */ + seriesNames: { [id: string]: string }; +}; + export type InitMessage = { - data: InitData; + data: InitPayload; type: 'INIT'; }; diff --git a/apps/web/package.json b/apps/web/package.json index c87f65aa3..a4e0950dc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -37,7 +37,6 @@ "@tanstack/react-router": "^1.170.40", "@tanstack/react-router-devtools": "^1.167.2", "axios": "catalog:", - "html2canvas": "^1.4.1", "immer": "^11.1.18", "jwt-decode": "^4.0.0", "lodash-es": "workspace:lodash-es__4.x@*", diff --git a/apps/web/src/components/CollectedFilterMenu/CollectedFilterMenu.tsx b/apps/web/src/components/CollectedFilterMenu/CollectedFilterMenu.tsx new file mode 100644 index 000000000..6e914f9cb --- /dev/null +++ b/apps/web/src/components/CollectedFilterMenu/CollectedFilterMenu.tsx @@ -0,0 +1,72 @@ +import React from 'react'; + +import { toBasicISOString } from '@douglasneuroinformatics/libjs'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; + +import { selectCollectedPreset } from '@/utils/collected-filter'; +import type { CollectedFilter, CollectedPreset } from '@/utils/collected-filter'; + +type CollectedFilterMenuProps = { + onChange: (next: CollectedFilter) => void; + /** Prefixed onto each control's `data-testid`, so two tables on different pages stay addressable */ + testIdPrefix: string; + value: CollectedFilter; +}; + +/** + * The collection-date window control, shared by the subject and instrument listings so both narrow + * by date the same way. + */ +export const CollectedFilterMenu = ({ onChange, testIdPrefix, value }: CollectedFilterMenuProps) => { + const { t } = useTranslation(); + + return ( + +
+ {t({ en: 'Collected:', es: 'Recopilado:', fr: 'Collecté :' })} + {/* A native select renders with the user agent's own white background, which reads as a + bright block in dark mode — so it carries the popover surface tokens explicitly, and + the options do too, since those are painted separately. */} + +
+ {value.preset === 'custom' && ( + +
+ {t({ en: 'Min:', es: 'Mín.:', fr: 'Min :' })} + onChange({ ...value, min: event.target.valueAsDate })} + /> +
+
+ {t({ en: 'Max:', es: 'Máx.:', fr: 'Max :' })} + onChange({ ...value, max: event.target.valueAsDate })} + /> +
+
+ )} +
+ ); +}; diff --git a/apps/web/src/components/CollectedFilterMenu/index.ts b/apps/web/src/components/CollectedFilterMenu/index.ts new file mode 100644 index 000000000..3924ae438 --- /dev/null +++ b/apps/web/src/components/CollectedFilterMenu/index.ts @@ -0,0 +1 @@ +export * from './CollectedFilterMenu'; diff --git a/apps/web/src/components/InstrumentTable/InstrumentTable.tsx b/apps/web/src/components/InstrumentTable/InstrumentTable.tsx new file mode 100644 index 000000000..14f80e88e --- /dev/null +++ b/apps/web/src/components/InstrumentTable/InstrumentTable.tsx @@ -0,0 +1,444 @@ +import React, { useMemo, useState } from 'react'; + +import { toBasicISOString } from '@douglasneuroinformatics/libjs'; +import { Button, DataTable, DropdownMenu } from '@douglasneuroinformatics/libui/components'; +import type { TanstackTable } from '@douglasneuroinformatics/libui/components'; +import { useDownload, useNotificationsStore, useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { InstrumentKind } from '@opendatacapture/runtime-core'; +import { ChevronDownIcon } from 'lucide-react'; +import { unparse } from 'papaparse'; + +import { CollectedFilterMenu } from '@/components/CollectedFilterMenu'; +import { ColorTagCell } from '@/components/ColorTagCell'; +import { InstrumentPreviewDialog } from '@/components/InstrumentPreviewDialog'; +import type { InstrumentPreviewItem } from '@/components/InstrumentPreviewDialog'; +import { SortableHeader } from '@/components/SortableHeader'; +import { TruncatedCell } from '@/components/TruncatedCell'; +import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; +import { useInstrumentKindLabels } from '@/hooks/useInstrumentKindLabels'; +import { resolveTheme } from '@/utils/chart-theme'; +import { DEFAULT_COLLECTED_FILTER, matchesCollectedFilter } from '@/utils/collected-filter'; +import type { CollectedFilter } from '@/utils/collected-filter'; +import { downloadSubjectTableExcel } from '@/utils/excel'; +import { getInstrumentKindColor } from '@/utils/tag-colors'; + +/** Listed in the order a reader is most likely to meet them, not the enum's own order. */ +const INSTRUMENT_KINDS = ['FORM', 'INTERACTIVE', 'FILE', 'SERIES'] as const satisfies readonly InstrumentKind[]; + +const LISTING_FORMATS = ['CSV', 'JSON', 'Excel'] as const; + +/** + * Both shapes of each format. Wide carries every instrument's measures as columns, so a record + * leaves blank whatever its own instrument does not measure; long carries one row per measured + * value instead, which stays narrow however many instruments the series spans. + */ +const RECORD_FORMATS = [ + { label: 'TSV', option: 'TSV' }, + { label: 'TSV Long', option: 'TSV Long' }, + { label: 'JSON', option: 'JSON' }, + { label: 'CSV', option: 'CSV' }, + { label: 'CSV Long', option: 'CSV Long' }, + { label: 'Excel', option: 'Excel' }, + { label: 'Excel Long', option: 'Excel Long' } +] as const satisfies readonly { label: string; option: RecordExportOption }[]; + +/** The formats a record export offers, matching the set every other record table already offers */ +type RecordExportOption = 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long'; + +type InstrumentRow = { + edition: null | number; + id: string; + kind: InstrumentKind; + lastCollectedAt: Date | null; + recordCount: number; + source: null | string; + subjectCount: number; + title: string; +}; + +type KindFilter = InstrumentKind[]; + +/** A kind tag, matching the treatment the subject table gives sex. */ +const InstrumentKindCell = ({ kind }: { kind: InstrumentKind }) => { + const [theme] = useTheme(); + const labels = useInstrumentKindLabels(); + return ( + + ); +}; + +/** + * The toolbar every instrument listing shares: filter by kind and by how much was collected, then + * export exactly the rows left on screen. + */ +const Toolbar = ({ + recordExport, + rows, + table +}: { + recordExport?: (option: RecordExportOption) => void; + rows: InstrumentRow[]; + table: TanstackTable.Table; +}) => { + const { t } = useTranslation(); + const download = useDownload(); + const addNotification = useNotificationsStore((store) => store.addNotification); + const kindLabels = useInstrumentKindLabels(); + const [isOpen, setIsOpen] = useState(false); + // Held as the raw string: coercing to a number on every keystroke re-seeded a `0` the moment the + // field was cleared, so typed digits landed after it instead of replacing it. + const [minRecords, setMinRecords] = useState(''); + + // Each column is declared below with these ids, and `initialState.columnFilters` seeds all three. + const columnsById = new Map(table.getAllColumns().map((column) => [column.id, column])); + const kindColumn = columnsById.get('kind')!; + const kindFilter = kindColumn.getFilterValue() as KindFilter; + const recordColumn = columnsById.get('recordCount')!; + const collectedColumn = columnsById.get('lastCollectedAt')!; + const collectedFilter = collectedColumn.getFilterValue() as CollectedFilter; + + /** Export what the table is listing, so a filtered view and its download agree. */ + const listed = useMemo(() => { + const visible = new Set(table.getFilteredRowModel().rows.map((row) => row.original.id)); + return rows.filter((row) => visible.has(row.id)); + }, [rows, table.getFilteredRowModel().rows]); + + const handleExport = (option: (typeof LISTING_FORMATS)[number]) => { + if (listed.length === 0) { + addNotification({ + message: t({ + en: 'There is nothing to export', + es: 'No hay nada que exportar', + fr: "Il n'y a rien à exporter" + }), + type: 'error' + }); + return; + } + // Built imperatively, in the order the table shows these columns: the key order is the header + // order, and `perfectionist/sort-objects` would alphabetize an object literal into a different + // one than the reader just looked at. + const exported = listed.map((row) => { + const exportedRow: { [key: string]: unknown } = {}; + exportedRow.Instrument = row.title; + exportedRow.Subjects = row.subjectCount; + exportedRow.Records = row.recordCount; + exportedRow.LastCollected = row.lastCollectedAt ? toBasicISOString(row.lastCollectedAt) : 'None'; + exportedRow.Kind = row.kind; + exportedRow.Edition = row.edition ?? 'None'; + exportedRow.Source = row.source ?? 'manual'; + return exportedRow; + }); + const filename = `instruments_${new Date().toISOString()}`; + switch (option) { + case 'CSV': + void download(`${filename}.csv`, () => unparse(exported)); + break; + case 'Excel': + downloadSubjectTableExcel(`${filename}.xlsx`, exported, 'Instruments'); + break; + case 'JSON': + void download(`${filename}.json`, () => JSON.stringify(exported, null, 2)); + break; + } + }; + + return ( +
+ + + + + + {t({ en: 'Kind', es: 'Tipo', fr: 'Type' })} + + {INSTRUMENT_KINDS.map((kind) => ( + { + kindColumn.setFilterValue((previous: KindFilter) => + checked ? [...previous, kind] : previous.filter((item) => item !== kind) + ); + }} + onSelect={(e) => e.preventDefault()} + > + {kindLabels[kind]} + + ))} + + {t({ en: 'Records', es: 'Registros', fr: 'Enregistrements' })} + +
+ {t({ en: 'At least:', es: 'Al menos:', fr: 'Au moins :' })} + { + setMinRecords(event.target.value); + // An empty field means no minimum, which is why the parse falls back to zero. + recordColumn.setFilterValue(Number.parseInt(event.target.value, 10) || 0); + }} + /> +
+ collectedColumn.setFilterValue(next)} + /> +
+
+
+ + + + + + {/* A heading earns its place only once there is a second section to tell apart from the + first; over one list of formats it says nothing the button did not already say. */} + {recordExport && ( + + {t({ en: 'Instrument list', es: 'Lista de instrumentos', fr: 'Liste des instruments' })} + + )} + + {LISTING_FORMATS.map((format) => ( + handleExport(format)} + > + {format} + + ))} + + {recordExport && ( + + + + {t({ en: 'Subject data', es: 'Datos de sujetos', fr: 'Données des clients' })} + + + {RECORD_FORMATS.map(({ label, option }) => ( + recordExport(option)} + > + {label} + + ))} + + + )} + + +
+ ); +}; + +type InstrumentTableProps = { + 'data-testid'?: string; + onOpen: (row: InstrumentRow) => void; + /** + * Offered as a second export section, for the records behind the listing rather than the listing + * itself. Supplied only where those records are one coherent set — a series' own page — and + * omitted by the hub index, whose rows share nothing a single file could describe. + */ + recordExport?: (option: RecordExportOption) => void; + rows: InstrumentRow[]; +}; + +/** + * The instrument listing, shared by the hub's index and by a series' own page so the two read the + * same way: same columns, same sort affordances, same search, filters and export. + */ +export const InstrumentTable = ({ 'data-testid': testId, onOpen, recordExport, rows }: InstrumentTableProps) => { + const { t } = useTranslation(); + const [highlightedRowId, setHighlightedRowId] = useState(null); + const [preview, setPreview] = useState(null); + + // Every edition, so a row naming an older one previews that one rather than the current version. + const instrumentInfoQuery = useInstrumentInfoQuery({ params: { allEditions: true } }); + const infoById = useMemo( + () => new Map((instrumentInfoQuery.data ?? []).map((info) => [info.id, info])), + [instrumentInfoQuery.data] + ); + + /** The shape the shared preview dialog takes, filled from the catalog the row only names by id. */ + const toPreviewItem = (row: InstrumentRow): InstrumentPreviewItem => { + const info = infoById.get(row.id); + return { + authors: info?.details.authors ?? null, + availability: null, + createdAt: info?.createdAt ?? null, + description: info?.details.description, + id: row.id, + internal: info && info.kind !== 'SERIES' ? info.internal : null, + kind: row.kind, + seriesItems: info?.kind === 'SERIES' ? info.seriesItems : undefined, + source: row.source === null ? { kind: 'manual' } : { kind: 'repo', name: row.source }, + title: row.title + }; + }; + + /** Consulted only to name a series' children, which are instruments this catalog also holds. */ + const previewableItems = useMemo( + () => (instrumentInfoQuery.data ?? []).map((info) => ({ id: info.id, title: info.details.title })), + [instrumentInfoQuery.data] + ); + + const ToolbarWithRows = useMemo(() => { + const Component = (props: { table: TanstackTable.Table }) => ( + + ); + Component.displayName = 'InstrumentTableToolbar'; + return Component; + }, [recordExport, rows]); + + return ( +
+ { + const title = ctx.getValue() as string; + return ( + + {title} + + + } + /> + ); + }, + header: ({ column }) => ( + + ), + // `size` is honoured only for a pinned column: `calculateColumnSizing` overwrites every + // unpinned one with an equal share of the remaining width. + size: 420 + }, + { + accessorKey: 'subjectCount', + header: ({ column }) => ( + + ) + }, + { + accessorKey: 'recordCount', + // Carries the minimum-records filter: the value is the floor a row must reach, not a + // count to match. Zero admits everything, which is what an empty field means. + filterFn: (row, id, minimum: number) => row.getValue(id) >= minimum, + header: ({ column }) => ( + + ) + }, + { + accessorKey: 'lastCollectedAt', + cell: (ctx) => { + const value = ctx.getValue() as Date | null; + return value ? toBasicISOString(value) : t({ en: 'None', es: 'Ninguno', fr: 'Aucun' }); + }, + filterFn: (row, id, filter: CollectedFilter) => + matchesCollectedFilter(row.getValue(id), filter), + header: ({ column }) => ( + + ), + id: 'lastCollectedAt' + }, + { + accessorKey: 'kind', + cell: (ctx) => , + filterFn: (row, id, filter: KindFilter) => filter.includes(row.getValue(id)), + header: ({ column }) => ( + + ), + id: 'kind' + }, + { + accessorKey: 'edition', + cell: (ctx) => (ctx.getValue() as null | number) ?? t({ en: 'None', es: 'Ninguno', fr: 'Aucun' }), + header: ({ column }) => ( + + ) + }, + { + accessorKey: 'source', + cell: (ctx) => { + const source = + (ctx.getValue() as null | string) ?? + t({ en: 'Manual upload', es: 'Carga manual', fr: 'Téléversement manuel' }); + return ; + }, + header: ({ column }) => ( + + ) + } + ]} + data={rows} + initialState={{ + columnPinning: { left: ['title'] }, + // eslint-disable-next-line perfectionist/sort-objects + columnFilters: [ + { id: 'kind', value: [...INSTRUMENT_KINDS] satisfies KindFilter }, + { id: 'recordCount', value: 0 }, + { id: 'lastCollectedAt', value: DEFAULT_COLLECTED_FILTER } + ] + }} + rowActions={[ + { label: t('common.view'), onSelect: onOpen }, + { + label: t({ en: 'Preview', es: 'Vista previa', fr: 'Aperçu' }), + onSelect: (row) => setPreview(toPreviewItem(row)) + } + ]} + togglesComponent={ToolbarWithRows} + onRowClick={(row) => setHighlightedRowId(row.id)} + onRowDoubleClick={onOpen} + /> + {preview && setPreview(null)} />} +
+ ); +}; + +export type { InstrumentRow }; diff --git a/apps/web/src/components/InstrumentTable/__tests__/InstrumentTable.test.tsx b/apps/web/src/components/InstrumentTable/__tests__/InstrumentTable.test.tsx new file mode 100644 index 000000000..eb4663a81 --- /dev/null +++ b/apps/web/src/components/InstrumentTable/__tests__/InstrumentTable.test.tsx @@ -0,0 +1,436 @@ +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { InstrumentTable } from '@/components/InstrumentTable'; +import type { InstrumentRow } from '@/components/InstrumentTable'; + +import '@/services/i18n'; + +const mocks = vi.hoisted(() => ({ + addNotification: vi.fn(), + download: vi.fn(), + downloadExcel: vi.fn(), + instrumentInfo: [] as + | undefined + | { + createdAt?: Date; + details: { authors?: string[]; description?: string; title: string }; + id: string; + internal?: { edition: number; name: string }; + kind: string; + seriesItems?: { id: string }[]; + }[], + preview: { current: null as null | { item: unknown; items: unknown; onClose: () => void } } +})); + +vi.mock('@douglasneuroinformatics/libui/hooks', async (importOriginal) => ({ + ...(await importOriginal()), + useDownload: () => mocks.download, + useNotificationsStore: (selector: (store: { addNotification: typeof mocks.addNotification }) => unknown) => + selector({ addNotification: mocks.addNotification }) +})); +vi.mock('@/utils/excel', () => ({ downloadSubjectTableExcel: mocks.downloadExcel })); +// The dialog fetches the instrument bundle to render it; what matters here is the item built for it. +vi.mock('@/components/InstrumentPreviewDialog', () => ({ + InstrumentPreviewDialog: (props: { item: unknown; items: unknown; onClose: () => void }) => { + mocks.preview.current = props; + return + ); + } +})); +vi.mock('@/components/SelectChartPalette', () => ({ + SelectChartPalette: ({ onSelect, slots, value }: { onSelect: (p: string) => void; slots: number; value: string }) => ( + + + + + {t({ en: 'Collection method', es: 'Método de recopilación', fr: 'Méthode de collecte' })} + + + {COLLECTION_METHODS.map((method) => ( + { + setMethods(checked ? [...selectedMethods, method] : selectedMethods.filter((item) => item !== method)); + }} + onSelect={(e) => e.preventDefault()} + > + {collectionMethodLabels[method]} + + ))} + + {t({ en: 'Series', es: 'Serie', fr: 'Série' })} + + {Array.from(seriesOptions, ([id, name]) => ( + { + setSeries(checked ? [...selectedSeries, id] : selectedSeries.filter((item) => item !== id)); + }} + onSelect={(e) => e.preventDefault()} + > + {id === NO_SERIES ? t({ en: 'Individual', es: 'Individual', fr: 'Individuel' }) : (name ?? id)} + + ))} + + + + ); +}; + +function useSeriesMemberRows( + records: InstrumentVisualizationRecord[], + infoById: { [id: string]: { edition: null | number; kind: InstrumentKind; title: string } } +) { + return useMemo(() => { + const members = new Map; total: number }>(); + for (const record of records) { + let member = members.get(record.__instrumentId__); + if (!member) { + member = { lastCollectedAt: null, subjectIds: new Set(), total: 0 }; + members.set(record.__instrumentId__, member); + } + member.total += 1; + member.subjectIds.add(record.__subjectId__); + if (!member.lastCollectedAt || record.__date__ > member.lastCollectedAt) { + member.lastCollectedAt = record.__date__; + } + } + return Array.from(members, ([id, member]) => ({ + edition: infoById[id]?.edition ?? null, + id, + kind: infoById[id]?.kind ?? 'FORM', + lastCollectedAt: member.lastCollectedAt, + recordCount: member.total, + source: null, + subjectCount: member.subjectIds.size, + title: infoById[id]?.title ?? id + })); + }, [records, infoById]); +} + +const RouteComponent = () => { + const navigate = Route.useNavigate(); + const { instrumentId: seriesInstrumentId } = Route.useParams(); + const [highlightedRowId, setHighlightedRowId] = useState(null); + const { dl, records } = useInstrumentHubRecords(); + const { editionOptions, isSeries, seriesOptions } = useInstrumentHubFacets(); + const instrumentInfoById = useInstrumentInfoById({ allEditions: true }); + const metadataColumns = useRecordMetadataColumns({ omitSeries: isSeries }); + const subjectIdDisplaySetting = useAppStore((store) => store.currentGroup?.settings.subjectIdDisplayLength); + const search = Route.useSearch(); + + const { t } = useTranslation(); + const seriesMembers = useSeriesMemberRows(records, instrumentInfoById); + + const measureColumns = useMemo[]>(() => { + const columns: TanstackTable.ColumnDef[] = []; + for (const key in records[0]) { + if (!key.startsWith(FIXED_COLUMN_PREFIX)) { + columns.push({ + accessorKey: key, + cell: (ctx) => { + const value = formatRecordValue(ctx.getValue()); + return ; + }, + header: ({ column }) => , + id: key + }); + } + } + return columns; + }, [records[0]]); + + // Both subject destinations carry where they were opened from, so the subject hub's return comes + // back to this record table rather than dropping the reader on the subject index. + const fromHere = { fromInstrument: seriesInstrumentId, fromSeries: search.fromSeries }; + + const viewRecord = (record: InstrumentVisualizationRecord) => { + void navigate({ + params: { recordId: record.__id__, subjectId: record.__subjectId__ }, + search: fromHere, + to: '/datahub/subjects/$subjectId/table/$recordId' + }); + }; + + if (isSeries) { + return ( + { + void navigate({ + params: { instrumentId: row.id }, + // Narrowed to this series, not every record the member instrument ever collected: the + // reader asked for the series' subjects, and the member's own page is reachable from + // the hub index when they want the whole set. + search: { fromSeries: seriesInstrumentId, series: [seriesInstrumentId] }, + to: '/datahub/instruments/$instrumentId/table' + }); + }} + /> + ); + } + + return ( +
+
+
+
+ { + void navigate({ params: { instrumentId: id }, to: '/datahub/instruments/$instrumentId/table' }); + }} + /> +
+
+ { + void navigate({ search: (prev) => ({ ...prev, minDate: minDate ?? undefined }) }); + }} + /> + + +
+
+
+
+ ( + + {removeSubjectIdScope(ctx.getValue() as string).slice(0, subjectIdDisplaySetting ?? 9)} + + + ), + header: ({ column }) => , + id: '__subjectId__' + }, + { + accessorKey: '__date__', + cell: (ctx) => { + const value = ctx.getValue(); + return value instanceof Date ? toBasicISOString(value) : value; + }, + header: ({ column }) => , + id: '__date__' + }, + ...metadataColumns, + ...measureColumns + ]} + data={records} + rowActions={[ + { + label: t({ en: 'View Record', es: 'Ver registro', fr: "Voir l'enregistrement" }), + onSelect: viewRecord + }, + { + label: t({ en: 'View Subject', es: 'Ver sujeto', fr: 'Voir le client' }), + onSelect: (record) => { + void navigate({ + params: { subjectId: record.__subjectId__ }, + search: fromHere, + to: '/datahub/subjects/$subjectId/table' + }); + } + } + ]} + onRowClick={(row) => setHighlightedRowId(row.__id__)} + onRowDoubleClick={viewRecord} + /> +
+
+ ); +}; + +export const Route = createFileRoute('/_app/datahub/instruments/$instrumentId/table')({ + component: RouteComponent +}); diff --git a/apps/web/src/routes/_app/datahub/instruments/__tests__/index.test.tsx b/apps/web/src/routes/_app/datahub/instruments/__tests__/index.test.tsx new file mode 100644 index 000000000..942c2eb26 --- /dev/null +++ b/apps/web/src/routes/_app/datahub/instruments/__tests__/index.test.tsx @@ -0,0 +1,162 @@ +import type { InstrumentRecordSummary } from '@opendatacapture/schemas/instrument-records'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { InstrumentRow } from '@/components/InstrumentTable'; +import { Route } from '@/routes/_app/datahub/instruments/index'; + +import '@/services/i18n'; + +type InstrumentInfo = { + details: { title: string }; + id: string; + internal?: { edition: number; name: string }; + kind: string; + sourceRepo?: null | { name: string }; +}; + +const mocks = vi.hoisted(() => ({ + infos: undefined as InstrumentInfo[] | undefined, + instrumentRecordSummaryQueryOptions: vi.fn((options: object) => ({ + options, + queryKey: ['instrument-record-summary'] + })), + navigate: vi.fn(), + seriesSummaries: [] as InstrumentRecordSummary[], + store: { currentGroup: null as null | { id: string } }, + summaries: [] as InstrumentRecordSummary[] +})); + +vi.mock('@tanstack/react-router', async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => mocks.navigate +})); +vi.mock('@/hooks/useInstrumentInfoQuery', () => ({ useInstrumentInfoQuery: () => ({ data: mocks.infos }) })); +vi.mock('@/hooks/useInstrumentRecordSummaryQuery', () => ({ + instrumentRecordSummaryQueryOptions: mocks.instrumentRecordSummaryQueryOptions, + useInstrumentRecordSummaryQuery: ({ params }: { params?: { bySeries?: boolean } } = {}) => ({ + data: params?.bySeries ? mocks.seriesSummaries : mocks.summaries + }) +})); +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (store: typeof mocks.store) => unknown) => selector(mocks.store), { + getState: () => mocks.store + }) +})); + +/** The listing itself has its own test; this one is about the rows the route builds for it. */ +vi.mock('@/components/InstrumentTable', () => ({ + InstrumentTable: ({ onOpen, rows }: { onOpen: (row: InstrumentRow) => void; rows: InstrumentRow[] }) => ( +
    + {rows.map((row) => ( +
  • + +
  • + ))} +
+ ) +})); + +const summaryOf = ( + instrumentId: string, + overrides: Partial = {} +): InstrumentRecordSummary => ({ + instrumentId, + lastCollectedAt: new Date('2026-01-01'), + recordCount: 3, + subjectCount: 2, + ...overrides +}); + +const scalar = (id: string, overrides: Partial = {}): InstrumentInfo => ({ + details: { title: 'Happiness Questionnaire' }, + id, + internal: { edition: 1, name: 'HQ' }, + kind: 'FORM', + ...overrides +}); + +const renderHub = () => { + const Component = Route.options.component!; + render(); +}; + +const rowFor = (id: string) => JSON.parse(screen.getByTestId(`row-${id}`).textContent) as InstrumentRow; + +beforeEach(() => { + mocks.infos = [scalar('hq-1')]; + mocks.navigate.mockReset(); + mocks.seriesSummaries = []; + mocks.store.currentGroup = null; + mocks.summaries = [summaryOf('hq-1')]; + mocks.instrumentRecordSummaryQueryOptions.mockClear(); +}); + +afterEach(cleanup); + +describe('instrument hub index route', () => { + it('should list an instrument with the counts collected under it', () => { + renderHub(); + expect(rowFor('hq-1')).toMatchObject({ edition: 1, kind: 'FORM', recordCount: 3, subjectCount: 2 }); + }); + + // A series holds no records under its own id, so counting the per-instrument summary would list + // every series as empty. + it('should count a series from what it orchestrated rather than from its own records', () => { + mocks.infos = [{ details: { title: 'Happiness Series' }, id: 'series-1', kind: 'SERIES' }]; + mocks.seriesSummaries = [summaryOf('series-1', { recordCount: 9, subjectCount: 4 })]; + renderHub(); + expect(rowFor('series-1')).toMatchObject({ edition: null, kind: 'SERIES', recordCount: 9, subjectCount: 4 }); + }); + + // An instrument the caller may read but has never collected still belongs in the listing. + it('should list an instrument with no records as empty rather than omitting it', () => { + mocks.summaries = []; + renderHub(); + expect(rowFor('hq-1')).toMatchObject({ lastCollectedAt: null, recordCount: 0, subjectCount: 0 }); + }); + + it('should name the repository an imported instrument came from', () => { + mocks.infos = [scalar('hq-1', { sourceRepo: { name: 'odc-instruments' } })]; + renderHub(); + expect(rowFor('hq-1').source).toBe('odc-instruments'); + }); + + it('should leave the source empty for an instrument that came from no repository', () => { + renderHub(); + expect(rowFor('hq-1').source).toBeNull(); + }); + + it('should list nothing while the catalog is still loading', () => { + mocks.infos = undefined; + renderHub(); + expect(screen.queryAllByRole('listitem')).toHaveLength(0); + }); + + it('should open the record table of the instrument that was clicked', () => { + renderHub(); + fireEvent.click(screen.getByTestId('row-hq-1')); + expect(mocks.navigate).toHaveBeenCalledWith({ + params: { instrumentId: 'hq-1' }, + to: '/datahub/instruments/$instrumentId/table' + }); + }); + + it('should prefetch both summaries, so the listing never renders a row without its counts', async () => { + mocks.store.currentGroup = { id: 'group-1' }; + const loader = Route.options.loader as (opts: { + context: { queryClient: { ensureQueryData: (options: unknown) => Promise } }; + }) => Promise; + const ensureQueryData = vi.fn().mockResolvedValue([]); + + await loader({ context: { queryClient: { ensureQueryData } } }); + + expect(mocks.instrumentRecordSummaryQueryOptions).toHaveBeenCalledWith({ params: { groupId: 'group-1' } }); + expect(mocks.instrumentRecordSummaryQueryOptions).toHaveBeenCalledWith({ + params: { bySeries: true, groupId: 'group-1' } + }); + expect(ensureQueryData).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/routes/_app/datahub/instruments/index.tsx b/apps/web/src/routes/_app/datahub/instruments/index.tsx new file mode 100644 index 000000000..3ed79f02e --- /dev/null +++ b/apps/web/src/routes/_app/datahub/instruments/index.tsx @@ -0,0 +1,84 @@ +import React, { useMemo } from 'react'; + +import { Heading } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; + +import { InstrumentTable } from '@/components/InstrumentTable'; +import type { InstrumentRow } from '@/components/InstrumentTable'; +import { PageHeader } from '@/components/PageHeader'; +import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; +import { + instrumentRecordSummaryQueryOptions, + useInstrumentRecordSummaryQuery +} from '@/hooks/useInstrumentRecordSummaryQuery'; +import { useAppStore } from '@/store'; + +const RouteComponent = () => { + const navigate = useNavigate(); + const currentGroup = useAppStore((store) => store.currentGroup); + const { t } = useTranslation(); + + // Every edition is its own row, so the list shows an instrument's full collection history rather + // than only its current version. + const instrumentInfoQuery = useInstrumentInfoQuery({ params: { allEditions: true } }); + const summaryQuery = useInstrumentRecordSummaryQuery({ params: { groupId: currentGroup?.id } }); + // A series holds no records of its own, so its counts come from what it orchestrated. + const seriesSummaryQuery = useInstrumentRecordSummaryQuery({ + params: { bySeries: true, groupId: currentGroup?.id } + }); + + const data = useMemo(() => { + const summaries = new Map(summaryQuery.data.map((summary) => [summary.instrumentId, summary])); + const seriesSummaries = new Map(seriesSummaryQuery.data.map((summary) => [summary.instrumentId, summary])); + return (instrumentInfoQuery.data ?? []).map((info) => { + const isSeries = info.kind === 'SERIES'; + const summary = (isSeries ? seriesSummaries : summaries).get(info.id); + return { + // A series has no `internal`, and therefore no edition to show. + edition: isSeries ? null : info.internal.edition, + id: info.id, + kind: info.kind, + lastCollectedAt: summary?.lastCollectedAt ?? null, + recordCount: summary?.recordCount ?? 0, + source: info.sourceRepo?.name ?? null, + subjectCount: summary?.subjectCount ?? 0, + title: info.details.title + }; + }); + }, [instrumentInfoQuery.data, seriesSummaryQuery.data, summaryQuery.data]); + + const openInstrument = (row: InstrumentRow) => { + void navigate({ params: { instrumentId: row.id }, to: '/datahub/instruments/$instrumentId/table' }); + }; + + return ( + + + + {t({ + en: 'Instruments', + es: 'Instrumentos', + fr: 'Instruments' + })} + + + + + ); +}; + +export const Route = createFileRoute('/_app/datahub/instruments/')({ + component: RouteComponent, + loader: async ({ context }) => { + const { currentGroup } = useAppStore.getState(); + await Promise.all([ + context.queryClient.ensureQueryData( + instrumentRecordSummaryQueryOptions({ params: { groupId: currentGroup?.id } }) + ), + context.queryClient.ensureQueryData( + instrumentRecordSummaryQueryOptions({ params: { bySeries: true, groupId: currentGroup?.id } }) + ) + ]); + } +}); diff --git a/apps/web/src/routes/_app/datahub/subjects/$subjectId/__tests__/graph.test.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/__tests__/graph.test.tsx index d3eeb01c5..ffcfb280f 100644 --- a/apps/web/src/routes/_app/datahub/subjects/$subjectId/__tests__/graph.test.tsx +++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/__tests__/graph.test.tsx @@ -9,14 +9,13 @@ import { Route } from '@/routes/_app/datahub/subjects/$subjectId/graph'; import '@/services/i18n'; -type CloneHandler = (document: Document, element: HTMLElement) => void; - type DownloadFunction = (filename: string, fetchData: () => Blob, options: { blobType: string }) => Promise; const mocks = vi.hoisted(() => ({ + addNotification: vi.fn<(notification: { message: string; type: string }) => void>(), download: vi.fn(), downloadSelection: { current: null as ((option: string) => void) | null }, - html2canvas: vi.fn<(element: HTMLElement, options: { onclone: CloneHandler }) => Promise>(), + navigate: vi.fn(), setInstrumentId: vi.fn(), setMinDate: vi.fn(), useGraphData: vi.fn(), @@ -26,7 +25,6 @@ const mocks = vi.hoisted(() => ({ useMeasureOptions: vi.fn() })); -vi.mock('html2canvas', () => ({ default: mocks.html2canvas })); vi.mock('@/hooks/useGraphData', () => ({ useGraphData: mocks.useGraphData })); vi.mock('@/hooks/useGraphLines', () => ({ useGraphLines: mocks.useGraphLines })); vi.mock('@/hooks/useInstrumentVisualization', () => ({ useInstrumentVisualization: mocks.useInstrumentVisualization })); @@ -38,7 +36,9 @@ vi.mock('@/store', () => ({ })); vi.mock('@douglasneuroinformatics/libui/hooks', async (importOriginal) => ({ ...(await importOriginal()), - useDownload: () => mocks.download + useDownload: () => mocks.download, + useNotificationsStore: (selector: (store: { addNotification: typeof mocks.addNotification }) => unknown) => + selector({ addNotification: mocks.addNotification }) })); vi.mock('@douglasneuroinformatics/libui/components', async (importOriginal) => ({ ...(await importOriginal()), @@ -76,7 +76,7 @@ vi.mock('@douglasneuroinformatics/libui/components', async (importOriginal) => ( })); vi.mock('@/components/LineGraph', () => ({ LineGraph: ({ data, lines, xAxis }: { data: unknown; lines: unknown; xAxis: { key: string; label: string } }) => ( - ({