diff --git a/apps/api/src/instrument-records/__tests__/export-worker.spec.ts b/apps/api/src/instrument-records/__tests__/export-worker.spec.ts index 5d3e1fbf9..d2d85b065 100644 --- a/apps/api/src/instrument-records/__tests__/export-worker.spec.ts +++ b/apps/api/src/instrument-records/__tests__/export-worker.spec.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.spec.ts b/apps/api/src/instrument-records/__tests__/instrument-records.controller.spec.ts index 1ee9a276e..b8f1a6999 100644 --- a/apps/api/src/instrument-records/__tests__/instrument-records.controller.spec.ts +++ b/apps/api/src/instrument-records/__tests__/instrument-records.controller.spec.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 } ); }); diff --git a/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts b/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts index f081a5ce7..ab45c4f41 100644 --- a/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts +++ b/apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts @@ -735,7 +735,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 +751,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 +786,7 @@ describe('InstrumentRecordsService', () => { groupId: '123', id: 'record-1', instrumentId: 'instrument-1', + seriesInstrumentId: null, session: { date: '2023-01-01', id: 'session-1', @@ -922,6 +923,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; @@ -967,7 +969,7 @@ describe('InstrumentRecordsService', () => { await expect(Promise.race([result, Promise.resolve('pending')])).resolves.toBe('pending'); expect(worker.postMessage).toHaveBeenCalledTimes(1); expect(worker.postMessage).toHaveBeenCalledWith({ - data: [{ edition: 1, id: 'instrument-1', name: 'X' }], + data: { instruments: [{ edition: 1, id: 'instrument-1', name: 'X' }], seriesNames: {} }, type: 'INIT' }); worker.emit('error', new Error('settle')); @@ -1234,13 +1236,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 }); @@ -1257,8 +1266,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 }); @@ -1270,8 +1279,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 }); @@ -1287,10 +1308,86 @@ 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 }); + }); + }); + + 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 bd76f954b..68d44fef4 100644 --- a/apps/api/src/instrument-records/export-worker.js +++ b/apps/api/src/instrument-records/export-worker.js @@ -60,12 +60,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 }); } @@ -97,6 +104,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; @@ -106,6 +116,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, @@ -131,6 +143,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 7fbfd0dda..625df8453 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 @@ -43,13 +45,29 @@ import { InstrumentMeasuresService } from './instrument-measures.service'; import type { BeginChunkProcessingMessage, - InitData, InitialMessage, + 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 { constructor( @@ -212,6 +230,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 = []; @@ -220,7 +240,7 @@ export class InstrumentRecordsService { chunks.push(records.slice(i, i + chunkSize)); } - const availableInstrumentArray: InitData = instruments + const availableInstrumentArray: InitInstruments = instruments .values() .toArray() .map((item) => { @@ -234,7 +254,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); worker.on('message', (message: InitialMessage) => { if (message.success) { @@ -263,7 +286,7 @@ export class InstrumentRecordsService { } async find( - { groupId, instrumentId, kind, minDate, subjectId }: InstrumentRecordQueryParams, + { groupId, instrumentId, kind, minDate, seriesInstrumentId, subjectId }: InstrumentRecordQueryParams, { ability }: EntityOperationOptions = {} ): Promise { if (groupId) { @@ -272,6 +295,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 }) @@ -287,6 +318,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, @@ -296,14 +328,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 = {}) { @@ -365,82 +398,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, @@ -654,13 +639,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: { @@ -707,20 +694,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 { @@ -728,7 +831,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 @@ -739,10 +842,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 005896228..96040526f 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/src/__tests__/datahub-instrument-table.test.tsx b/apps/web/src/__tests__/datahub-instrument-table.test.tsx new file mode 100644 index 000000000..69b928312 --- /dev/null +++ b/apps/web/src/__tests__/datahub-instrument-table.test.tsx @@ -0,0 +1,148 @@ +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/$instrumentId/table'; + +import '@/services/i18n'; + +type VisualizationRecord = { + [key: string]: unknown; + __date__: unknown; + __id__: string; + __instrumentId__: string; + __method__: null | string; + __seriesId__: null | string; + __seriesName__: null | string; + __subjectId__: string; + __time__: number; +}; + +const mocks = vi.hoisted(() => ({ + facets: { + editionOptions: {}, + isSeries: false, + seriesOptions: new Map(), + title: 'Happiness Questionnaire' + }, + infoById: {}, + openSeriesMember: { current: null as ((row: InstrumentRow) => void) | null }, + records: [] as VisualizationRecord[] +})); + +vi.mock('@/hooks/useInstrumentHubRecords', () => ({ + useInstrumentHubRecords: () => ({ dl: vi.fn(), records: mocks.records }) +})); +vi.mock('@/hooks/useInstrumentHubFacets', () => ({ + NO_SERIES: '', + useInstrumentHubFacets: () => mocks.facets +})); +vi.mock('@/hooks/useInstrumentInfoById', () => ({ useInstrumentInfoById: () => mocks.infoById })); +vi.mock('@/store', () => ({ + useAppStore: (selector: (store: { currentGroup: null }) => unknown) => selector({ currentGroup: null }) +})); +// Captured rather than rendered: the listing itself is covered by instrument-table.test.tsx, and +// what matters here is the search the drill-down navigates with. +vi.mock('@/components/InstrumentTable', () => ({ + InstrumentTable: ({ onOpen }: { onOpen: (row: InstrumentRow) => void }) => { + mocks.openSeriesMember.current = onOpen; + return ; + } +})); +vi.mock('@/components/SelectEdition', () => ({ SelectEdition: () => })); +vi.mock('@/components/TimeDropdown', () => ({ TimeDropdown: () => })); + +const navigate = vi.fn(); + +const record = (overrides: Partial = {}): VisualizationRecord => ({ + __date__: new Date('2026-01-15'), + __id__: 'record-1', + __instrumentId__: 'hq-1', + __method__: 'IN_PERSON', + __seriesId__: null, + __seriesName__: null, + __subjectId__: 'ROOT$alice', + __time__: 0, + totalScore: 7, + ...overrides +}); + +const renderTable = () => { + const Component = Route.options.component!; + render(); +}; + +const headers = () => + Array.from(screen.getByTestId('data-table-head').firstElementChild!.children, (header) => header.textContent).filter( + Boolean + ); + +/** The sort control a header carries, or undefined when the header is plain text. */ +const headerButton = (label: string) => + Array.from(screen.getByTestId('data-table-head').querySelectorAll('button')).find( + (button) => button.textContent === label + ); + +beforeEach(() => { + navigate.mockReset(); + mocks.facets.isSeries = false; + mocks.facets.seriesOptions = new Map(); + mocks.infoById = {}; + mocks.openSeriesMember.current = null; + mocks.records = [record()]; + vi.spyOn(Route, 'useNavigate').mockReturnValue(navigate); + vi.spyOn(Route, 'useParams').mockReturnValue({ instrumentId: 'hq-1' }); + vi.spyOn(Route, 'useSearch').mockReturnValue({}); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('instrument hub table route', () => { + it('should name every column after the field the export uses', () => { + renderTable(); + expect(headers()).toEqual(['SUBJECT', 'DATE_COLLECTED', 'COLLECTION_METHOD', 'SERIES', 'TOTAL_SCORE']); + }); + + // The hub's instrument listing sorts from its headers, so this table reading as fixed text made + // the two look like different kinds of table. + it.each(['SUBJECT', 'DATE_COLLECTED', 'COLLECTION_METHOD', 'SERIES', 'TOTAL_SCORE'])( + 'should offer a sort control on the %s header', + (label) => { + renderTable(); + expect(headerButton(label)).toBeTruthy(); + } + ); + + it('should reorder the rows when a header control is used', () => { + mocks.records = [record({ __id__: 'r-1', totalScore: 2 }), record({ __id__: 'r-2', totalScore: 9 })]; + renderTable(); + const scores = () => screen.queryAllByTestId('instrument-table-cell-totalScore').map((cell) => cell.textContent); + expect(scores()).toEqual(['2', '9']); + fireEvent.click(headerButton('TOTAL_SCORE')!); + expect(scores()).toEqual(['9', '2']); + }); +}); + +describe('instrument hub series members', () => { + beforeEach(() => { + mocks.facets.isSeries = true; + mocks.infoById = { 'member-1': { edition: 2, kind: 'FORM', title: 'Member One' } }; + mocks.records = [record({ __instrumentId__: 'member-1', __seriesId__: 'hq-1', __seriesName__: 'Repeated' })]; + }); + + // Opening a member from a series means "the subjects this series collected", not every record the + // member instrument ever took — so the drill-down carries the series as a filter, not just as the + // breadcrumb the return link reads. + it('should narrow a member instrument to the series it was opened from', () => { + renderTable(); + mocks.openSeriesMember.current!({ id: 'member-1' } as InstrumentRow); + expect(navigate).toHaveBeenCalledWith({ + params: { instrumentId: 'member-1' }, + search: { fromSeries: 'hq-1', series: ['hq-1'] }, + to: '/datahub/instruments/$instrumentId/table' + }); + }); +}); diff --git a/apps/web/src/__tests__/datahub-subject-graph.test.tsx b/apps/web/src/__tests__/datahub-subject-graph.test.tsx index d3eeb01c5..61d64e22e 100644 --- a/apps/web/src/__tests__/datahub-subject-graph.test.tsx +++ b/apps/web/src/__tests__/datahub-subject-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 } }) => ( - ({ + + + {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/index.ts b/apps/web/src/components/InstrumentTable/index.ts new file mode 100644 index 000000000..ae9941cb9 --- /dev/null +++ b/apps/web/src/components/InstrumentTable/index.ts @@ -0,0 +1 @@ +export * from './InstrumentTable'; diff --git a/apps/web/src/components/RecordDistributionChart/RecordDistributionChart.tsx b/apps/web/src/components/RecordDistributionChart/RecordDistributionChart.tsx new file mode 100644 index 000000000..4045874a1 --- /dev/null +++ b/apps/web/src/components/RecordDistributionChart/RecordDistributionChart.tsx @@ -0,0 +1,87 @@ +import { useMemo } from 'react'; + +import { useTheme } from '@douglasneuroinformatics/libui/hooks'; +import { Bar, BarChart, CartesianGrid, Label, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; + +import type { RecordChartSeries } from '@/hooks/useRecordChartSeries'; +import { + AXIS_STROKE, + inkColors, + resolveTheme, + tooltipStyles, + X_AXIS_HEIGHT, + X_AXIS_LABEL_OFFSET, + Y_AXIS_WIDTH +} from '@/utils/chart-theme'; +import { binDistribution } from '@/utils/distribution'; + +type RecordDistributionChartProps = { + countLabel: string; + /** False when the measure's values are categories, which are counted as-is rather than binned */ + isNumeric: boolean; + measureLabel: string; + series: RecordChartSeries[]; +}; + +/** + * How the selected measure is distributed across subjects, as counts per bin. + * + * A numeric measure is binned over the range spanned by every series at once, so bars from + * different groups line up and can be compared; a categorical measure is counted per distinct + * value instead, since binning a category is meaningless. + */ +export const RecordDistributionChart = ({ + countLabel, + isNumeric, + measureLabel, + series +}: RecordDistributionChartProps) => { + const [theme] = useTheme(); + const resolvedTheme = resolveTheme(theme); + + const data = useMemo(() => binDistribution(series, isNumeric), [isNumeric, series]); + + return ( + + + + + + + + + {series.map((item) => ( + + ))} + {series.length > 1 && } + + + ); +}; diff --git a/apps/web/src/components/RecordDistributionChart/index.ts b/apps/web/src/components/RecordDistributionChart/index.ts new file mode 100644 index 000000000..858405184 --- /dev/null +++ b/apps/web/src/components/RecordDistributionChart/index.ts @@ -0,0 +1 @@ +export * from './RecordDistributionChart'; diff --git a/apps/web/src/components/RecordScatterChart/RecordScatterChart.tsx b/apps/web/src/components/RecordScatterChart/RecordScatterChart.tsx new file mode 100644 index 000000000..ef68c8ebc --- /dev/null +++ b/apps/web/src/components/RecordScatterChart/RecordScatterChart.tsx @@ -0,0 +1,119 @@ +import { toBasicISOString } from '@douglasneuroinformatics/libjs'; +import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { + CartesianGrid, + Label, + Legend, + ReferenceLine, + ResponsiveContainer, + Scatter, + ScatterChart, + Tooltip, + XAxis, + YAxis +} from 'recharts'; + +import type { RecordChartSeries } from '@/hooks/useRecordChartSeries'; +import { + AXIS_STROKE, + CHART_SURFACE, + inkColors, + resolveTheme, + tooltipStyles, + X_AXIS_HEIGHT, + X_AXIS_LABEL_OFFSET, + Y_AXIS_WIDTH +} from '@/utils/chart-theme'; + +type RecordScatterChartProps = { + measureLabel: string; + series: RecordChartSeries[]; + /** The group-wide regression for the selected measure, drawn as a trend line when present */ + trend?: { intercept: number; slope: number }; + xLabel: string; +}; + +/** + * One dot per record: collection date against the selected measure, across every subject. + * + * The marks carry a surface-coloured ring so overlapping records stay countable, which matters here + * because a cohort assessed on the same day stacks vertically. + */ +export const RecordScatterChart = ({ measureLabel, series, trend, xLabel }: RecordScatterChartProps) => { + const [theme] = useTheme(); + const { resolvedLanguage } = useTranslation(); + const resolvedTheme = resolveTheme(theme); + + const times = series.flatMap((item) => item.points.map((point) => point.time)); + const trendSegment = + trend && times.length > 0 + ? [ + { x: Math.min(...times), y: trend.intercept + trend.slope * Math.min(...times) }, + { x: Math.max(...times), y: trend.intercept + trend.slope * Math.max(...times) } + ] + : null; + + return ( + + + + toBasicISOString(new Date(time))} + tickLine={{ stroke: AXIS_STROKE }} + tickMargin={8} + tickSize={8} + type="number" + > + + + [value, name === 'time' ? xLabel : measureLabel]} + labelFormatter={(time: number) => + new Intl.DateTimeFormat(resolvedLanguage, { dateStyle: 'medium' }).format(new Date(time)) + } + labelStyle={{ color: inkColors[resolvedTheme], fontWeight: 500 }} + /> + {trendSegment && ( + + )} + {series.map((item) => ( + + ))} + {series.length > 1 && } + + + ); +}; diff --git a/apps/web/src/components/RecordScatterChart/index.ts b/apps/web/src/components/RecordScatterChart/index.ts new file mode 100644 index 000000000..149cc35c0 --- /dev/null +++ b/apps/web/src/components/RecordScatterChart/index.ts @@ -0,0 +1 @@ +export * from './RecordScatterChart'; diff --git a/apps/web/src/components/SelectChartPalette/SelectChartPalette.tsx b/apps/web/src/components/SelectChartPalette/SelectChartPalette.tsx new file mode 100644 index 000000000..4947d04cb --- /dev/null +++ b/apps/web/src/components/SelectChartPalette/SelectChartPalette.tsx @@ -0,0 +1,63 @@ +import { Select } from '@douglasneuroinformatics/libui/components'; +import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; + +import { CHART_PALETTE_NAMES, getPaletteSwatch } from '@/utils/chart-palette'; +import type { ChartPaletteName } from '@/utils/chart-palette'; +import { resolveTheme } from '@/utils/chart-theme'; + +/** The hues a palette would actually draw with, so the choice is visible rather than named. */ +const PaletteSwatch = ({ palette, slots }: { palette: ChartPaletteName; slots: number }) => { + const [theme] = useTheme(); + return ( + + {getPaletteSwatch(palette, resolveTheme(theme)) + .slice(0, slots) + .map((color) => ( + + ))} + + ); +}; + +type SelectChartPaletteProps = { + 'data-testid'?: string; + onSelect: (palette: ChartPaletteName) => void; + /** How many of the theme's hues the chart will actually draw with, so the swatch does not overpromise */ + slots: number; + value: ChartPaletteName; +}; + +/** + * The palette control shared by the instrument and subject graphs, so the two offer the same themes + * under the same names and a chart means the same thing on either page. + */ +export const SelectChartPalette = ({ 'data-testid': testId, onSelect, slots, value }: SelectChartPaletteProps) => { + const { t } = useTranslation(); + + const paletteLabels: { [K in ChartPaletteName]: string } = { + berry: t({ en: 'Berry', es: 'Baya', fr: 'Baie' }), + default: t({ en: 'Default', es: 'Predeterminada', fr: 'Par défaut' }), + ember: t({ en: 'Ember', es: 'Brasa', fr: 'Braise' }), + jade: t({ en: 'Jade', es: 'Jade', fr: 'Jade' }) + }; + + return ( + + ); +}; diff --git a/apps/web/src/components/SelectChartPalette/index.ts b/apps/web/src/components/SelectChartPalette/index.ts new file mode 100644 index 000000000..3c5760bd4 --- /dev/null +++ b/apps/web/src/components/SelectChartPalette/index.ts @@ -0,0 +1 @@ +export * from './SelectChartPalette'; diff --git a/apps/web/src/components/TabLink/TabLink.tsx b/apps/web/src/components/TabLink/TabLink.tsx index a6a9156f0..2bcff3c38 100644 --- a/apps/web/src/components/TabLink/TabLink.tsx +++ b/apps/web/src/components/TabLink/TabLink.tsx @@ -19,6 +19,9 @@ export const TabLink = ({ label, pathname, testId }: TabLinkProps) => { data-nav-url={pathname} data-spotlight-type="tab-link" data-testid={testId} + // The instrument hub keeps its filters in the search params so both tabs describe one set; + // navigating without this drops them, and the newly mounted tab silently widens the selection. + search={true} to={pathname} > {label} diff --git a/apps/web/src/components/TruncatedCell/TruncatedCell.tsx b/apps/web/src/components/TruncatedCell/TruncatedCell.tsx new file mode 100644 index 000000000..56f3dca32 --- /dev/null +++ b/apps/web/src/components/TruncatedCell/TruncatedCell.tsx @@ -0,0 +1,21 @@ +import type React from 'react'; + +type TruncatedCellProps = { + 'data-testid'?: string; + /** The full value, shown on hover when the cell is too narrow to hold it */ + title: string; + value: React.ReactNode; +}; + +/** + * One line of cell text, clipped with an ellipsis rather than wrapping the row taller. + * + * The full value is exposed through the native `title` attribute rather than a tooltip component: a + * record table renders this in every cell of every row, and a few thousand tooltip roots is a real + * cost for a hint that is only ever wanted on the one cell a user is pointing at. + */ +export const TruncatedCell = ({ 'data-testid': testId, title, value }: TruncatedCellProps) => ( +

+ {value} +

+); diff --git a/apps/web/src/components/TruncatedCell/index.ts b/apps/web/src/components/TruncatedCell/index.ts new file mode 100644 index 000000000..817c1e324 --- /dev/null +++ b/apps/web/src/components/TruncatedCell/index.ts @@ -0,0 +1 @@ +export * from './TruncatedCell'; diff --git a/apps/web/src/hooks/__tests__/useCollectionMethodLabels.test.ts b/apps/web/src/hooks/__tests__/useCollectionMethodLabels.test.ts new file mode 100644 index 000000000..291e76bfc --- /dev/null +++ b/apps/web/src/hooks/__tests__/useCollectionMethodLabels.test.ts @@ -0,0 +1,32 @@ +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { useCollectionMethodLabels } from '../useCollectionMethodLabels'; + +import '@/services/i18n'; + +describe('useCollectionMethodLabels', () => { + beforeEach(() => { + i18n.changeLanguage('en'); + }); + + afterEach(cleanup); + + it('should name every session type, so no collection method renders as its stored enum', () => { + const { result } = renderHook(() => useCollectionMethodLabels()); + expect(result.current).toStrictEqual({ + IN_PERSON: 'In-Person', + REMOTE: 'Remote', + RETROSPECTIVE: 'Retrospective' + }); + }); + + // Reuses the copy the session form already shows for the same concept, so a session started as + // "In-Person" is not later described with a different word in the datahub. + it('should follow the interface language', () => { + i18n.changeLanguage('fr'); + const { result } = renderHook(() => useCollectionMethodLabels()); + expect(result.current.REMOTE).toBe('À distance'); + }); +}); diff --git a/apps/web/src/hooks/__tests__/useGraphData.test.ts b/apps/web/src/hooks/__tests__/useGraphData.test.ts index 551f228c6..14306b543 100644 --- a/apps/web/src/hooks/__tests__/useGraphData.test.ts +++ b/apps/web/src/hooks/__tests__/useGraphData.test.ts @@ -9,6 +9,11 @@ import type { InstrumentVisualizationRecord } from '../useInstrumentVisualizatio const record = (time: number, measures: { [key: string]: number }): InstrumentVisualizationRecord => ({ __date__: new Date(time), __id__: `record-${time}`, + __instrumentId__: 'instrument-1', + __method__: 'IN_PERSON', + __seriesId__: null, + __seriesName__: null, + __subjectId__: 'subject-1', __time__: time, ...measures }); diff --git a/apps/web/src/hooks/__tests__/useGraphLines.test.ts b/apps/web/src/hooks/__tests__/useGraphLines.test.ts index a75228d8a..538b6cbbe 100644 --- a/apps/web/src/hooks/__tests__/useGraphLines.test.ts +++ b/apps/web/src/hooks/__tests__/useGraphLines.test.ts @@ -2,6 +2,8 @@ import { i18n } from '@douglasneuroinformatics/libui/i18n'; import { act, cleanup, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { CHART_PALETTE_THEMES } from '@/utils/chart-palette'; + import { useGraphLines } from '../useGraphLines'; import '@/services/i18n'; @@ -15,6 +17,9 @@ const NO_MEASURES: { key: string; label: string }[] = []; const ONLY_SCORE = [SCORE]; const SCORE_AND_MOOD = [SCORE, MOOD]; +// `useTheme` defaults to the light surface under happy-dom, which has no colour-scheme preference. +const LIGHT = CHART_PALETTE_THEMES.default.light; + describe('useGraphLines', () => { beforeEach(() => { i18n.changeLanguage('en'); @@ -23,18 +28,18 @@ describe('useGraphLines', () => { afterEach(cleanup); it('should draw no lines when no measure is selected', () => { - const { result } = renderHook(() => useGraphLines({ selectedMeasures: NO_MEASURES })); + const { result } = renderHook(() => useGraphLines({ palette: 'default', selectedMeasures: NO_MEASURES })); expect(result.current).toEqual([]); }); it('should pair each measure with a dashed group trend line in the same colour', () => { - const { result } = renderHook(() => useGraphLines({ selectedMeasures: ONLY_SCORE })); + const { result } = renderHook(() => useGraphLines({ palette: 'default', selectedMeasures: ONLY_SCORE })); expect(result.current).toEqual([ - { name: 'Score', stroke: '#D81B60', val: 'score' }, + { name: 'Score', stroke: LIGHT[0], val: 'score' }, { legendType: 'none', name: 'Score (Group Trend)', - stroke: '#D81B60', + stroke: LIGHT[0], strokeDasharray: '5 5', strokeWidth: 0.5, val: 'scoreGroup' @@ -43,17 +48,23 @@ describe('useGraphLines', () => { }); it('should give each measure its own colour, so the lines can be told apart', () => { - const { result } = renderHook(() => useGraphLines({ selectedMeasures: SCORE_AND_MOOD })); + const { result } = renderHook(() => useGraphLines({ palette: 'default', selectedMeasures: SCORE_AND_MOOD })); expect(result.current.map((line) => [line.val, line.stroke])).toEqual([ - ['score', '#D81B60'], - ['scoreGroup', '#D81B60'], - ['mood', '#1E88E5'], - ['moodGroup', '#1E88E5'] + ['score', LIGHT[0]], + ['scoreGroup', LIGHT[0]], + ['mood', LIGHT[1]], + ['moodGroup', LIGHT[1]] ]); }); + // The palette is a search param, so a shared link has to reproduce the colours it was made with. + it('should draw from the chosen palette rather than always the default', () => { + const { result } = renderHook(() => useGraphLines({ palette: 'jade', selectedMeasures: ONLY_SCORE })); + expect(result.current[0]!.stroke).toBe(CHART_PALETTE_THEMES.jade.light[0]); + }); + it('should relabel the group trend when the language changes', () => { - const { result } = renderHook(() => useGraphLines({ selectedMeasures: ONLY_SCORE })); + const { result } = renderHook(() => useGraphLines({ palette: 'default', selectedMeasures: ONLY_SCORE })); act(() => { i18n.changeLanguage('fr'); }); diff --git a/apps/web/src/hooks/__tests__/useInstrumentHubFacets.test.ts b/apps/web/src/hooks/__tests__/useInstrumentHubFacets.test.ts new file mode 100644 index 000000000..2fc5bba77 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useInstrumentHubFacets.test.ts @@ -0,0 +1,99 @@ +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { NO_SERIES, useInstrumentHubFacets } from '../useInstrumentHubFacets'; + +import '@/services/i18n'; + +const mocks = vi.hoisted(() => ({ + infos: [] as { details: { title: string }; id: string; internal?: { edition: number; name: string }; kind: string }[], + instrumentId: 'hq-1', + records: [] as { seriesInstrumentId?: null | string }[], + seriesInfoById: {} +})); + +vi.mock('@tanstack/react-router', () => ({ + useParams: () => ({ instrumentId: mocks.instrumentId }) +})); +vi.mock('@/hooks/useInstrumentInfoQuery', () => ({ + useInstrumentInfoQuery: () => ({ data: mocks.infos }) +})); +vi.mock('@/hooks/useInstrumentRecords', () => ({ + useInstrumentRecords: () => ({ data: mocks.records }) +})); +vi.mock('@/hooks/useInstrumentInfoById', () => ({ + useInstrumentInfoById: () => mocks.seriesInfoById +})); +vi.mock('@/store', () => ({ + useAppStore: (selector: (store: { currentGroup: null }) => unknown) => selector({ currentGroup: null }) +})); + +const scalar = (id: string, name: string, edition: number) => ({ + details: { title: name }, + id, + internal: { edition, name }, + kind: 'FORM' +}); + +describe('useInstrumentHubFacets', () => { + beforeEach(() => { + mocks.infos = [scalar('hq-1', 'Happiness Questionnaire', 1)]; + mocks.instrumentId = 'hq-1'; + mocks.records = []; + mocks.seriesInfoById = {}; + }); + + afterEach(cleanup); + + it('should name the opened instrument from the catalog', () => { + const { result } = renderHook(() => useInstrumentHubFacets()); + expect(result.current.title).toBe('Happiness Questionnaire'); + }); + + it('should report no title for an instrument the catalog does not hold', () => { + mocks.instrumentId = 'missing'; + const { result } = renderHook(() => useInstrumentHubFacets()); + expect(result.current.title).toBeNull(); + }); + + // The series page is a different shape — no shared measures, so no measure columns and no chart. + it('should flag a series, so its page can suppress the chart', () => { + mocks.infos = [{ details: { title: 'Happiness Series' }, id: 'series-1', kind: 'SERIES' }]; + mocks.instrumentId = 'series-1'; + const { result } = renderHook(() => useInstrumentHubFacets()); + expect(result.current.isSeries).toBe(true); + }); + + it('should not flag a scalar instrument as a series', () => { + const { result } = renderHook(() => useInstrumentHubFacets()); + expect(result.current.isSeries).toBe(false); + }); + + it('should offer every sibling edition of the opened instrument', () => { + mocks.infos = [scalar('hq-1', 'Happiness Questionnaire', 1), scalar('hq-2', 'Happiness Questionnaire', 2)]; + const { result } = renderHook(() => useInstrumentHubFacets()); + expect(result.current.editionOptions).toStrictEqual({ 'hq-1': 'Edition 1', 'hq-2': 'Edition 2' }); + }); + + // The filter can only offer what the records actually reference, so a series nothing was + // collected under is not an option. + it('should offer only the series its records were collected under', () => { + mocks.records = [{ seriesInstrumentId: 'series-1' }]; + mocks.seriesInfoById = { 'series-1': { edition: null, kind: 'SERIES', title: 'Happiness Series' } }; + const { result } = renderHook(() => useInstrumentHubFacets()); + expect(Array.from(result.current.seriesOptions)).toStrictEqual([['series-1', 'Happiness Series']]); + }); + + it('should bucket records collected outside any series under the no-series option', () => { + mocks.records = [{ seriesInstrumentId: null }]; + const { result } = renderHook(() => useInstrumentHubFacets()); + expect(Array.from(result.current.seriesOptions)).toStrictEqual([[NO_SERIES, null]]); + }); + + // A record may name a series the caller cannot read, leaving the id as the only available label. + it('should offer a series it cannot name with no title rather than omitting it', () => { + mocks.records = [{ seriesInstrumentId: 'series-unreadable' }]; + const { result } = renderHook(() => useInstrumentHubFacets()); + expect(result.current.seriesOptions.get('series-unreadable')).toBeNull(); + }); +}); diff --git a/apps/web/src/hooks/__tests__/useInstrumentHubRecords.test.ts b/apps/web/src/hooks/__tests__/useInstrumentHubRecords.test.ts new file mode 100644 index 000000000..f5bbfd952 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useInstrumentHubRecords.test.ts @@ -0,0 +1,132 @@ +import type { SessionType } from '@opendatacapture/schemas/session'; +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useInstrumentHubRecords } from '../useInstrumentHubRecords'; + +import type { InstrumentVisualizationRecord } from '../useInstrumentVisualization'; + +type HubSearch = { methods?: SessionType[]; minDate?: Date; series?: string[] }; + +type VisualizationParams = { + filterRecord: (record: InstrumentVisualizationRecord) => boolean; + instrumentId: string | undefined; + seriesInstrumentId: string | undefined; +}; + +const mocks: { + isSeries: boolean; + search: HubSearch; + /** Captured so the params the hook hands down can be asserted without rendering the real hook */ + visualizationParams: undefined | VisualizationParams; +} = vi.hoisted(() => ({ + isSeries: false, + search: {}, + visualizationParams: undefined +})); + +vi.mock('@tanstack/react-router', () => ({ + useParams: () => ({ instrumentId: 'instrument-1' }), + useSearch: () => mocks.search +})); +vi.mock('@/hooks/useInstrumentHubFacets', () => ({ + NO_SERIES: '', + useInstrumentHubFacets: () => ({ isSeries: mocks.isSeries }) +})); +vi.mock('@/hooks/useInstrumentVisualization', () => ({ + useInstrumentVisualization: ({ params }: { params: VisualizationParams }) => { + mocks.visualizationParams = params; + return { records: [] }; + } +})); + +const record = (overrides: Partial = {}): InstrumentVisualizationRecord => ({ + __date__: new Date('2025-01-01'), + __id__: 'record-1', + __instrumentId__: 'instrument-1', + __method__: 'IN_PERSON', + __seriesId__: null, + __seriesName__: null, + __subjectId__: 'subject-1', + __time__: 0, + ...overrides +}); + +/** The predicate the hook hands down, which is what both tabs and the download are filtered by. */ +const filterRecord = (): ((record: InstrumentVisualizationRecord) => boolean) => { + renderHook(() => useInstrumentHubRecords()); + return mocks.visualizationParams!.filterRecord; +}; + +describe('useInstrumentHubRecords', () => { + beforeEach(() => { + mocks.isSeries = false; + mocks.search = {}; + mocks.visualizationParams = undefined; + }); + + afterEach(cleanup); + + // The filter goes *into* the visualization hook rather than being applied to its result, because + // its download closes over its own records — a filter applied outside would narrow the table + // while the download kept every row the user believed they had excluded. + it('should hand the predicate to the visualization hook rather than filtering its output', () => { + renderHook(() => useInstrumentHubRecords()); + expect(typeof mocks.visualizationParams!.filterRecord).toBe('function'); + }); + + it('should keep every record when no filter is set', () => { + const matches = filterRecord(); + expect(matches(record())).toBe(true); + expect(matches(record({ __method__: null }))).toBe(true); + }); + + it('should drop a record whose method is not selected', () => { + mocks.search = { methods: ['REMOTE'] }; + expect(filterRecord()(record({ __method__: 'IN_PERSON' }))).toBe(false); + }); + + it('should keep a record whose method is selected', () => { + mocks.search = { methods: ['IN_PERSON'] }; + expect(filterRecord()(record({ __method__: 'IN_PERSON' }))).toBe(true); + }); + + // A record whose session was deleted carries no method, so it matches no explicit selection. + // Exempting it meant unchecking every method still left rows on screen. + it('should drop a record with no method once the method filter narrows', () => { + mocks.search = { methods: ['IN_PERSON'] }; + expect(filterRecord()(record({ __method__: null }))).toBe(false); + }); + + it('should show nothing when every method is unchecked, which an absent filter does not mean', () => { + mocks.search = { methods: [] }; + const matches = filterRecord(); + expect(matches(record({ __method__: 'IN_PERSON' }))).toBe(false); + expect(matches(record({ __method__: null }))).toBe(false); + }); + + it('should match a record collected outside any series against the no-series option', () => { + mocks.search = { series: [''] }; + expect(filterRecord()(record({ __seriesId__: null }))).toBe(true); + }); + + it('should drop a record whose series is not selected', () => { + mocks.search = { series: ['series-1'] }; + expect(filterRecord()(record({ __seriesId__: 'series-2' }))).toBe(false); + }); + + // A record never carries a series as its own `instrumentId`, so querying it that way for a series + // page would always come back empty. + it('should query a series through seriesInstrumentId, not instrumentId', () => { + mocks.isSeries = true; + renderHook(() => useInstrumentHubRecords()); + expect(mocks.visualizationParams!.instrumentId).toBeUndefined(); + expect(mocks.visualizationParams!.seriesInstrumentId).toBe('instrument-1'); + }); + + it('should query a scalar instrument through instrumentId', () => { + renderHook(() => useInstrumentHubRecords()); + expect(mocks.visualizationParams!.instrumentId).toBe('instrument-1'); + expect(mocks.visualizationParams!.seriesInstrumentId).toBeUndefined(); + }); +}); diff --git a/apps/web/src/hooks/__tests__/useInstrumentInfoById.test.ts b/apps/web/src/hooks/__tests__/useInstrumentInfoById.test.ts new file mode 100644 index 000000000..b6b3b4986 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useInstrumentInfoById.test.ts @@ -0,0 +1,62 @@ +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useInstrumentInfoById } from '../useInstrumentInfoById'; + +type InfoQueryParams = { allEditions?: boolean; kind?: string; subjectId?: string }; + +const mocks = vi.hoisted(() => ({ + data: undefined as + undefined | { details: { title: string }; id: string; internal?: { edition: number }; kind: string }[], + /** What the hook asked the catalog for, captured so the slice can be asserted */ + params: undefined as InfoQueryParams | undefined +})); + +vi.mock('@/hooks/useInstrumentInfoQuery', () => ({ + useInstrumentInfoQuery: ({ params }: { params?: InfoQueryParams }) => { + mocks.params = params; + return { data: mocks.data }; + } +})); + +describe('useInstrumentInfoById', () => { + beforeEach(() => { + mocks.data = undefined; + mocks.params = undefined; + }); + + afterEach(cleanup); + + it('should key every instrument by its id, carrying the kind beside the title', () => { + mocks.data = [ + { details: { title: 'Happiness Questionnaire' }, id: 'hq-1', internal: { edition: 1 }, kind: 'FORM' }, + { details: { title: 'Stroop Task' }, id: 'stroop-1', internal: { edition: 3 }, kind: 'INTERACTIVE' } + ]; + const { result } = renderHook(() => useInstrumentInfoById()); + expect(result.current).toStrictEqual({ + 'hq-1': { edition: 1, kind: 'FORM', title: 'Happiness Questionnaire' }, + 'stroop-1': { edition: 3, kind: 'INTERACTIVE', title: 'Stroop Task' } + }); + }); + + // A series' member list reads the kind off this map, so an INTERACTIVE member must not be + // reported as a form. + it('should report a non-form kind as itself', () => { + mocks.data = [{ details: { title: 'Upload' }, id: 'file-1', internal: { edition: 1 }, kind: 'FILE' }]; + const { result } = renderHook(() => useInstrumentInfoById()); + expect(result.current['file-1']?.kind).toBe('FILE'); + expect(result.current['file-1']?.edition).toBe(1); + }); + + it('should pass the requested slice of the catalog straight through to the query', () => { + renderHook(() => useInstrumentInfoById({ kind: 'SERIES' })); + expect(mocks.params).toStrictEqual({ kind: 'SERIES' }); + }); + + // The hook is read during the first render, before the query settles, so an unresolved query has + // to be an empty map rather than undefined. + it('should be an empty map while the catalog has not loaded', () => { + const { result } = renderHook(() => useInstrumentInfoById()); + expect(result.current).toStrictEqual({}); + }); +}); diff --git a/apps/web/src/hooks/__tests__/useInstrumentKindLabels.test.ts b/apps/web/src/hooks/__tests__/useInstrumentKindLabels.test.ts new file mode 100644 index 000000000..896769f78 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useInstrumentKindLabels.test.ts @@ -0,0 +1,34 @@ +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { useInstrumentKindLabels } from '../useInstrumentKindLabels'; + +import '@/services/i18n'; + +describe('useInstrumentKindLabels', () => { + beforeEach(() => { + i18n.changeLanguage('en'); + }); + + afterEach(cleanup); + + it('should name every instrument kind, so none reaches the screen as its stored value', () => { + const { result } = renderHook(() => useInstrumentKindLabels()); + expect(result.current).toStrictEqual({ + FILE: 'File', + FORM: 'Form', + INTERACTIVE: 'Interactive', + SERIES: 'Series' + }); + }); + + // The kind is shown in a table cell and in the filter that narrows on it; both read this map, so + // a reader cannot be offered "Formulaire" and then shown "Form". + it('should follow the interface language', () => { + i18n.changeLanguage('fr'); + const { result } = renderHook(() => useInstrumentKindLabels()); + expect(result.current.FORM).toBe('Formulaire'); + expect(result.current.SERIES).toBe('Série'); + }); +}); diff --git a/apps/web/src/hooks/__tests__/useInstrumentRecordSummaryQuery.test.ts b/apps/web/src/hooks/__tests__/useInstrumentRecordSummaryQuery.test.ts new file mode 100644 index 000000000..0e1b5bd18 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useInstrumentRecordSummaryQuery.test.ts @@ -0,0 +1,81 @@ +import { QueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { instrumentRecordSummaryQueryOptions } from '../useInstrumentRecordSummaryQuery'; + +vi.mock('axios'); + +const runQuery = (params: { bySeries?: boolean; groupId?: string }) => + new QueryClient().fetchQuery(instrumentRecordSummaryQueryOptions({ params })); + +// eslint-disable-next-line @typescript-eslint/unbound-method -- a vitest mock, never invoked as a method +const get = vi.mocked(axios).get; + +const summary = (overrides: { [key: string]: unknown } = {}) => ({ + instrumentId: 'instrument-1', + lastCollectedAt: null, + recordCount: 2, + subjectCount: 1, + ...overrides +}); + +describe('instrumentRecordSummaryQueryOptions', () => { + beforeEach(() => { + get.mockReset(); + }); + + it('should summarize by instrument by default, which is what the hub lists', async () => { + get.mockResolvedValueOnce({ data: [] }); + await runQuery({ groupId: 'group-1' }); + expect(get).toHaveBeenCalledWith('/v1/instrument-records/summary/by-instrument', { + params: { groupId: 'group-1' } + }); + }); + + // A series collects through the instruments it composes, so its totals come from a different + // aggregation rather than from filtering the per-instrument one. + it('should summarize by series when asked, since a series owns no records under its own id', async () => { + get.mockResolvedValueOnce({ data: [] }); + await runQuery({ bySeries: true, groupId: 'group-1' }); + expect(get).toHaveBeenCalledWith('/v1/instrument-records/summary/by-series', { params: { groupId: 'group-1' } }); + }); + + it('should send only the group, so the series flag stays a client-side choice of endpoint', async () => { + get.mockResolvedValueOnce({ data: [] }); + await runQuery({ bySeries: true, groupId: 'group-1' }); + expect(get.mock.lastCall?.[1]).toStrictEqual({ params: { groupId: 'group-1' } }); + }); + + it('should coerce the collection date, so the column can compare it as a date rather than a string', async () => { + get.mockResolvedValueOnce({ data: [summary({ lastCollectedAt: '2025-02-02T00:00:00.000Z' })] }); + const [parsed] = await runQuery({ groupId: 'group-1' }); + expect(parsed!.lastCollectedAt).toStrictEqual(new Date('2025-02-02T00:00:00.000Z')); + }); + + // An instrument the caller may read but has never collected belongs in the list with no date. + it('should accept a null collection date for an instrument never collected', async () => { + get.mockResolvedValueOnce({ data: [summary({ recordCount: 0, subjectCount: 0 })] }); + await expect(runQuery({ groupId: 'group-1' })).resolves.toStrictEqual([ + { instrumentId: 'instrument-1', lastCollectedAt: null, recordCount: 0, subjectCount: 0 } + ]); + }); + + it('should reject a fractional subject count, since subjects are counted not measured', async () => { + get.mockResolvedValueOnce({ data: [summary({ subjectCount: 1.5 })] }); + await expect(runQuery({ groupId: 'group-1' })).rejects.toThrow(); + }); + + it('should key the cache on the group, so switching group does not serve the previous group counts', () => { + expect(instrumentRecordSummaryQueryOptions({ params: { groupId: 'group-1' } }).queryKey).not.toStrictEqual( + instrumentRecordSummaryQueryOptions({ params: { groupId: 'group-2' } }).queryKey + ); + }); + + // Both summaries are fetched on the same page, so sharing a key would serve one as the other. + it('should key the cache on the series flag, so the two summaries do not share an entry', () => { + expect(instrumentRecordSummaryQueryOptions({ params: { groupId: 'group-1' } }).queryKey).not.toStrictEqual( + instrumentRecordSummaryQueryOptions({ params: { bySeries: true, groupId: 'group-1' } }).queryKey + ); + }); +}); diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index 910fb3526..8c6a3914f 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -44,8 +44,10 @@ const createMockRecords = (data: { [key: string]: unknown }) => [ computedMeasures: {}, data, date: FIXED_TEST_DATE, + instrumentId: 'instrument-1', session: { user: { username: 'testusername' } }, - sessionId: '123' + sessionId: '123', + subjectId: 'group-1$subject-1' } ]; const mockInstrumentRecords: { data?: { [key: string]: unknown }[] } = { @@ -113,7 +115,7 @@ describe('useInstrumentVisualization', () => { const result = await renderWithSetRecord(); act(() => result.current.dl('CSV Long')); const [, getContentFn] = mockDownloadFn.mock.calls[0] ?? []; - expect(getContentFn()).toContain(`testusername,"FRIENDS, MONEY",causes`); + expect(getContentFn()).toContain(`testusername,causes,"FRIENDS, MONEY"`); }); it('should export a set in JSON as the subject table displays it, not as {}', async () => { @@ -138,7 +140,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.csv'); const csvContents = getContentFn(); expect(csvContents).toMatch( - `GroupID,subjectId,Date,Username,someValue\r\ntestGroupId,testId,${toBasicISOString(FIXED_TEST_DATE)},testusername,abc` + `GroupID,SubjectID,InstrumentName,InstrumentEdition,Date,CollectionMethod,SeriesID,SeriesName,Username,someValue\r\ntestGroupId,subject-1,instrument-1,,${toBasicISOString(FIXED_TEST_DATE)},,,,testusername,abc` ); }); }); @@ -156,7 +158,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.tsv'); const tsvContents = getContentFn(); expect(tsvContents).toMatch( - `GroupID\tsubjectId\tDate\tUsername\tsomeValue\r\ntestGroupId\ttestId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestusername\tabc` + `GroupID\tSubjectID\tInstrumentName\tInstrumentEdition\tDate\tCollectionMethod\tSeriesID\tSeriesName\tUsername\tsomeValue\r\ntestGroupId\tsubject-1\tinstrument-1\t\t${toBasicISOString(FIXED_TEST_DATE)}\t\t\t\ttestusername\tabc` ); }); }); @@ -175,7 +177,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.csv'); const csvLongContents = getContentFn(); expect(csvLongContents).toMatch( - `GroupID,Date,SubjectID,Username,Value,Variable\r\ntestGroupId,${toBasicISOString(FIXED_TEST_DATE)},testId,testusername,abc,someValue` + `GroupID,SubjectID,InstrumentName,InstrumentEdition,Date,CollectionMethod,SeriesID,SeriesName,Username,Variable,Value\r\ntestGroupId,subject-1,instrument-1,,${toBasicISOString(FIXED_TEST_DATE)},,,,testusername,someValue,abc` ); }); }); @@ -194,7 +196,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toMatch('.tsv'); const tsvLongContents = getContentFn(); expect(tsvLongContents).toMatch( - `GroupID\tDate\tSubjectID\tUsername\tValue\tVariable\r\ntestGroupId\t${toBasicISOString(FIXED_TEST_DATE)}\ttestId\ttestusername\tabc\tsomeValue` + `GroupID\tSubjectID\tInstrumentName\tInstrumentEdition\tDate\tCollectionMethod\tSeriesID\tSeriesName\tUsername\tVariable\tValue\r\ntestGroupId\tsubject-1\tinstrument-1\t\t${toBasicISOString(FIXED_TEST_DATE)}\t\t\t\ttestusername\tsomeValue\tabc` ); }); }); @@ -214,9 +216,14 @@ describe('useInstrumentVisualization', () => { expect(excelContents).toEqual([ { + CollectionMethod: null, Date: '2025-04-30', GroupID: 'testGroupId', - subjectId: 'testId', + InstrumentEdition: null, + InstrumentName: 'instrument-1', + SeriesID: null, + SeriesName: null, + SubjectID: 'subject-1', // eslint-disable-next-line perfectionist/sort-objects someValue: 'abc', Username: 'testusername' @@ -241,9 +248,14 @@ describe('useInstrumentVisualization', () => { expect(excelContents).toEqual([ { + CollectionMethod: null, Date: '2025-04-30', GroupID: 'testGroupId', - SubjectID: 'testId', + InstrumentEdition: null, + InstrumentName: 'instrument-1', + SeriesID: null, + SeriesName: null, + SubjectID: 'subject-1', Username: 'testusername', Value: 'abc', Variable: 'someValue' @@ -251,6 +263,99 @@ describe('useInstrumentVisualization', () => { ]); }); }); + // A series' export spans several member instruments, so the instrument cannot be left to the + // filename the way a single instrument's export can — each row has to say what produced it. + describe('instrument provenance', () => { + const SERIES_RECORDS = [ + { + computedMeasures: {}, + data: { someValue: 'abc' }, + date: FIXED_TEST_DATE, + instrumentId: 'member-1', + seriesInstrumentId: 'series-1', + session: { user: { username: 'testusername' } }, + sessionId: '123', + subjectId: 'group-1$subject-1' + }, + { + computedMeasures: {}, + data: { otherValue: 7 }, + date: FIXED_TEST_DATE, + instrumentId: 'member-2', + seriesInstrumentId: 'series-1', + session: { user: { username: 'testusername' } }, + sessionId: '124', + subjectId: 'group-1$subject-1' + } + ]; + + const renderSeries = async () => { + mockUseInstrument.mockReturnValue(null); + mockInfoQuery.data = [ + { details: { title: 'Member One' }, id: 'member-1', internal: { edition: 1, name: 'one' }, kind: 'FORM' }, + { details: { title: 'Member Two' }, id: 'member-2', internal: { edition: 3, name: 'two' }, kind: 'FORM' } + ]; + mockInstrumentRecords.data = SERIES_RECORDS; + const { result } = renderHook(() => useInstrumentVisualization({ params: { seriesInstrumentId: 'series-1' } })); + await waitFor(() => { + expect(result.current.records.length).toBe(2); + }); + return result; + }; + + it('should name the instrument and edition that produced each row', async () => { + const result = await renderSeries(); + act(() => result.current.dl('Excel Long')); + const [, rows] = mockExcelDownloadFn.mock.calls[0] ?? []; + expect(rows).toEqual([ + expect.objectContaining({ InstrumentEdition: 1, InstrumentName: 'Member One', Variable: 'someValue' }), + expect.objectContaining({ InstrumentEdition: 3, InstrumentName: 'Member Two', Variable: 'otherValue' }) + ]); + }); + + // Papaparse takes its header from the first row alone, so without the union of every record's + // measures a series export silently dropped whatever the first instrument did not measure. + it("should keep every instrument's fields as columns in a wide export", async () => { + const result = await renderSeries(); + act(() => result.current.dl('CSV')); + const [, getContentFn] = mockDownloadFn.mock.calls[0] ?? []; + const [header, firstRow, secondRow] = (getContentFn() as string).split('\r\n'); + expect(header).toContain('someValue'); + expect(header).toContain('otherValue'); + // Each row fills only its own instrument's field and leaves the other blank rather than + // claiming a value it never collected. + expect(firstRow!.endsWith('abc,')).toBe(true); + expect(secondRow!.endsWith(',7')).toBe(true); + }); + + // Every member's measures reach one file, which is the point of exporting a whole series. + it('should carry every member instrument into a single export', async () => { + const result = await renderSeries(); + act(() => result.current.dl('CSV Long')); + const [filename, getContentFn] = mockDownloadFn.mock.calls[0] ?? []; + expect(filename).toContain('series'); + const contents = getContentFn() as string; + expect(contents).toContain('Member One'); + expect(contents).toContain('Member Two'); + }); + + it('should fall back to the id so a row is never anonymous when the catalog has dropped it', async () => { + mockUseInstrument.mockReturnValue(null); + mockInfoQuery.data = []; + mockInstrumentRecords.data = SERIES_RECORDS; + const { result } = renderHook(() => useInstrumentVisualization({ params: { seriesInstrumentId: 'series-1' } })); + await waitFor(() => { + expect(result.current.records.length).toBe(2); + }); + act(() => result.current.dl('Excel Long')); + const [, rows] = mockExcelDownloadFn.mock.calls[0] ?? []; + expect(rows).toEqual([ + expect.objectContaining({ InstrumentEdition: null, InstrumentName: 'member-1' }), + expect.objectContaining({ InstrumentEdition: null, InstrumentName: 'member-2' }) + ]); + }); + }); + describe('editions', () => { it('should list only the latest edition of an instrument, with every edition available as an option', async () => { mockInfoQuery.data = [ @@ -317,7 +422,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toMatch('.json'); const jsonContents = await getContentFn(); expect(jsonContents).toContain('"someValue": "abc"'); - expect(jsonContents).toContain('"subjectID": "testId"'); + expect(jsonContents).toContain('"SubjectID": "subject-1"'); }); }); @@ -383,12 +488,25 @@ describe('useInstrumentVisualization', () => { it('should merge the computed measures into the record, ignoring data that is not an object', async () => { const result = await renderWithRecords([ - { computedMeasures: { total: 3 }, data: null, date: FIXED_TEST_DATE, id: 'record-1', session: null } + { + computedMeasures: { total: 3 }, + data: null, + date: FIXED_TEST_DATE, + id: 'record-1', + instrumentId: 'instrument-1', + session: null, + subjectId: 'group-1$subject-1' + } ]); expect(result.current.records).toEqual([ { __date__: FIXED_TEST_DATE, __id__: 'record-1', + __instrumentId__: 'instrument-1', + __method__: null, + __seriesId__: null, + __seriesName__: null, + __subjectId__: 'group-1$subject-1', __time__: FIXED_TEST_DATE.getTime(), total: 3, username: 'N/A' diff --git a/apps/web/src/hooks/__tests__/useNavItems.test.ts b/apps/web/src/hooks/__tests__/useNavItems.test.ts index 801642733..c52fe9d30 100644 --- a/apps/web/src/hooks/__tests__/useNavItems.test.ts +++ b/apps/web/src/hooks/__tests__/useNavItems.test.ts @@ -103,12 +103,22 @@ describe('useNavItems', () => { // The datahub is a parent over its views rather than a link of its own, so the subject listing is // reachable only as a child and the group itself carries no url to navigate to. - it('should offer the subject view nested under the data hub parent', () => { - const datahub = renderHook(() => useNavItems()) + const datahubItem = () => + renderHook(() => useNavItems()) .result.current.flat() .find((item) => item.label === 'Data Hub'); + + it('should nest both views under the data hub parent, which is itself not a destination', () => { + const datahub = datahubItem(); expect(datahub?.url).toBeUndefined(); - expect(datahub?.children?.map((child) => child.url)).toStrictEqual(['/datahub/subjects']); + expect(datahub?.children?.map((child) => child.url)).toStrictEqual(['/datahub/subjects', '/datahub/instruments']); + }); + + // The two halves are gated independently, so a user who may read records but not the instrument + // catalog still gets a working menu rather than an empty group or no datahub at all. + it('should offer the subject view alone to a user who cannot read instruments', () => { + mocks.can.mockImplementation((action, subject) => !(action === 'read' && subject === 'Instrument')); + expect(datahubItem()?.children?.map((child) => child.url)).toStrictEqual(['/datahub/subjects']); }); it('should omit the data hub entirely when the user cannot read both subjects and records', () => { diff --git a/apps/web/src/hooks/__tests__/useRecordChartSeries.test.ts b/apps/web/src/hooks/__tests__/useRecordChartSeries.test.ts new file mode 100644 index 000000000..26a303d04 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useRecordChartSeries.test.ts @@ -0,0 +1,147 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { CHART_PALETTE_THEMES, MAX_CATEGORICAL_GROUPS, NEUTRAL_MARK, OTHER_GROUP_KEY } from '@/utils/chart-palette'; + +import { useRecordChartSeries } from '../useRecordChartSeries'; + +import type { InstrumentVisualizationRecord } from '../useInstrumentVisualization'; + +vi.mock('@douglasneuroinformatics/libui/hooks', () => ({ + useTheme: () => ['light'], + useTranslation: () => ({ t: (value: string | { en: string }) => (typeof value === 'string' ? value : value.en) }) +})); + +const recordOf = (overrides: Partial): InstrumentVisualizationRecord => ({ + __date__: new Date('2024-01-01'), + __id__: 'record-1', + __instrumentId__: 'instrument-1', + __method__: 'IN_PERSON', + __seriesId__: null, + __seriesName__: null, + __subjectId__: 'subject-1', + __time__: 0, + score: 1, + ...overrides +}); + +type Options = Parameters[0]; + +const render = (options: Omit & { palette?: Options['palette'] }) => + renderHook(() => useRecordChartSeries({ palette: 'default', ...options })).result.current; + +describe('useRecordChartSeries', () => { + it('should produce nothing until a measure is chosen', () => { + const { series } = render({ colourBy: 'none', measure: undefined, records: [recordOf({})] }); + expect(series).toStrictEqual([]); + }); + + it('should put every record in one series when no grouping is asked for', () => { + const { series } = render({ + colourBy: 'none', + measure: 'score', + records: [recordOf({ __method__: 'IN_PERSON' }), recordOf({ __method__: 'REMOTE' })] + }); + expect(series).toHaveLength(1); + expect(series[0]!.points).toHaveLength(2); + }); + + // Without this the palette control would be inert until a grouping was chosen. + it('should colour an ungrouped series from the chosen palette rather than a fixed grey', () => { + const records = [recordOf({})]; + expect(render({ colourBy: 'none', measure: 'score', records }).series[0]!.color).toBe( + CHART_PALETTE_THEMES.default.light[0] + ); + expect(render({ colourBy: 'none', measure: 'score', palette: 'jade', records }).series[0]!.color).toBe( + CHART_PALETTE_THEMES.jade.light[0] + ); + expect(render({ colourBy: 'none', measure: 'score', records }).series[0]!.color).not.toBe(NEUTRAL_MARK.light); + }); + + it('should split records by collection method, which is why that column exists', () => { + const { series } = render({ + colourBy: 'method', + measure: 'score', + records: [ + recordOf({ __method__: 'REMOTE' }), + recordOf({ __method__: 'REMOTE' }), + recordOf({ __method__: 'IN_PERSON' }) + ] + }); + // The mocked translator echoes keys, so these are the session namespace's own labels — the + // point being that the method labels are reused rather than written again for the chart. + expect(series.map((item) => [item.label, item.points.length])).toStrictEqual([ + ['session.type.remote', 2], + ['session.type.in-person', 1] + ]); + }); + + it('should label records collected outside a series as individual when grouping by series', () => { + const { series } = render({ colourBy: 'series', measure: 'score', records: [recordOf({})] }); + expect(series[0]!.label).toBe('Individual'); + }); + + // Colour must follow the group, never its rank, so the biggest group always takes the first slot + // and a group keeps its colour as the filters change the counts around it. + it('should order groups by size, so the palette is assigned deterministically', () => { + const { series } = render({ + colourBy: 'series', + measure: 'score', + records: [ + recordOf({ __seriesId__: 's1', __seriesName__: 'One' }), + recordOf({ __seriesId__: 's2', __seriesName__: 'Two' }), + recordOf({ __seriesId__: 's2', __seriesName__: 'Two' }) + ] + }); + expect(series.map((item) => item.label)).toStrictEqual(['Two', 'One']); + }); + + it('should fold the groups past the palette into one Other series rather than inventing hues', () => { + const records = Array.from({ length: MAX_CATEGORICAL_GROUPS + 2 }, (_, index) => + recordOf({ __seriesId__: `s${index}`, __seriesName__: `Series ${index}` }) + ); + const { series } = render({ colourBy: 'series', measure: 'score', records }); + + expect(series).toHaveLength(MAX_CATEGORICAL_GROUPS + 1); + const folded = series.at(-1)!; + expect(folded.key).toBe(OTHER_GROUP_KEY); + expect(folded.label).toBe('Other (2)'); + expect(folded.points).toHaveLength(2); + }); + + it('should report a measure holding text as non-numeric, since a scatter cannot plot it', () => { + const { isNumeric } = render({ + colourBy: 'none', + measure: 'score', + records: [recordOf({ score: 'high' })] + }); + expect(isNumeric).toBe(false); + }); + + it('should skip records with no value for the measure rather than plotting them as zero', () => { + const { series } = render({ + colourBy: 'none', + measure: 'score', + records: [recordOf({ score: 1 }), recordOf({ score: null }), recordOf({ score: undefined })] + }); + expect(series[0]!.points).toHaveLength(1); + }); + // The palette is a search param, so a shared link has to reproduce the colours it was made with. + it('should draw a group from the chosen palette theme rather than always the default', () => { + const records = [recordOf({ __method__: 'REMOTE' })]; + const asDefault = render({ colourBy: 'method', measure: 'score', records }); + const asBerry = render({ colourBy: 'method', measure: 'score', palette: 'berry', records }); + + expect(asDefault.series[0]!.color).toBe(CHART_PALETTE_THEMES.default.light[0]); + expect(asBerry.series[0]!.color).toBe(CHART_PALETTE_THEMES.berry.light[0]); + }); + + // Colour follows the entity, not its rank, so the folded tail stays outside every theme. + it('should keep the folded tail neutral whichever palette is chosen', () => { + const records = Array.from({ length: MAX_CATEGORICAL_GROUPS + 2 }, (_, index) => + recordOf({ __seriesId__: `s${index}`, __seriesName__: `Series ${index}` }) + ); + const { series } = render({ colourBy: 'series', measure: 'score', palette: 'jade', records }); + expect(series.at(-1)!.color).toBe(NEUTRAL_MARK.light); + }); +}); diff --git a/apps/web/src/hooks/__tests__/useRecordMetadataColumns.test.tsx b/apps/web/src/hooks/__tests__/useRecordMetadataColumns.test.tsx new file mode 100644 index 000000000..fb75c4667 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useRecordMetadataColumns.test.tsx @@ -0,0 +1,116 @@ +import type { ReactNode } from 'react'; + +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import type { SessionType } from '@opendatacapture/schemas/session'; +import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useRecordMetadataColumns } from '../useRecordMetadataColumns'; + +import type { InstrumentVisualizationRecord } from '../useInstrumentVisualization'; + +import '@/services/i18n'; + +const record = (overrides: Partial = {}): InstrumentVisualizationRecord => ({ + __date__: new Date('2025-01-01'), + __id__: 'record-1', + __instrumentId__: 'instrument-1', + __method__: 'IN_PERSON', + __seriesId__: null, + __seriesName__: null, + __subjectId__: 'subject-1', + __time__: 0, + ...overrides +}); + +const columns = (omitSeries = false) => renderHook(() => useRecordMetadataColumns({ omitSeries })).result.current; + +/** Renders one column's cell against a record, the way the table would. */ +const renderCell = (columnId: string, value: unknown, original: InstrumentVisualizationRecord) => { + const column = columns().find((candidate) => candidate.id === columnId)!; + const cell = (column as { cell: (ctx: unknown) => ReactNode }).cell; + render(<>{cell({ getValue: () => value, row: { original } })}); +}; + +/** Renders one column's header the way the table would, returning its sort toggle. */ +const renderHeader = (columnId: string, omitSeries = false) => { + const column = columns(omitSeries).find((candidate) => candidate.id === columnId)!; + const header = (column as { header: (ctx: unknown) => ReactNode }).header; + const toggleSorting = vi.fn(); + render(<>{header({ column: { getIsSorted: () => false, toggleSorting } })}); + return toggleSorting; +}; + +describe('useRecordMetadataColumns', () => { + beforeEach(() => { + i18n.changeLanguage('en'); + }); + + afterEach(cleanup); + + it('should show both provenance columns under the names the export uses', () => { + expect(columns().map((column) => column.id)).toStrictEqual(['__method__', '__seriesName__']); + expect(renderHeader('__method__') && screen.getByRole('button').textContent).toBe('COLLECTION_METHOD'); + cleanup(); + expect(renderHeader('__seriesName__') && screen.getByRole('button').textContent).toBe('SERIES'); + }); + + // Every row on a series' own page names that same series, so the column says nothing there. + it('should omit the series column when asked', () => { + expect(columns(true).map((column) => column.id)).toStrictEqual(['__method__']); + }); + + // These sit mid-table beside sortable columns, so a plain label would read as the odd one out. + it.each(['__method__', '__seriesName__'])('should let the %s column be sorted from its header', (columnId) => { + const toggleSorting = renderHeader(columnId); + fireEvent.click(screen.getByRole('button')); + expect(toggleSorting).toHaveBeenCalled(); + }); + + it('should translate the collection method rather than showing the stored enum', () => { + renderCell('__method__', 'REMOTE', record({ __method__: 'REMOTE' })); + expect(screen.getByTestId('record-cell-collection-method').textContent).toBe('Remote'); + }); + + // A record whose session was deleted reads back without a type, which must not render as blank + // or as "null". + it('should name the absence for a record whose collection method is unknown', () => { + renderCell('__method__', null, record({ __method__: null })); + expect(screen.getByTestId('record-cell-collection-method').textContent).toBe('None'); + }); + + it('should show the series name when the record was collected under one', () => { + renderCell('__seriesName__', 'Happiness Series', record({ __seriesName__: 'Happiness Series' })); + expect(screen.getByTestId('record-cell-series').textContent).toBe('Happiness Series'); + }); + + // The presence of a series name is itself the individual-vs-series answer, so the absence has to + // read as "none" rather than as missing data. + it('should name the absence for a record collected individually', () => { + renderCell('__seriesName__', null, record()); + expect(screen.getByTestId('record-cell-series').textContent).toBe('None'); + }); + + it('should filter the method column on the stored value, not the translated label', () => { + const column = columns().find((candidate) => candidate.id === '__method__')!; + const filterFn = column.filterFn as (row: unknown, id: string, filter: SessionType[]) => boolean; + const row = { getValue: () => 'IN_PERSON' }; + expect(filterFn(row, '__method__', ['IN_PERSON'])).toBe(true); + expect(filterFn(row, '__method__', ['REMOTE'])).toBe(false); + }); + + // Filtered on the id rather than the displayed name, since a name is not a stable identity. + it('should filter the series column on the series id', () => { + const column = columns().find((candidate) => candidate.id === '__seriesName__')!; + const filterFn = column.filterFn as (row: unknown, id: string, filter: string[]) => boolean; + const inSeries = { original: record({ __seriesId__: 'series-1', __seriesName__: 'Happiness Series' }) }; + expect(filterFn(inSeries, '__seriesName__', ['series-1'])).toBe(true); + expect(filterFn(inSeries, '__seriesName__', ['series-2'])).toBe(false); + }); + + it('should match a record collected outside any series against the no-series option', () => { + const column = columns().find((candidate) => candidate.id === '__seriesName__')!; + const filterFn = column.filterFn as (row: unknown, id: string, filter: string[]) => boolean; + expect(filterFn({ original: record() }, '__seriesName__', [''])).toBe(true); + }); +}); diff --git a/apps/web/src/hooks/useCollectionMethodLabels.ts b/apps/web/src/hooks/useCollectionMethodLabels.ts new file mode 100644 index 000000000..5f8d3ce8c --- /dev/null +++ b/apps/web/src/hooks/useCollectionMethodLabels.ts @@ -0,0 +1,17 @@ +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { SessionType } from '@opendatacapture/schemas/session'; + +/** + * Display labels for a record's data collection method, keyed by the stored value. + * + * Reuses the copy the session form already shows for the same concept, so a session started as + * "In-Person" is not later described with a different word in the datahub. + */ +export function useCollectionMethodLabels(): { [K in SessionType]: string } { + const { t } = useTranslation(); + return { + IN_PERSON: t('session.type.in-person'), + REMOTE: t('session.type.remote'), + RETROSPECTIVE: t('session.type.retrospective') + }; +} diff --git a/apps/web/src/hooks/useGraphLines.ts b/apps/web/src/hooks/useGraphLines.ts index b22a887f8..a4e779348 100644 --- a/apps/web/src/hooks/useGraphLines.ts +++ b/apps/web/src/hooks/useGraphLines.ts @@ -1,50 +1,43 @@ -import { useEffect, useState } from 'react'; +import { useMemo } from 'react'; import type { ListboxDropdownOption } from '@douglasneuroinformatics/libui/components'; -import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { LineGraphLine } from '@/components/LineGraph'; - -const COLOR_PALETTE: readonly string[] = [ - '#D81B60', - '#1E88E5', - '#FD08FA', - '#A06771', - '#353A9B', - '#D90323', - '#9C9218', - '#CF0583', - '#4075A3' -]; +import { getMeasureColor } from '@/utils/chart-palette'; +import type { ChartPaletteName } from '@/utils/chart-palette'; +import { resolveTheme } from '@/utils/chart-theme'; type UseGraphLinesOptions = { + palette: ChartPaletteName; selectedMeasures: ListboxDropdownOption[]; }; -export function useGraphLines({ selectedMeasures }: UseGraphLinesOptions) { +export function useGraphLines({ palette, selectedMeasures }: UseGraphLinesOptions) { const { resolvedLanguage, t } = useTranslation('common'); - const [lines, setLines] = useState([]); + const [theme] = useTheme(); + const mode = resolveTheme(theme); - useEffect(() => { + return useMemo(() => { const lines: LineGraphLine[] = []; - for (let i = 0; i < selectedMeasures.length; i++) { - const measure = selectedMeasures[i]!; + for (const [index, measure] of selectedMeasures.entries()) { + const stroke = getMeasureColor(index, mode, palette); lines.push({ name: measure.label, - stroke: COLOR_PALETTE[i], + stroke, val: measure.key }); lines.push({ legendType: 'none', name: `${measure.label} (${t('groupTrend')})`, - stroke: COLOR_PALETTE[i], + stroke, strokeDasharray: '5 5', strokeWidth: 0.5, val: measure.key + 'Group' }); } - setLines(lines); - }, [resolvedLanguage, selectedMeasures]); - - return lines; + return lines; + // Keyed on `resolvedLanguage` rather than `t`, which is a fresh closure every render and would + // make this memo recompute always. The language is what actually changes the trend label. + }, [mode, palette, resolvedLanguage, selectedMeasures]); } diff --git a/apps/web/src/hooks/useInstrumentHubFacets.ts b/apps/web/src/hooks/useInstrumentHubFacets.ts new file mode 100644 index 000000000..7fd8e655c --- /dev/null +++ b/apps/web/src/hooks/useInstrumentHubFacets.ts @@ -0,0 +1,64 @@ +import { useMemo } from 'react'; + +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { useParams } from '@tanstack/react-router'; + +import { useInstrumentInfoById } from '@/hooks/useInstrumentInfoById'; +import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; +import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; +import { useAppStore } from '@/store'; +import { getEditionOptions } from '@/utils/instrument-editions'; + +/** Stands for "collected outside any series" wherever a series id is expected */ +const NO_SERIES = ''; + +const INSTRUMENT_HUB_ROUTE = '/_app/datahub/instruments/$instrumentId'; + +/** + * What the instrument hub's header and filter controls need: the instrument's title, its sibling + * editions, and the series its records were collected under. + * + * Deliberately built from the instrument catalog and the record list rather than from + * `useInstrumentVisualization`, because that hook interprets the instrument's bundle — which runs + * the bundle as a module, and validates it in development. Calling it here as well as in the active + * tab would do that twice per page for a title and two dropdowns that need none of it. + */ +export function useInstrumentHubFacets() { + const { instrumentId } = useParams({ from: INSTRUMENT_HUB_ROUTE }); + const currentGroup = useAppStore((store) => store.currentGroup); + const { t } = useTranslation(); + + const infoQuery = useInstrumentInfoQuery({ params: { allEditions: true } }); + const recordsQuery = useInstrumentRecords({ params: { groupId: currentGroup?.id, instrumentId } }); + + const seriesInfoById = useInstrumentInfoById({ kind: 'SERIES' }); + + const editionLabel = t({ en: 'Edition', es: 'Edición', fr: 'Édition' }); + const editionOptions = useMemo( + () => getEditionOptions(infoQuery.data ?? [], instrumentId, editionLabel), + [infoQuery.data, instrumentId, editionLabel] + ); + + const info = useMemo( + () => infoQuery.data?.find((entry) => entry.id === instrumentId) ?? null, + [infoQuery.data, instrumentId] + ); + const title = info?.details.title ?? null; + // A series orchestrates records across the instruments it composes, so its page is a different + // shape: no shared measures, and therefore no measure columns and no chart. + const isSeries = info?.kind === 'SERIES'; + + /** The series actually present for this instrument, which is what the filter can offer */ + const seriesOptions = useMemo(() => { + const options = new Map(); + for (const record of recordsQuery.data ?? []) { + const seriesId = record.seriesInstrumentId ?? null; + options.set(seriesId ?? NO_SERIES, seriesId ? (seriesInfoById[seriesId]?.title ?? null) : null); + } + return options; + }, [recordsQuery.data, seriesInfoById]); + + return { editionOptions, isSeries, seriesOptions, title }; +} + +export { NO_SERIES }; diff --git a/apps/web/src/hooks/useInstrumentHubRecords.ts b/apps/web/src/hooks/useInstrumentHubRecords.ts new file mode 100644 index 000000000..6f9861b7f --- /dev/null +++ b/apps/web/src/hooks/useInstrumentHubRecords.ts @@ -0,0 +1,51 @@ +import { useCallback } from 'react'; + +import { useParams, useSearch } from '@tanstack/react-router'; + +import { NO_SERIES, useInstrumentHubFacets } from '@/hooks/useInstrumentHubFacets'; +import { useInstrumentVisualization } from '@/hooks/useInstrumentVisualization'; +import type { InstrumentVisualizationRecord } from '@/hooks/useInstrumentVisualization'; + +const INSTRUMENT_HUB_ROUTE = '/_app/datahub/instruments/$instrumentId'; + +/** + * Every record collected with the instrument named by the route, narrowed by the search params the + * parent route owns. + * + * Both tabs read through this, and the filter is handed to the visualization hook rather than + * applied afterwards, so the table, the chart and the download always describe the same set. + */ +export function useInstrumentHubRecords() { + const { instrumentId } = useParams({ from: INSTRUMENT_HUB_ROUTE }); + const { methods, minDate, series } = useSearch({ from: INSTRUMENT_HUB_ROUTE }); + + const filterRecord = useCallback( + (record: InstrumentVisualizationRecord) => { + // An absent `methods` filter means every method, which an empty array deliberately does not. + // A record whose session was deleted carries no method, so it matches no explicit selection — + // it survives the unfiltered view and is excluded as soon as the filter narrows. + if (methods && (!record.__method__ || !methods.includes(record.__method__))) { + return false; + } + // An absent `series` filter means every series, which is not an empty selection. + if (series && !series.includes(record.__seriesId__ ?? NO_SERIES)) { + return false; + } + return true; + }, + [methods, series] + ); + + const { isSeries } = useInstrumentHubFacets(); + + // A series is named through `seriesInstrumentId`; records never carry it as their `instrumentId`, + // so asking for it that way would always come back empty. + return useInstrumentVisualization({ + params: { + filterRecord, + instrumentId: isSeries ? undefined : instrumentId, + minDate, + seriesInstrumentId: isSeries ? instrumentId : undefined + } + }); +} diff --git a/apps/web/src/hooks/useInstrumentInfoById.ts b/apps/web/src/hooks/useInstrumentInfoById.ts new file mode 100644 index 000000000..57c05c701 --- /dev/null +++ b/apps/web/src/hooks/useInstrumentInfoById.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; + +import type { InstrumentKind } from '@opendatacapture/runtime-core'; + +import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; + +type InstrumentInfoById = { [id: string]: { edition: null | number; kind: InstrumentKind; title: string } }; + +/** + * The accessible instrument catalog keyed by id, narrowed by the same params the info query takes. + * + * A record names the instrument that collected it by id alone, so every view that shows records + * from more than one instrument — a series' member list, a series column, the collection-method + * facets — has to resolve those ids against the catalog. They differ only in which slice of it they + * ask for, which is why that is the parameter rather than three near-identical hooks. + */ +export function useInstrumentInfoById(params?: { + allEditions?: boolean; + kind?: TKind; + subjectId?: string; +}): InstrumentInfoById { + const query = useInstrumentInfoQuery({ params }); + return useMemo( + () => + Object.fromEntries( + (query.data ?? []).map((info) => [ + info.id, + { + edition: info.kind !== 'SERIES' ? info.internal.edition : null, + kind: info.kind, + title: info.details.title + } + ]) + ), + [query.data] + ); +} diff --git a/apps/web/src/hooks/useInstrumentKindLabels.ts b/apps/web/src/hooks/useInstrumentKindLabels.ts new file mode 100644 index 000000000..c1f0c0828 --- /dev/null +++ b/apps/web/src/hooks/useInstrumentKindLabels.ts @@ -0,0 +1,18 @@ +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { InstrumentKind } from '@opendatacapture/runtime-core'; + +/** + * Display labels for an instrument's kind, keyed by the stored value. + * + * Keyed over `InstrumentKind` rather than a loose record, so a kind added to the enum fails to + * compile here instead of reaching the screen as its raw stored value. + */ +export function useInstrumentKindLabels(): { [K in InstrumentKind]: string } { + const { t } = useTranslation(); + return { + FILE: t({ en: 'File', es: 'Archivo', fr: 'Fichier' }), + FORM: t({ en: 'Form', es: 'Formulario', fr: 'Formulaire' }), + INTERACTIVE: t({ en: 'Interactive', es: 'Interactivo', fr: 'Interactif' }), + SERIES: t({ en: 'Series', es: 'Serie', fr: 'Série' }) + }; +} diff --git a/apps/web/src/hooks/useInstrumentRecordSummaryQuery.ts b/apps/web/src/hooks/useInstrumentRecordSummaryQuery.ts new file mode 100644 index 000000000..753aa3a7c --- /dev/null +++ b/apps/web/src/hooks/useInstrumentRecordSummaryQuery.ts @@ -0,0 +1,28 @@ +import { $InstrumentRecordSummary } from '@opendatacapture/schemas/instrument-records'; +import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +type InstrumentRecordSummaryQueryParams = { + /** Count what each series orchestrated instead of what each scalar instrument collected */ + bySeries?: boolean; + groupId?: string; +}; + +export const instrumentRecordSummaryQueryOptions = ({ + params +}: { params?: InstrumentRecordSummaryQueryParams } = {}) => { + return queryOptions({ + queryFn: async () => { + const path = params?.bySeries ? 'by-series' : 'by-instrument'; + const response = await axios.get(`/v1/instrument-records/summary/${path}`, { + params: { groupId: params?.groupId } + }); + return $InstrumentRecordSummary.array().parse(response.data); + }, + queryKey: ['instrument-record-summary', params?.groupId, params?.bySeries ?? false] + }); +}; + +export function useInstrumentRecordSummaryQuery({ params }: { params?: InstrumentRecordSummaryQueryParams } = {}) { + return useSuspenseQuery(instrumentRecordSummaryQueryOptions({ params })); +} diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 212bdf177..7c345a61f 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -3,29 +3,70 @@ import { useEffect, useMemo, useState } from 'react'; import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import { useDownload, useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { AnyUnilingualScalarInstrument, InstrumentKind } from '@opendatacapture/runtime-core'; +import { DEFAULT_GROUP_NAME } from '@opendatacapture/schemas/core'; import type { TranslatedInstrumentInfo } from '@opendatacapture/schemas/instrument'; +import type { SessionType } from '@opendatacapture/schemas/session'; import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; import { mapValues, omit } from 'lodash-es'; import { unparse } from 'papaparse'; import { useInstrument } from '@/hooks/useInstrument'; +import { useInstrumentInfoById } from '@/hooks/useInstrumentInfoById'; import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; +import { getEditionOptions } from '@/utils/instrument-editions'; import { formatRecordValue } from '@/utils/record-value'; type InstrumentVisualizationRecord = { [key: string]: unknown; __date__: Date; __id__: string; + /** The scalar instrument actually administered, which varies across a series */ + __instrumentId__: string; + /** The data collection method, from the session that collected the record */ + __method__: null | SessionType; + /** The id of the series that collected the record, or null if it was collected individually */ + __seriesId__: null | string; + /** That series' title, resolved for display; null if collected individually */ + __seriesName__: null | string; + __subjectId__: string; __time__: number; }; +/** The record fields that become fixed metadata columns rather than measure columns */ +const EXPORT_METADATA_KEYS = [ + '__date__', + '__id__', + '__instrumentId__', + '__method__', + '__seriesId__', + '__seriesName__', + '__subjectId__', + '__time__', + 'username' +] as const; + type UseInstrumentVisualizationOptions = { params: { + /** + * Narrows the records every consumer sees, the export included — which is what keeps a filtered + * table and its download describing the same set. Must be referentially stable. + */ + filterRecord?: (record: InstrumentVisualizationRecord) => boolean; + /** Fixes the instrument rather than leaving it to be chosen, for the per-instrument view */ + instrumentId?: string; kind?: InstrumentKind; - subjectId: string; + /** Drives the earliest collection date externally, for a view holding it in the URL */ + minDate?: Date; + /** + * Every record a series orchestrated, instead of one instrument's. The records then span several + * scalar instruments, so there is no single instrument to interpret and no coherent wide shape. + */ + seriesInstrumentId?: string; + /** Omitted by the per-instrument view, which spans every subject */ + subjectId?: string; }; }; @@ -37,28 +78,50 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio const notifications = useNotificationsStore(); const { t } = useTranslation('common'); - const [records, setRecords] = useState([]); - const [minDate, setMinDate] = useState(null); - const [instrumentId, setInstrumentId] = useState(null); + const [allRecords, setRecords] = useState([]); + const [selectedMinDate, setMinDate] = useState(null); + const [instrumentId, setInstrumentId] = useState(params.instrumentId ?? null); + + // A caller holding the date in the URL owns it outright; everyone else drives it with setMinDate. + const minDate = params.minDate ?? selectedMinDate; + + const filterRecord = params.filterRecord; + const records = useMemo( + () => (filterRecord ? allRecords.filter(filterRecord) : allRecords), + [allRecords, filterRecord] + ); + + // The per-instrument view takes its instrument from the route, so a navigation between editions + // changes it underneath this hook rather than through setInstrumentId. + useEffect(() => { + if (params.instrumentId) { + setInstrumentId(params.instrumentId); + } + }, [params.instrumentId]); const instrument = useInstrument(instrumentId) as AnyUnilingualScalarInstrument; + const seriesInfoById = useInstrumentInfoById({ kind: 'SERIES' }); + // Every edition, because a record names the exact one administered — and across a series the + // export spans several instruments, so each row has to say which produced it. + const instrumentInfoById = useInstrumentInfoById({ allEditions: true }); const instrumentInfoQuery = useInstrumentInfoQuery({ params: { allEditions: true, kind: params.kind, subjectId: params.subjectId } }); const recordsQuery = useInstrumentRecords({ - enabled: instrumentId !== null, + enabled: instrumentId !== null || params.seriesInstrumentId !== undefined, params: { groupId: currentGroup?.id, - instrumentId: instrumentId!, + instrumentId: params.seriesInstrumentId ? undefined : instrumentId!, kind: params.kind, minDate: minDate ?? undefined, + seriesInstrumentId: params.seriesInstrumentId, subjectId: params.subjectId } }); const dl = (option: 'CSV' | 'CSV Long' | 'Excel' | 'Excel Long' | 'JSON' | 'TSV' | 'TSV Long') => { - if (!instrument) { + if (!instrument && !params.seriesInstrumentId) { notifications.addNotification({ message: t('errors.noInstrumentSelected'), type: 'error' }); return; } else if (records.length === 0) { @@ -66,86 +129,97 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio return; } - const baseFilename = `${currentUser!.username}_${instrument.internal.name}_${ - instrument.internal.edition - }_${new Date().toISOString()}`; + const instrumentName = instrument ? `${instrument.internal.name}_${instrument.internal.edition}` : 'series'; + const baseFilename = `${currentUser!.username}_${instrumentName}_${new Date().toISOString()}`; - const exportRecords = records.map((record) => - mapValues(omit(record, ['__time__', '__id__']), (value) => + // One subject's worth of records is named for the subject; an instrument's whole set, which + // spans subjects, is named for the instrument. + const sheetName = params.subjectId ? removeSubjectIdScope(params.subjectId) : instrumentName; + + // Split each record into the metadata every exported row repeats and the measures that vary by + // row, so the long format cannot mistake a metadata field for a measure. + const exportRecords = records.map((record) => ({ + measures: mapValues(omit(record, EXPORT_METADATA_KEYS), (value) => value instanceof Set ? formatRecordValue(value) : value - ) - ); + ), + meta: { + collectionMethod: record.__method__, + date: toBasicISOString(record.__date__), + groupId: currentGroup ? currentGroup.id : DEFAULT_GROUP_NAME, + instrumentEdition: instrumentInfoById[record.__instrumentId__]?.edition ?? null, + // Falls back to the id so a row is never anonymous, even if the catalog no longer lists it. + instrumentName: instrumentInfoById[record.__instrumentId__]?.title ?? record.__instrumentId__, + seriesId: record.__seriesId__, + seriesName: record.__seriesName__, + subjectId: removeSubjectIdScope(record.__subjectId__), + username: (record.username as null | string) ?? 'N/A' + } + })); + + /** Built imperatively because the column order is the header order, which sorting would discard. */ + const withMetadataColumns = (meta: (typeof exportRecords)[number]['meta']) => { + const row: { [key: string]: any } = {}; + row.GroupID = meta.groupId; + row.SubjectID = meta.subjectId; + row.InstrumentName = meta.instrumentName; + row.InstrumentEdition = meta.instrumentEdition; + row.Date = meta.date; + row.CollectionMethod = meta.collectionMethod; + row.SeriesID = meta.seriesId; + row.SeriesName = meta.seriesName; + row.Username = meta.username; + return row; + }; + + /** + * Every measure any record carries, so a set spanning several instruments keeps all of their + * fields. Each row is given the whole key set because papaparse takes its header from the first + * row alone — without this, a series export silently dropped every column the first record's + * instrument happened not to measure. + */ + const allMeasureKeys = Array.from(new Set(exportRecords.flatMap(({ measures }) => Object.keys(measures)))); const makeWideRows = () => { - const columnNames = Object.keys(exportRecords[0]!); - return exportRecords.map((item) => { - const obj: { [key: string]: any } = { - GroupID: currentGroup ? currentGroup.id : 'root', - subjectId: removeSubjectIdScope(params.subjectId) - }; - for (const key of columnNames) { - const val = item[key]; - if (key === '__date__') { - obj.Date = toBasicISOString(val as Date); - continue; - } - if (key === 'username') { - obj.Username = val; - continue; - } - obj[key] = typeof val === 'object' ? JSON.stringify(val) : val; + return exportRecords.map(({ measures, meta }) => { + const row = withMetadataColumns(meta); + for (const key of allMeasureKeys) { + const value = measures[key]; + // An instrument that does not measure this field leaves the cell empty rather than + // claiming a value it never collected. + row[key] = value === undefined ? '' : typeof value === 'object' ? JSON.stringify(value) : value; } - return obj; + return row; }); }; - const makeLongRows = () => { - const longRecord: { [key: string]: any }[] = []; - - exportRecords.forEach((item) => { - let date: Date; - let username = 'N/A'; + /** A measure holding an array of objects contributes one row per nested key. */ + const expandMeasure = (measureKey: string, measureValue: unknown): { value: unknown; variable: string }[] => { + if (!Array.isArray(measureValue)) { + return [{ value: measureValue, variable: measureKey }]; + } + return measureValue.flatMap((arrayItem) => + Object.entries(arrayItem as object).map(([arrayKey, arrayValue]) => ({ + value: arrayValue as unknown, + variable: `${measureKey}-${arrayKey}` + })) + ); + }; - Object.entries(item).forEach(([objKey, objVal]) => { - if (objKey === '__date__') { - date = objVal as Date; - return; - } - if (objKey === 'username') { - username = objVal as string; - return; - } + const makeLongRows = () => { + const longRecords: { [key: string]: any }[] = []; - if (Array.isArray(objVal)) { - objVal.forEach((arrayItem) => { - Object.entries(arrayItem as object).forEach(([arrKey, arrItem]) => { - longRecord.push({ - GroupID: currentGroup ? currentGroup.id : 'root', - // eslint-disable-next-line perfectionist/sort-objects - Date: toBasicISOString(date), - SubjectID: removeSubjectIdScope(params.subjectId), - Username: username, - Variable: `${objKey}-${arrKey}`, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, perfectionist/sort-objects - Value: arrItem - }); - }); - }); - } else { - longRecord.push({ - GroupID: currentGroup ? currentGroup.id : 'root', - // eslint-disable-next-line perfectionist/sort-objects - Date: toBasicISOString(date), - SubjectID: removeSubjectIdScope(params.subjectId), - Username: username, - Value: objVal, - Variable: objKey - }); + for (const { measures, meta } of exportRecords) { + for (const [measureKey, measureValue] of Object.entries(measures)) { + for (const { value, variable } of expandMeasure(measureKey, measureValue)) { + const row = withMetadataColumns(meta); + row.Variable = variable; + row.Value = value; + longRecords.push(row); } - }); - }); + } + } - return longRecord; + return longRecords; }; const parseHelper = (rows: unknown[], delimiter: string) => { @@ -178,19 +252,17 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio } case 'Excel': { const rows = makeWideRows(); - downloadSubjectTableExcel(`${baseFilename}.xlsx`, rows, removeSubjectIdScope(params.subjectId)); + downloadSubjectTableExcel(`${baseFilename}.xlsx`, rows, sheetName); break; } case 'Excel Long': { const rows = makeLongRows(); - downloadSubjectTableExcel(`${baseFilename}.xlsx`, rows, removeSubjectIdScope(params.subjectId)); + downloadSubjectTableExcel(`${baseFilename}.xlsx`, rows, sheetName); break; } case 'JSON': { - exportRecords.map((item) => { - item.subjectID = params.subjectId; - }); - void download(`${baseFilename}.json`, () => Promise.resolve(JSON.stringify(exportRecords, null, 2))); + const rows = makeWideRows(); + void download(`${baseFilename}.json`, () => Promise.resolve(JSON.stringify(rows, null, 2))); break; } case 'TSV': @@ -217,10 +289,16 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio if (recordsQuery.data) { const records: InstrumentVisualizationRecord[] = recordsQuery.data.map((record) => { const props = record.data && typeof record.data === 'object' ? record.data : {}; + const seriesId = record.seriesInstrumentId ?? null; return { __date__: record.date, __id__: record.id, + __instrumentId__: record.instrumentId, + __method__: record.session?.type ?? null, + __seriesId__: seriesId, + __seriesName__: seriesId ? (seriesInfoById[seriesId]?.title ?? null) : null, + __subjectId__: record.subjectId, __time__: record.date.getTime(), username: record.session?.user?.username ?? 'N/A', ...record.computedMeasures, @@ -241,7 +319,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio type: 'error' }); } - }, [recordsQuery.data]); + }, [recordsQuery.data, seriesInfoById]); const instrumentOptions: { [key: string]: string } = useMemo(() => { // only show the latest edition of each instrument; older editions are selectable via editionOptions @@ -262,24 +340,15 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio return options; }, [instrumentInfoQuery.data]); - const editionOptions: { [key: string]: string } = useMemo(() => { - const infos = instrumentInfoQuery.data ?? []; - const selected = infos.find((info) => info.id === instrumentId); - if (!selected || selected.kind === 'SERIES') { - return {}; - } - const selectedName = selected.internal.name; - const options: { [key: string]: string } = {}; - infos - .filter((info): info is typeof selected => info.kind !== 'SERIES' && info.internal.name === selectedName) - .sort((a, b) => a.internal.edition - b.internal.edition) - .forEach((info) => { - options[info.id] = `${t({ en: 'Edition', es: 'Edición', fr: 'Édition' })} ${info.internal.edition}`; - }); - return options; - }, [instrumentInfoQuery.data, instrumentId]); + const editionLabel = t({ en: 'Edition', es: 'Edición', fr: 'Édition' }); + const editionOptions = useMemo( + () => getEditionOptions(instrumentInfoQuery.data ?? [], instrumentId, editionLabel), + [instrumentInfoQuery.data, instrumentId, editionLabel] + ); return { + /** Every record for the instrument, before `filterRecord` — what a filter's own options come from */ + allRecords, dl, editionOptions, instrument, diff --git a/apps/web/src/hooks/useMeasureOptions.ts b/apps/web/src/hooks/useMeasureOptions.ts index c7d0fa919..8194dfb5f 100644 --- a/apps/web/src/hooks/useMeasureOptions.ts +++ b/apps/web/src/hooks/useMeasureOptions.ts @@ -2,14 +2,23 @@ import { useMemo } from 'react'; import type { ListboxDropdownOption } from '@douglasneuroinformatics/libui/components'; import { getFormFields } from '@opendatacapture/instrument-utils'; -import type { AnyUnilingualFormInstrument } from '@opendatacapture/runtime-core'; +import type { AnyUnilingualScalarInstrument, FormInstrument } from '@opendatacapture/runtime-core'; import { match } from 'ts-pattern'; -export function useMeasureOptions(instrument: AnyUnilingualFormInstrument | null): ListboxDropdownOption[] { +/** + * The measures of any scalar instrument, as dropdown options. + * + * A `const` measure may omit its own label and borrow the label of the form field it references, + * which is only possible for a form — every other kind has no fields to borrow from, so its + * measures fall back to the reference and then to the key. + */ +export function useMeasureOptions(instrument: AnyUnilingualScalarInstrument | null): ListboxDropdownOption[] { return useMemo(() => { const arr: ListboxDropdownOption[] = []; if (instrument) { - const formFields = getFormFields(instrument.content); + // The type argument is explicit because `content` is typed `any` on the scalar union, which + // leaves `TData` to collapse and `Fields` to resolve against the wrong shape. + const formFields = instrument.kind === 'FORM' ? getFormFields(instrument.content) : null; for (const key in instrument.measures) { const label = match(instrument.measures[key]!) .with({ kind: 'computed' }, (measure) => measure.label) @@ -17,6 +26,11 @@ export function useMeasureOptions(instrument: AnyUnilingualFormInstrument | null if (measure.label) { return measure.label; } + // Only a form has fields to borrow a label from; everything else falls back to the + // measure's own key, which is also what the record table's column header shows. + if (!formFields) { + return key; + } const field = formFields[key]!; if (field.kind === 'dynamic') { return field.render({})?.label; diff --git a/apps/web/src/hooks/useNavItems.ts b/apps/web/src/hooks/useNavItems.ts index 143eecac2..2b2da3062 100644 --- a/apps/web/src/hooks/useNavItems.ts +++ b/apps/web/src/hooks/useNavItems.ts @@ -70,8 +70,8 @@ export function useNavItems() { url: '/dashboard' }); } - // The datahub becomes a group so its views can be read along different axes. Only the subject - // view exists today; the instrument one lands next and joins this group beside it. + // The two halves of the datahub read the same records along different axes, and are gated + // independently: holding only one must still surface a working menu. const datahubItems: NavItem[] = []; if (ability?.can('read', 'Subject') && ability.can('read', 'InstrumentRecord')) { datahubItems.push({ @@ -79,6 +79,13 @@ export function useNavItems() { label: t({ en: 'Subjects', es: 'Sujetos', fr: 'Clients' }), url: '/datahub/subjects' }); + if (ability.can('read', 'Instrument')) { + datahubItems.push({ + icon: ClipboardListIcon, + label: t({ en: 'Instruments', es: 'Instrumentos', fr: 'Instruments' }), + url: '/datahub/instruments' + }); + } } if (datahubItems.length > 0) { globalItems.push({ diff --git a/apps/web/src/hooks/useRecordChartSeries.ts b/apps/web/src/hooks/useRecordChartSeries.ts new file mode 100644 index 000000000..ae009d526 --- /dev/null +++ b/apps/web/src/hooks/useRecordChartSeries.ts @@ -0,0 +1,130 @@ +import { useMemo } from 'react'; + +import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; + +import { useCollectionMethodLabels } from '@/hooks/useCollectionMethodLabels'; +import type { InstrumentVisualizationRecord } from '@/hooks/useInstrumentVisualization'; +import { getCategoricalColor, MAX_CATEGORICAL_GROUPS, NEUTRAL_MARK, OTHER_GROUP_KEY } from '@/utils/chart-palette'; +import type { ChartPaletteName } from '@/utils/chart-palette'; + +/** The grouping dimensions a chart may spend colour on. Source of truth for the search param. */ +const COLOUR_BY_OPTIONS = ['none', 'method', 'series'] as const; + +type ColourBy = (typeof COLOUR_BY_OPTIONS)[number]; + +type RecordChartPoint = { + time: number; + value: number | string; +}; + +type RecordChartSeries = { + color: string; + key: string; + label: string; + points: RecordChartPoint[]; +}; + +type UseRecordChartSeriesOptions = { + colourBy: ColourBy; + measure: string | undefined; + palette: ChartPaletteName; + records: InstrumentVisualizationRecord[]; +}; + +/** + * Splits records into one coloured series per value of the grouping dimension, carrying the + * selected measure. + * + * Groups are ordered by size and the tail beyond the palette's three slots folds into one "Other" + * series, so a colour always means the same group no matter how many exist, and a grouping with + * many values degrades rather than inventing hues. + */ +export function useRecordChartSeries({ colourBy, measure, palette, records }: UseRecordChartSeriesOptions): { + isNumeric: boolean; + series: RecordChartSeries[]; +} { + const [theme] = useTheme(); + const { t } = useTranslation(); + const collectionMethodLabels = useCollectionMethodLabels(); + + const unlabelled = t({ en: 'Unknown', es: 'Desconocido', fr: 'Inconnu' }); + const individual = t({ en: 'Individual', es: 'Individual', fr: 'Individuel' }); + const ungrouped = t({ en: 'Records', es: 'Registros', fr: 'Enregistrements' }); + const other = t({ en: 'Other', es: 'Otros', fr: 'Autres' }); + + return useMemo(() => { + const resolvedTheme = theme === 'dark' ? 'dark' : 'light'; + if (!measure) { + return { isNumeric: true, series: [] }; + } + + const groupOf = (record: InstrumentVisualizationRecord): { key: string; label: string } => { + switch (colourBy) { + case 'method': + return record.__method__ + ? { key: record.__method__, label: collectionMethodLabels[record.__method__] } + : { key: 'UNKNOWN', label: unlabelled }; + case 'none': + return { key: 'ALL', label: ungrouped }; + case 'series': + return record.__seriesId__ + ? { key: record.__seriesId__, label: record.__seriesName__ ?? record.__seriesId__ } + : { key: 'NONE', label: individual }; + } + }; + + let isNumeric = true; + const groups = new Map(); + for (const record of records) { + const value = record[measure]; + if (value === null || value === undefined) { + continue; + } + if (typeof value !== 'number') { + isNumeric = false; + } + if (typeof value !== 'number' && typeof value !== 'string') { + continue; + } + const { key, label } = groupOf(record); + let group = groups.get(key); + if (!group) { + group = { label, points: [] }; + groups.set(key, group); + } + group.points.push({ time: record.__time__, value }); + } + + const ranked = Array.from(groups, ([key, group]) => ({ key, ...group })).sort( + (a, b) => b.points.length - a.points.length + ); + + // Ungrouped, every record is one series, which takes the chosen theme's first slot — so the + // palette control still does something without a grouping. Grey is left to mean "not a real + // group", which here is only the folded tail below. + if (colourBy === 'none') { + return { + isNumeric, + series: ranked.map((group) => ({ ...group, color: getCategoricalColor(0, resolvedTheme, palette) })) + }; + } + + const series: RecordChartSeries[] = ranked.slice(0, MAX_CATEGORICAL_GROUPS).map((group, index) => ({ + ...group, + color: getCategoricalColor(index, resolvedTheme, palette) + })); + const tail = ranked.slice(MAX_CATEGORICAL_GROUPS); + if (tail.length > 0) { + series.push({ + color: NEUTRAL_MARK[resolvedTheme], + key: OTHER_GROUP_KEY, + label: `${other} (${tail.length})`, + points: tail.flatMap((group) => group.points) + }); + } + return { isNumeric, series }; + }, [colourBy, collectionMethodLabels, individual, measure, other, palette, records, theme, ungrouped, unlabelled]); +} + +export { COLOUR_BY_OPTIONS }; +export type { ColourBy, RecordChartPoint, RecordChartSeries }; diff --git a/apps/web/src/hooks/useRecordMetadataColumns.tsx b/apps/web/src/hooks/useRecordMetadataColumns.tsx new file mode 100644 index 000000000..6ea96eab1 --- /dev/null +++ b/apps/web/src/hooks/useRecordMetadataColumns.tsx @@ -0,0 +1,60 @@ +import { useMemo } from 'react'; + +import { TanstackTable } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { SessionType } from '@opendatacapture/schemas/session'; + +import { SortableHeader } from '@/components/SortableHeader'; +import { TruncatedCell } from '@/components/TruncatedCell'; +import { useCollectionMethodLabels } from '@/hooks/useCollectionMethodLabels'; +import { NO_SERIES } from '@/hooks/useInstrumentHubFacets'; +import type { InstrumentVisualizationRecord } from '@/hooks/useInstrumentVisualization'; + +/** + * The provenance columns every record table shows: how the record was collected, and the series it + * was collected under, if any. A series cell holding a name is itself the answer to whether the + * record was collected individually, which is why there is no separate column for that. + */ +export function useRecordMetadataColumns({ + omitSeries = false +}: { + /** Set on a series' own page, where every row names that same series and the column says nothing */ + omitSeries?: boolean; +} = {}): TanstackTable.ColumnDef[] { + const collectionMethodLabels = useCollectionMethodLabels(); + const { t } = useTranslation(); + const noneLabel = t({ en: 'None', es: 'Ninguno', fr: 'Aucun' }); + return useMemo(() => { + const columns: TanstackTable.ColumnDef[] = [ + { + accessorKey: '__method__', + cell: (ctx) => { + const value = ctx.getValue() as null | SessionType; + const label = value ? collectionMethodLabels[value] : noneLabel; + return ; + }, + filterFn: (row, id, filter: SessionType[]) => filter.includes(row.getValue(id)), + header: ({ column }) => , + id: '__method__' + } + ]; + if (omitSeries) { + return columns; + } + columns.push({ + accessorKey: '__seriesName__', + cell: (ctx) => { + // A series title is the longest thing in the row and the first to wrap, which is what + // drove this column to one clipped line. + const label = (ctx.getValue() as null | string) ?? noneLabel; + return ; + }, + // Filtered on the id rather than the displayed name, since a name is not a stable identity. + // The empty string stands for a record collected outside any series. + filterFn: (row, _id, filter: string[]) => filter.includes(row.original.__seriesId__ ?? NO_SERIES), + header: ({ column }) => , + id: '__seriesName__' + }); + return columns; + }, [collectionMethodLabels, noneLabel, omitSeries]); +} diff --git a/apps/web/src/route-tree.ts b/apps/web/src/route-tree.ts index 8500b2d8b..f30e021b1 100644 --- a/apps/web/src/route-tree.ts +++ b/apps/web/src/route-tree.ts @@ -39,9 +39,14 @@ import { Route as AppAdminInstrumentReposIndexRouteImport } from './routes/_app/ import { Route as AppAdminUsersIndexRouteImport } from './routes/_app/admin/users/index' import { Route as AppAdminUsersUserIdRouteImport } from './routes/_app/admin/users/$userId' import { Route as AppAdminUsersCreateRouteImport } from './routes/_app/admin/users/create' +import { Route as AppDatahubInstrumentsIndexRouteImport } from './routes/_app/datahub/instruments/index' +import { Route as AppDatahubInstrumentsInstrumentIdRouteRouteImport } from './routes/_app/datahub/instruments/$instrumentId/route' import { Route as AppDatahubSubjectsIndexRouteImport } from './routes/_app/datahub/subjects/index' import { Route as AppDatahubSubjectsSubjectIdRouteRouteImport } from './routes/_app/datahub/subjects/$subjectId/route' import { Route as AppInstrumentsRenderIdRouteImport } from './routes/_app/instruments/render/$id' +import { Route as AppDatahubInstrumentsInstrumentIdIndexRouteImport } from './routes/_app/datahub/instruments/$instrumentId/index' +import { Route as AppDatahubInstrumentsInstrumentIdGraphRouteImport } from './routes/_app/datahub/instruments/$instrumentId/graph' +import { Route as AppDatahubInstrumentsInstrumentIdTableRouteImport } from './routes/_app/datahub/instruments/$instrumentId/table' import { Route as AppDatahubSubjectsSubjectIdAssignmentsRouteImport } from './routes/_app/datahub/subjects/$subjectId/assignments' import { Route as AppDatahubSubjectsSubjectIdGraphRouteImport } from './routes/_app/datahub/subjects/$subjectId/graph' import { Route as AppDatahubSubjectsSubjectIdTableIndexRouteImport } from './routes/_app/datahub/subjects/$subjectId/table/index' @@ -201,6 +206,18 @@ const AppAdminUsersCreateRoute = AppAdminUsersCreateRouteImport.update({ path: '/admin/users/create', getParentRoute: () => AppRouteRoute, } as any) +const AppDatahubInstrumentsIndexRoute = + AppDatahubInstrumentsIndexRouteImport.update({ + id: '/datahub/instruments/', + path: '/datahub/instruments/', + getParentRoute: () => AppRouteRoute, + } as any) +const AppDatahubInstrumentsInstrumentIdRouteRoute = + AppDatahubInstrumentsInstrumentIdRouteRouteImport.update({ + id: '/datahub/instruments/$instrumentId', + path: '/datahub/instruments/$instrumentId', + getParentRoute: () => AppRouteRoute, + } as any) const AppDatahubSubjectsIndexRoute = AppDatahubSubjectsIndexRouteImport.update({ id: '/datahub/subjects/', path: '/datahub/subjects/', @@ -217,6 +234,24 @@ const AppInstrumentsRenderIdRoute = AppInstrumentsRenderIdRouteImport.update({ path: '/instruments/render/$id', getParentRoute: () => AppRouteRoute, } as any) +const AppDatahubInstrumentsInstrumentIdIndexRoute = + AppDatahubInstrumentsInstrumentIdIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AppDatahubInstrumentsInstrumentIdRouteRoute, + } as any) +const AppDatahubInstrumentsInstrumentIdGraphRoute = + AppDatahubInstrumentsInstrumentIdGraphRouteImport.update({ + id: '/graph', + path: '/graph', + getParentRoute: () => AppDatahubInstrumentsInstrumentIdRouteRoute, + } as any) +const AppDatahubInstrumentsInstrumentIdTableRoute = + AppDatahubInstrumentsInstrumentIdTableRouteImport.update({ + id: '/table', + path: '/table', + getParentRoute: () => AppDatahubInstrumentsInstrumentIdRouteRoute, + } as any) const AppDatahubSubjectsSubjectIdAssignmentsRoute = AppDatahubSubjectsSubjectIdAssignmentsRouteImport.update({ id: '/assignments', @@ -263,6 +298,7 @@ export interface FileRoutesByFullPath { '/upload/$instrumentId': typeof AppUploadInstrumentIdRoute '/datahub/': typeof AppDatahubIndexRoute '/upload/': typeof AppUploadIndexRoute + '/datahub/instruments/$instrumentId': typeof AppDatahubInstrumentsInstrumentIdRouteRouteWithChildren '/datahub/subjects/$subjectId': typeof AppDatahubSubjectsSubjectIdRouteRouteWithChildren '/admin/audit/logs': typeof AppAdminAuditLogsRoute '/admin/branding/login-page': typeof AppAdminBrandingLoginPageRoute @@ -274,9 +310,13 @@ export interface FileRoutesByFullPath { '/admin/groups/': typeof AppAdminGroupsIndexRoute '/admin/instrument-repos/': typeof AppAdminInstrumentReposIndexRoute '/admin/users/': typeof AppAdminUsersIndexRoute + '/datahub/instruments/': typeof AppDatahubInstrumentsIndexRoute '/datahub/subjects/': typeof AppDatahubSubjectsIndexRoute + '/datahub/instruments/$instrumentId/graph': typeof AppDatahubInstrumentsInstrumentIdGraphRoute + '/datahub/instruments/$instrumentId/table': typeof AppDatahubInstrumentsInstrumentIdTableRoute '/datahub/subjects/$subjectId/assignments': typeof AppDatahubSubjectsSubjectIdAssignmentsRoute '/datahub/subjects/$subjectId/graph': typeof AppDatahubSubjectsSubjectIdGraphRoute + '/datahub/instruments/$instrumentId/': typeof AppDatahubInstrumentsInstrumentIdIndexRoute '/datahub/subjects/$subjectId/table/$recordId': typeof AppDatahubSubjectsSubjectIdTableRecordIdRoute '/datahub/subjects/$subjectId/table/': typeof AppDatahubSubjectsSubjectIdTableIndexRoute } @@ -312,9 +352,13 @@ export interface FileRoutesByTo { '/admin/groups': typeof AppAdminGroupsIndexRoute '/admin/instrument-repos': typeof AppAdminInstrumentReposIndexRoute '/admin/users': typeof AppAdminUsersIndexRoute + '/datahub/instruments': typeof AppDatahubInstrumentsIndexRoute '/datahub/subjects': typeof AppDatahubSubjectsIndexRoute + '/datahub/instruments/$instrumentId/graph': typeof AppDatahubInstrumentsInstrumentIdGraphRoute + '/datahub/instruments/$instrumentId/table': typeof AppDatahubInstrumentsInstrumentIdTableRoute '/datahub/subjects/$subjectId/assignments': typeof AppDatahubSubjectsSubjectIdAssignmentsRoute '/datahub/subjects/$subjectId/graph': typeof AppDatahubSubjectsSubjectIdGraphRoute + '/datahub/instruments/$instrumentId': typeof AppDatahubInstrumentsInstrumentIdIndexRoute '/datahub/subjects/$subjectId/table/$recordId': typeof AppDatahubSubjectsSubjectIdTableRecordIdRoute '/datahub/subjects/$subjectId/table': typeof AppDatahubSubjectsSubjectIdTableIndexRoute } @@ -341,6 +385,7 @@ export interface FileRoutesById { '/_app/upload/$instrumentId': typeof AppUploadInstrumentIdRoute '/_app/datahub/': typeof AppDatahubIndexRoute '/_app/upload/': typeof AppUploadIndexRoute + '/_app/datahub/instruments/$instrumentId': typeof AppDatahubInstrumentsInstrumentIdRouteRouteWithChildren '/_app/datahub/subjects/$subjectId': typeof AppDatahubSubjectsSubjectIdRouteRouteWithChildren '/_app/admin/audit/logs': typeof AppAdminAuditLogsRoute '/_app/admin/branding/login-page': typeof AppAdminBrandingLoginPageRoute @@ -352,9 +397,13 @@ export interface FileRoutesById { '/_app/admin/groups/': typeof AppAdminGroupsIndexRoute '/_app/admin/instrument-repos/': typeof AppAdminInstrumentReposIndexRoute '/_app/admin/users/': typeof AppAdminUsersIndexRoute + '/_app/datahub/instruments/': typeof AppDatahubInstrumentsIndexRoute '/_app/datahub/subjects/': typeof AppDatahubSubjectsIndexRoute + '/_app/datahub/instruments/$instrumentId/graph': typeof AppDatahubInstrumentsInstrumentIdGraphRoute + '/_app/datahub/instruments/$instrumentId/table': typeof AppDatahubInstrumentsInstrumentIdTableRoute '/_app/datahub/subjects/$subjectId/assignments': typeof AppDatahubSubjectsSubjectIdAssignmentsRoute '/_app/datahub/subjects/$subjectId/graph': typeof AppDatahubSubjectsSubjectIdGraphRoute + '/_app/datahub/instruments/$instrumentId/': typeof AppDatahubInstrumentsInstrumentIdIndexRoute '/_app/datahub/subjects/$subjectId/table/$recordId': typeof AppDatahubSubjectsSubjectIdTableRecordIdRoute '/_app/datahub/subjects/$subjectId/table/': typeof AppDatahubSubjectsSubjectIdTableIndexRoute } @@ -381,6 +430,7 @@ export interface FileRouteTypes { | '/upload/$instrumentId' | '/datahub/' | '/upload/' + | '/datahub/instruments/$instrumentId' | '/datahub/subjects/$subjectId' | '/admin/audit/logs' | '/admin/branding/login-page' @@ -392,9 +442,13 @@ export interface FileRouteTypes { | '/admin/groups/' | '/admin/instrument-repos/' | '/admin/users/' + | '/datahub/instruments/' | '/datahub/subjects/' + | '/datahub/instruments/$instrumentId/graph' + | '/datahub/instruments/$instrumentId/table' | '/datahub/subjects/$subjectId/assignments' | '/datahub/subjects/$subjectId/graph' + | '/datahub/instruments/$instrumentId/' | '/datahub/subjects/$subjectId/table/$recordId' | '/datahub/subjects/$subjectId/table/' fileRoutesByTo: FileRoutesByTo @@ -430,9 +484,13 @@ export interface FileRouteTypes { | '/admin/groups' | '/admin/instrument-repos' | '/admin/users' + | '/datahub/instruments' | '/datahub/subjects' + | '/datahub/instruments/$instrumentId/graph' + | '/datahub/instruments/$instrumentId/table' | '/datahub/subjects/$subjectId/assignments' | '/datahub/subjects/$subjectId/graph' + | '/datahub/instruments/$instrumentId' | '/datahub/subjects/$subjectId/table/$recordId' | '/datahub/subjects/$subjectId/table' id: @@ -458,6 +516,7 @@ export interface FileRouteTypes { | '/_app/upload/$instrumentId' | '/_app/datahub/' | '/_app/upload/' + | '/_app/datahub/instruments/$instrumentId' | '/_app/datahub/subjects/$subjectId' | '/_app/admin/audit/logs' | '/_app/admin/branding/login-page' @@ -469,9 +528,13 @@ export interface FileRouteTypes { | '/_app/admin/groups/' | '/_app/admin/instrument-repos/' | '/_app/admin/users/' + | '/_app/datahub/instruments/' | '/_app/datahub/subjects/' + | '/_app/datahub/instruments/$instrumentId/graph' + | '/_app/datahub/instruments/$instrumentId/table' | '/_app/datahub/subjects/$subjectId/assignments' | '/_app/datahub/subjects/$subjectId/graph' + | '/_app/datahub/instruments/$instrumentId/' | '/_app/datahub/subjects/$subjectId/table/$recordId' | '/_app/datahub/subjects/$subjectId/table/' fileRoutesById: FileRoutesById @@ -695,6 +758,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppAdminUsersCreateRouteImport parentRoute: typeof AppRouteRoute } + '/_app/datahub/instruments/': { + id: '/_app/datahub/instruments/' + path: '/datahub/instruments' + fullPath: '/datahub/instruments/' + preLoaderRoute: typeof AppDatahubInstrumentsIndexRouteImport + parentRoute: typeof AppRouteRoute + } + '/_app/datahub/instruments/$instrumentId': { + id: '/_app/datahub/instruments/$instrumentId' + path: '/datahub/instruments/$instrumentId' + fullPath: '/datahub/instruments/$instrumentId' + preLoaderRoute: typeof AppDatahubInstrumentsInstrumentIdRouteRouteImport + parentRoute: typeof AppRouteRoute + } '/_app/datahub/subjects/': { id: '/_app/datahub/subjects/' path: '/datahub/subjects' @@ -716,6 +793,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppInstrumentsRenderIdRouteImport parentRoute: typeof AppRouteRoute } + '/_app/datahub/instruments/$instrumentId/': { + id: '/_app/datahub/instruments/$instrumentId/' + path: '/' + fullPath: '/datahub/instruments/$instrumentId/' + preLoaderRoute: typeof AppDatahubInstrumentsInstrumentIdIndexRouteImport + parentRoute: typeof AppDatahubInstrumentsInstrumentIdRouteRoute + } + '/_app/datahub/instruments/$instrumentId/graph': { + id: '/_app/datahub/instruments/$instrumentId/graph' + path: '/graph' + fullPath: '/datahub/instruments/$instrumentId/graph' + preLoaderRoute: typeof AppDatahubInstrumentsInstrumentIdGraphRouteImport + parentRoute: typeof AppDatahubInstrumentsInstrumentIdRouteRoute + } + '/_app/datahub/instruments/$instrumentId/table': { + id: '/_app/datahub/instruments/$instrumentId/table' + path: '/table' + fullPath: '/datahub/instruments/$instrumentId/table' + preLoaderRoute: typeof AppDatahubInstrumentsInstrumentIdTableRouteImport + parentRoute: typeof AppDatahubInstrumentsInstrumentIdRouteRoute + } '/_app/datahub/subjects/$subjectId/assignments': { id: '/_app/datahub/subjects/$subjectId/assignments' path: '/assignments' @@ -747,6 +845,27 @@ declare module '@tanstack/react-router' { } } +interface AppDatahubInstrumentsInstrumentIdRouteRouteChildren { + AppDatahubInstrumentsInstrumentIdGraphRoute: typeof AppDatahubInstrumentsInstrumentIdGraphRoute + AppDatahubInstrumentsInstrumentIdTableRoute: typeof AppDatahubInstrumentsInstrumentIdTableRoute + AppDatahubInstrumentsInstrumentIdIndexRoute: typeof AppDatahubInstrumentsInstrumentIdIndexRoute +} + +const AppDatahubInstrumentsInstrumentIdRouteRouteChildren: AppDatahubInstrumentsInstrumentIdRouteRouteChildren = + { + AppDatahubInstrumentsInstrumentIdGraphRoute: + AppDatahubInstrumentsInstrumentIdGraphRoute, + AppDatahubInstrumentsInstrumentIdTableRoute: + AppDatahubInstrumentsInstrumentIdTableRoute, + AppDatahubInstrumentsInstrumentIdIndexRoute: + AppDatahubInstrumentsInstrumentIdIndexRoute, + } + +const AppDatahubInstrumentsInstrumentIdRouteRouteWithChildren = + AppDatahubInstrumentsInstrumentIdRouteRoute._addFileChildren( + AppDatahubInstrumentsInstrumentIdRouteRouteChildren, + ) + interface AppDatahubSubjectsSubjectIdRouteRouteChildren { AppDatahubSubjectsSubjectIdAssignmentsRoute: typeof AppDatahubSubjectsSubjectIdAssignmentsRoute AppDatahubSubjectsSubjectIdGraphRoute: typeof AppDatahubSubjectsSubjectIdGraphRoute @@ -789,6 +908,7 @@ interface AppRouteRouteChildren { AppUploadInstrumentIdRoute: typeof AppUploadInstrumentIdRoute AppDatahubIndexRoute: typeof AppDatahubIndexRoute AppUploadIndexRoute: typeof AppUploadIndexRoute + AppDatahubInstrumentsInstrumentIdRouteRoute: typeof AppDatahubInstrumentsInstrumentIdRouteRouteWithChildren AppDatahubSubjectsSubjectIdRouteRoute: typeof AppDatahubSubjectsSubjectIdRouteRouteWithChildren AppAdminAuditLogsRoute: typeof AppAdminAuditLogsRoute AppAdminBrandingLoginPageRoute: typeof AppAdminBrandingLoginPageRoute @@ -800,6 +920,7 @@ interface AppRouteRouteChildren { AppAdminGroupsIndexRoute: typeof AppAdminGroupsIndexRoute AppAdminInstrumentReposIndexRoute: typeof AppAdminInstrumentReposIndexRoute AppAdminUsersIndexRoute: typeof AppAdminUsersIndexRoute + AppDatahubInstrumentsIndexRoute: typeof AppDatahubInstrumentsIndexRoute AppDatahubSubjectsIndexRoute: typeof AppDatahubSubjectsIndexRoute } @@ -822,6 +943,8 @@ const AppRouteRouteChildren: AppRouteRouteChildren = { AppUploadInstrumentIdRoute: AppUploadInstrumentIdRoute, AppDatahubIndexRoute: AppDatahubIndexRoute, AppUploadIndexRoute: AppUploadIndexRoute, + AppDatahubInstrumentsInstrumentIdRouteRoute: + AppDatahubInstrumentsInstrumentIdRouteRouteWithChildren, AppDatahubSubjectsSubjectIdRouteRoute: AppDatahubSubjectsSubjectIdRouteRouteWithChildren, AppAdminAuditLogsRoute: AppAdminAuditLogsRoute, @@ -834,6 +957,7 @@ const AppRouteRouteChildren: AppRouteRouteChildren = { AppAdminGroupsIndexRoute: AppAdminGroupsIndexRoute, AppAdminInstrumentReposIndexRoute: AppAdminInstrumentReposIndexRoute, AppAdminUsersIndexRoute: AppAdminUsersIndexRoute, + AppDatahubInstrumentsIndexRoute: AppDatahubInstrumentsIndexRoute, AppDatahubSubjectsIndexRoute: AppDatahubSubjectsIndexRoute, } diff --git a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/graph.tsx b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/graph.tsx new file mode 100644 index 000000000..51a382e84 --- /dev/null +++ b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/graph.tsx @@ -0,0 +1,199 @@ +import { useRef } from 'react'; + +import { ActionDropdown, Select, Tabs } from '@douglasneuroinformatics/libui/components'; +import { useDownload, useNotificationsStore, useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { createFileRoute } from '@tanstack/react-router'; + +import { RecordDistributionChart } from '@/components/RecordDistributionChart'; +import { RecordScatterChart } from '@/components/RecordScatterChart'; +import { SelectChartPalette } from '@/components/SelectChartPalette'; +import { useInstrumentHubRecords } from '@/hooks/useInstrumentHubRecords'; +import { useLinearModelQuery } from '@/hooks/useLinearModelQuery'; +import { useMeasureOptions } from '@/hooks/useMeasureOptions'; +import { useRecordChartSeries } from '@/hooks/useRecordChartSeries'; +import type { ColourBy } from '@/hooks/useRecordChartSeries'; +import { useAppStore } from '@/store'; +import { chartImageFilename, renderChartPng } from '@/utils/chart-image'; +import { MAX_CATEGORICAL_GROUPS } from '@/utils/chart-palette'; +import { resolveTheme } from '@/utils/chart-theme'; + +const RouteComponent = () => { + const navigate = Route.useNavigate(); + const { chart, colourBy, measure, palette } = Route.useSearch(); + const currentGroup = useAppStore((store) => store.currentGroup); + const { instrument, instrumentId, records } = useInstrumentHubRecords(); + const { t } = useTranslation(); + const downloadFile = useDownload(); + const addNotification = useNotificationsStore((store) => store.addNotification); + const [theme] = useTheme(); + const chartContainerRef = useRef(null); + + const title = instrument?.details.title ?? t({ en: 'Instrument', es: 'Instrumento', fr: 'Instrument' }); + + const measureOptions = useMeasureOptions(instrument); + const selectedMeasure = measure ?? measureOptions[0]?.key; + const measureLabel = measureOptions.find((option) => option.key === selectedMeasure)?.label ?? ''; + + const handlePngDownload = async () => { + if (!chartContainerRef.current) { + return; + } + try { + const blob = await renderChartPng(chartContainerRef.current, { + headings: [title, measureLabel].filter(Boolean), + // Only a grouped chart has a legend on screen, and only then does the capture need one. + legend: series.length > 1 ? series.map((item) => ({ color: item.color, label: item.label })) : [], + mode: resolveTheme(theme) + }); + await downloadFile(chartImageFilename(title), () => blob, { blobType: 'image/png' }); + } catch (error) { + console.error(error); + addNotification({ + // The cause is named rather than swallowed: without it the toast says only that something + // went wrong, which is no more actionable than the button doing nothing. + message: `${t({ + en: 'The chart could not be saved as an image', + es: 'No se pudo guardar el gráfico como imagen', + fr: "Le graphique n'a pas pu être enregistré comme image" + })}: ${error instanceof Error ? error.message : String(error)}`, + type: 'error' + }); + } + }; + + const { isNumeric, series } = useRecordChartSeries({ colourBy, measure: selectedMeasure, palette, records }); + + // The regression is a property of the instrument across the whole group, so it is only an honest + // overlay when the plotted set has not been narrowed to a subgroup. + const isTrendComparable = colourBy === 'none' && series.length <= 1; + const linearModelQuery = useLinearModelQuery({ + enabled: Boolean(instrumentId) && isNumeric && isTrendComparable, + params: { groupId: currentGroup?.id, instrumentId: instrumentId! } + }); + const trend = isTrendComparable && selectedMeasure ? linearModelQuery.data?.[selectedMeasure] : undefined; + + const hasData = series.some((item) => item.points.length > 0); + + return ( +
+
+ { + void navigate({ search: (prev) => ({ ...prev, chart: value as 'distribution' | 'scatter' }) }); + }} + > + + + {t({ en: 'Over Time', es: 'A lo largo del tiempo', fr: 'Dans le temps' })} + + + {t({ en: 'Distribution', es: 'Distribución', fr: 'Distribution' })} + + + +
+ + + { + void navigate({ search: (prev) => ({ ...prev, palette: selected }) }); + }} + /> + { + void handlePngDownload(); + }} + /> +
+
+
+ {!hasData ? ( +

+ {t({ + en: 'No records match the current filters.', + es: 'Ningún registro coincide con los filtros actuales.', + fr: 'Aucun enregistrement ne correspond aux filtres actuels.' + })} +

+ ) : chart === 'distribution' ? ( + + ) : !isNumeric ? ( +

+ {t({ + en: 'This measure holds categories rather than numbers, so it can only be shown as a distribution.', + es: 'Esta medida contiene categorías en lugar de números, por lo que solo puede mostrarse como distribución.', + fr: 'Cette mesure contient des catégories et non des nombres : elle ne peut être affichée que sous forme de distribution.' + })} +

+ ) : ( + + )} +
+
+ ); +}; + +export const Route = createFileRoute('/_app/datahub/instruments/$instrumentId/graph')({ + component: RouteComponent +}); diff --git a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/index.tsx b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/index.tsx new file mode 100644 index 000000000..b456f0a37 --- /dev/null +++ b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute, redirect } from '@tanstack/react-router'; + +export const Route = createFileRoute('/_app/datahub/instruments/$instrumentId/')({ + beforeLoad: ({ params, search }) => { + throw redirect({ params, search, to: '/datahub/instruments/$instrumentId/table' }); + } +}); diff --git a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx new file mode 100644 index 000000000..5fb06accd --- /dev/null +++ b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx @@ -0,0 +1,74 @@ +import React from 'react'; + +import { Heading } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { ChevronLeftIcon } from '@heroicons/react/24/solid'; +import { $SessionType } from '@opendatacapture/schemas/session'; +import { createFileRoute, Link, Outlet } from '@tanstack/react-router'; +import { z } from 'zod/v4'; + +import { LoadingFallback } from '@/components/LoadingFallback'; +import { PageHeader } from '@/components/PageHeader'; +import { TabLink } from '@/components/TabLink'; +import { useInstrumentHubFacets } from '@/hooks/useInstrumentHubFacets'; +import { COLOUR_BY_OPTIONS } from '@/hooks/useRecordChartSeries'; +import { CHART_PALETTE_NAMES } from '@/utils/chart-palette'; + +const RouteComponent = () => { + const params = Route.useParams(); + const search = Route.useSearch(); + const { t } = useTranslation(); + const { isSeries, title } = useInstrumentHubFacets(); + + const basePathname = `/datahub/instruments/${params.instrumentId}`; + + return ( + + + + {title ?? t({ en: 'Instrument', es: 'Instrumento', fr: 'Instrument' })} + + + + + {t({ en: 'Return', es: 'Volver', fr: 'Retour' })} + + {!isSeries && ( +
+ + +
+ )} + }> + + +
+ ); +}; + +export const Route = createFileRoute('/_app/datahub/instruments/$instrumentId')({ + component: RouteComponent, + validateSearch: z.object({ + chart: z.enum(['scatter', 'distribution']).default('scatter'), + colourBy: z.enum(COLOUR_BY_OPTIONS).default('none'), + fromSeries: z.string().optional(), + measure: z.string().optional(), + methods: $SessionType.array().optional(), + minDate: z.coerce.date().optional(), + palette: z.enum(CHART_PALETTE_NAMES).default('default'), + series: z.string().array().optional() + }) +}); diff --git a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/table.tsx b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/table.tsx new file mode 100644 index 000000000..1fc296258 --- /dev/null +++ b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/table.tsx @@ -0,0 +1,307 @@ +import { useMemo, useState } from 'react'; + +import { camelToSnakeCase, toBasicISOString } from '@douglasneuroinformatics/libjs'; +import { + ActionDropdown, + Button, + DataTable, + DropdownMenu, + TanstackTable +} from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { InstrumentKind } from '@opendatacapture/runtime-core'; +import { $SessionType } from '@opendatacapture/schemas/session'; +import type { SessionType } from '@opendatacapture/schemas/session'; +import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; +import { createFileRoute } from '@tanstack/react-router'; +import { ChevronDownIcon } from 'lucide-react'; + +import { InstrumentTable } from '@/components/InstrumentTable'; +import type { InstrumentRow } from '@/components/InstrumentTable'; +import { SelectEdition } from '@/components/SelectEdition'; +import { SortableHeader } from '@/components/SortableHeader'; +import { TimeDropdown } from '@/components/TimeDropdown'; +import { TruncatedCell } from '@/components/TruncatedCell'; +import { useCollectionMethodLabels } from '@/hooks/useCollectionMethodLabels'; +import { NO_SERIES, useInstrumentHubFacets } from '@/hooks/useInstrumentHubFacets'; +import { useInstrumentHubRecords } from '@/hooks/useInstrumentHubRecords'; +import { useInstrumentInfoById } from '@/hooks/useInstrumentInfoById'; +import type { InstrumentVisualizationRecord } from '@/hooks/useInstrumentVisualization'; +import { useRecordMetadataColumns } from '@/hooks/useRecordMetadataColumns'; +import { useAppStore } from '@/store'; +import { formatRecordValue } from '@/utils/record-value'; + +const COLLECTION_METHODS = $SessionType.options; + +const FIXED_COLUMN_PREFIX = '__'; + +const INDIVIDUAL_LABEL_KEY = 'individual'; + +const Filters = ({ seriesOptions }: { seriesOptions: Map }) => { + const navigate = Route.useNavigate(); + const { methods, series } = Route.useSearch(); + const { t } = useTranslation(); + const collectionMethodLabels = useCollectionMethodLabels(); + + const [isOpen, setIsOpen] = useState(false); + + const selectedMethods = methods ?? COLLECTION_METHODS; + const selectedSeries = series ?? Array.from(seriesOptions.keys()); + + const setMethods = (next: SessionType[]) => { + void navigate({ search: (prev) => ({ ...prev, methods: next }) }); + }; + const setSeries = (next: string[]) => { + void navigate({ search: (prev) => ({ ...prev, series: next }) }); + }; + + return ( + + + + + + + {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[] = []; + if (isSeries) { + return [ + { + accessorFn: (record) => instrumentInfoById[record.__instrumentId__]?.title ?? record.__instrumentId__, + cell: (ctx) => { + const title = ctx.getValue() as string; + return ; + }, + header: ({ column }) => , + id: '__instrument__' + } + ]; + } + 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; + }, [instrumentInfoById, isSeries, 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/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/graph.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/graph.tsx index 3d92eb187..5cfbee8cc 100644 --- a/apps/web/src/routes/_app/datahub/subjects/$subjectId/graph.tsx +++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/graph.tsx @@ -3,12 +3,12 @@ import { useRef, useState } from 'react'; import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import { ActionDropdown, ListboxDropdown } from '@douglasneuroinformatics/libui/components'; import type { ListboxDropdownOption } from '@douglasneuroinformatics/libui/components'; -import { useDownload, useTranslation } from '@douglasneuroinformatics/libui/hooks'; -import type { AnyUnilingualFormInstrument } from '@opendatacapture/runtime-core'; +import { useDownload, useNotificationsStore, useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { createFileRoute } from '@tanstack/react-router'; -import html2canvas from 'html2canvas'; +import { z } from 'zod/v4'; import { LineGraph } from '@/components/LineGraph'; +import { SelectChartPalette } from '@/components/SelectChartPalette'; import { SelectEdition } from '@/components/SelectEdition'; import { SelectInstrument } from '@/components/SelectInstrument'; import { TimeDropdown } from '@/components/TimeDropdown'; @@ -18,20 +18,26 @@ import { useInstrumentVisualization } from '@/hooks/useInstrumentVisualization'; import { useLinearModelQuery } from '@/hooks/useLinearModelQuery'; import { useMeasureOptions } from '@/hooks/useMeasureOptions'; import { useAppStore } from '@/store'; +import { renderChartPng } from '@/utils/chart-image'; +import { CHART_PALETTE_NAMES } from '@/utils/chart-palette'; +import { inkColors, resolveTheme } from '@/utils/chart-theme'; const RouteComponent = () => { const downloadCanvas = useDownload(); + const addNotification = useNotificationsStore((store) => store.addNotification); + const [theme] = useTheme(); const currentGroup = useAppStore((store) => store.currentGroup); + const navigate = Route.useNavigate(); const params = Route.useParams(); + const { palette } = Route.useSearch(); const { editionOptions, instrument, instrumentId, instrumentOptions, minDate, records, setInstrumentId, setMinDate } = useInstrumentVisualization({ params: { - kind: 'FORM', subjectId: params.subjectId } }); const { t } = useTranslation(); - const measureOptions = useMeasureOptions(instrument as AnyUnilingualFormInstrument); + const measureOptions = useMeasureOptions(instrument); const [selectedMeasures, setSelectedMeasures] = useState([]); const linearModelQuery = useLinearModelQuery({ @@ -47,59 +53,48 @@ const RouteComponent = () => { selectedMeasures }); - const lines = useGraphLines({ selectedMeasures }); + const lines = useGraphLines({ palette, selectedMeasures }); const graphContainerRef = useRef(null); const handleGraphDownload = async () => { - if (!graphContainerRef.current) return; - - const canvas = await html2canvas(graphContainerRef.current, { - onclone: (_, element) => { - const graphDesc = document.createElement('p'); - - const formType = instrument.details.title; - const subjectId = params.subjectId.slice(0, 7); - - graphDesc.innerText = t('datahub.downloadInfo.subjectText', { - args: [formType ?? '', subjectId ?? ''] - }); - - graphDesc.className = 'p-2 font-semibold text-center'; - element.prepend(graphDesc); - - if (selectedMeasures) { - const graphMeasure = document.createElement('p'); - graphMeasure.innerText = t('datahub.downloadInfo.measurement'); - selectedMeasures.map((measure) => measure.label).join(', '); - graphMeasure.innerText += selectedMeasures.map((measure) => measure.label).join(', '); - graphMeasure.className = 'p-2 font-semibold'; - element.append(graphMeasure); - } - - const graphTime = document.createElement('p'); - if (minDate) { - graphTime.innerText = t('datahub.downloadInfo.time'); - graphTime.innerText += toBasicISOString(minDate) + ' - ' + toBasicISOString(new Date()); - - graphTime.className = 'p-2 font-semibold'; - element.append(graphTime); - } else { - graphTime.innerText = t('datahub.downloadInfo.allTime'); - - graphTime.className = 'p-2 font-semibold'; - element.append(graphTime); - } - } - }); - const blobCanvas = await new Promise((resolve, reject) => { - canvas.toBlob((blob) => { - if (!blob) return reject(new Error('blob does not exist')); - return resolve(blob); + if (!graphContainerRef.current) { + return; + } + const subjectId = params.subjectId.slice(0, 7); + const footnotes = [ + minDate + ? t('datahub.downloadInfo.time') + toBasicISOString(minDate) + ' - ' + toBasicISOString(new Date()) + : t('datahub.downloadInfo.allTime') + ]; + if (selectedMeasures.length > 0) { + footnotes.unshift( + t('datahub.downloadInfo.measurement') + selectedMeasures.map((measure) => measure.label).join(', ') + ); + } + try { + const blob = await renderChartPng(graphContainerRef.current, { + footnotes, + headings: [t('datahub.downloadInfo.subjectText', { args: [instrument?.details.title ?? '', subjectId] })], + // The trend lines carry `legendType: 'none'` on screen and are left out here for the same + // reason: each one restates the measure it accompanies. + legend: lines + .filter((line) => line.legendType !== 'none') + .map((line) => ({ color: line.stroke ?? inkColors[resolveTheme(theme)], label: line.name })), + mode: resolveTheme(theme) }); - }); - - await downloadCanvas(`${params.subjectId.slice(0, 7)}.png`, () => blobCanvas, { blobType: 'image/png' }); + await downloadCanvas(`${subjectId}.png`, () => blob, { blobType: 'image/png' }); + } catch (error) { + console.error(error); + addNotification({ + message: `${t({ + en: 'The chart could not be saved as an image', + es: 'No se pudo guardar el gráfico como imagen', + fr: "Le graphique n'a pas pu être enregistré comme image" + })}: ${error instanceof Error ? error.message : String(error)}`, + type: 'error' + }); + } }; return ( @@ -144,6 +139,15 @@ const RouteComponent = () => {
+
+ { + void navigate({ search: (prev) => ({ ...prev, palette: selected }) }); + }} + /> +
{ }; export const Route = createFileRoute('/_app/datahub/subjects/$subjectId/graph')({ - component: RouteComponent + component: RouteComponent, + validateSearch: z.object({ + palette: z.enum(CHART_PALETTE_NAMES).default('default') + }) }); diff --git a/apps/web/src/routes/_app/datahub/subjects/$subjectId/route.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/route.tsx index 0ca63c90d..3011d679b 100644 --- a/apps/web/src/routes/_app/datahub/subjects/$subjectId/route.tsx +++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/route.tsx @@ -2,8 +2,10 @@ import React from 'react'; import { Heading } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { ChevronLeftIcon } from '@heroicons/react/24/solid'; import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; -import { createFileRoute, Outlet } from '@tanstack/react-router'; +import { createFileRoute, Link, Outlet } from '@tanstack/react-router'; +import { z } from 'zod/v4'; import { LoadingFallback } from '@/components/LoadingFallback'; import { PageHeader } from '@/components/PageHeader'; @@ -13,6 +15,7 @@ import { useAppStore } from '@/store'; const RouteComponent = () => { const params = Route.useParams(); + const search = Route.useSearch(); const { t } = useTranslation('datahub'); const subjectId = params.subjectId; const basePathname = `/datahub/subjects/${subjectId}`; @@ -34,6 +37,18 @@ const RouteComponent = () => { )} + {/* A subject reached from an instrument's record table belongs to that listing, not to the + subject index — so the return goes back to where the reader actually was. */} + + + {t({ en: 'Return', es: 'Volver', fr: 'Retour' })} +
@@ -53,5 +68,11 @@ const RouteComponent = () => { }; export const Route = createFileRoute('/_app/datahub/subjects/$subjectId')({ - component: RouteComponent + component: RouteComponent, + validateSearch: z.object({ + /** The instrument whose record table this subject was opened from, if it was */ + fromInstrument: z.string().optional(), + /** Carried through so returning to that instrument keeps its own return pointing at the series */ + fromSeries: z.string().optional() + }) }); diff --git a/apps/web/src/routes/_app/datahub/subjects/$subjectId/table/index.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/table/index.tsx index 77ecd6eb9..e15731f23 100644 --- a/apps/web/src/routes/_app/datahub/subjects/$subjectId/table/index.tsx +++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/table/index.tsx @@ -8,8 +8,10 @@ import { createFileRoute } from '@tanstack/react-router'; import { SelectEdition } from '@/components/SelectEdition'; import { SelectInstrument } from '@/components/SelectInstrument'; import { TimeDropdown } from '@/components/TimeDropdown'; +import { TruncatedCell } from '@/components/TruncatedCell'; import { useInstrumentVisualization } from '@/hooks/useInstrumentVisualization'; import type { InstrumentVisualizationRecord } from '@/hooks/useInstrumentVisualization'; +import { useRecordMetadataColumns } from '@/hooks/useRecordMetadataColumns'; import { formatRecordValue } from '@/utils/record-value'; const RouteComponent = () => { @@ -21,6 +23,7 @@ const RouteComponent = () => { }); const { t } = useTranslation(); + const metadataColumns = useRecordMetadataColumns(); const columns = useMemo[]>(() => { const columns: TanstackTable.ColumnDef[] = []; @@ -29,15 +32,8 @@ const RouteComponent = () => { columns.push({ accessorKey: subItem, cell: (ctx) => { - const value = ctx.getValue(); - return ( -

- {formatRecordValue(value)} -

- ); + const value = formatRecordValue(ctx.getValue()); + return ; }, header: camelToSnakeCase(subItem).toUpperCase(), id: subItem @@ -86,6 +82,7 @@ const RouteComponent = () => { }, header: 'DATE_COLLECTED' }, + ...metadataColumns, ...columns ]} data={records} diff --git a/apps/web/src/routes/_app/datahub/subjects/index.tsx b/apps/web/src/routes/_app/datahub/subjects/index.tsx index 8b8b349c6..d31faf644 100644 --- a/apps/web/src/routes/_app/datahub/subjects/index.tsx +++ b/apps/web/src/routes/_app/datahub/subjects/index.tsx @@ -20,6 +20,7 @@ import { ChevronDownIcon, UserSearchIcon } from 'lucide-react'; import { unpack } from 'msgpackr/unpack'; import { unparse } from 'papaparse'; +import { CollectedFilterMenu } from '@/components/CollectedFilterMenu'; import { ColorTagCell } from '@/components/ColorTagCell'; import { IdentificationForm } from '@/components/IdentificationForm'; import { PageHeader } from '@/components/PageHeader'; @@ -28,13 +29,12 @@ import { SortableHeader } from '@/components/SortableHeader'; import { subjectRecordSummaryQueryOptions, useSubjectRecordSummaryQuery } from '@/hooks/useSubjectRecordSummaryQuery'; import { subjectsQueryOptions, useSubjectsQuery } from '@/hooks/useSubjectsQuery'; import { useAppStore } from '@/store'; -import { downloadExcel } from '@/utils/excel'; +import { DEFAULT_COLLECTED_FILTER, matchesCollectedFilter } from '@/utils/collected-filter'; +import type { CollectedFilter } from '@/utils/collected-filter'; +import { downloadSubjectTableExcel } from '@/utils/excel'; import { getListedSubjectIds } from '@/utils/table'; import { getSexColor } from '@/utils/tag-colors'; -/** Shown where a subject has never had a record collected */ -const EMPTY_CELL = '—'; - type DateFilter = { allowNull: boolean; max: Date | null; @@ -47,39 +47,6 @@ type HasSearchStringFilter = { searchString: string; }; -/** A named window over the collection date, or an explicit one the user typed */ -type CollectedPreset = - 'all' | 'custom' | 'pastMonth' | 'pastSixMonths' | 'pastThreeMonths' | 'pastTwoYears' | 'pastWeek' | 'pastYear'; - -type CollectedFilter = { - max: Date | null; - min: Date | null; - preset: CollectedPreset; -}; - -/** The earliest date a preset admits, or null for no lower bound */ -function presetMinDate(preset: CollectedPreset): Date | null { - const now = new Date(); - const monthsAgo = (count: number) => new Date(new Date(now).setMonth(now.getMonth() - count)); - const yearsAgo = (count: number) => new Date(new Date(now).setFullYear(now.getFullYear() - count)); - switch (preset) { - case 'pastMonth': - return monthsAgo(1); - case 'pastSixMonths': - return monthsAgo(6); - case 'pastThreeMonths': - return monthsAgo(3); - case 'pastTwoYears': - return yearsAgo(2); - case 'pastWeek': - return new Date(new Date(now).setDate(now.getDate() - 7)); - case 'pastYear': - return yearsAgo(1); - default: - return null; - } -} - const Filters: React.FC<{ minRecords: string; setMinRecords: (v: string) => void; @@ -226,70 +193,11 @@ const Filters: React.FC<{ onChange={(event) => setMinRecords(event.target.value)} />
-
- {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. */} - -
- {collectedFilter.preset === 'custom' && ( - -
- {t({ en: 'Min:', es: 'Mín.:', fr: 'Min :' })} - { - collectedColumn.setFilterValue((prevValue: CollectedFilter): CollectedFilter => ({ - ...prevValue, - min: event.target.valueAsDate - })); - }} - /> -
-
- {t({ en: 'Max:', es: 'Máx.:', fr: 'Max :' })} - { - collectedColumn.setFilterValue((prevValue: CollectedFilter): CollectedFilter => ({ - ...prevValue, - max: event.target.valueAsDate - })); - }} - /> -
-
- )} + collectedColumn.setFilterValue(next)} + /> @@ -369,15 +277,17 @@ const Toggles: React.FC<{ ); } + const exportData = filteredData.map(({ seriesId: _, ...rest }) => rest); + switch (option) { case 'CSV': void download('README.txt', t('datahub.index.table.exportHelpText')); - void download(`${baseFilename}.csv`, unparse(filteredData)); + void download(`${baseFilename}.csv`, unparse(exportData)); break; case 'Excel': - return downloadExcel(`${baseFilename}.xlsx`, filteredData); + return downloadSubjectTableExcel(`${baseFilename}.xlsx`, exportData, 'Records'); case 'JSON': - return download(`${baseFilename}.json`, JSON.stringify(filteredData, null, 2)); + return download(`${baseFilename}.json`, JSON.stringify(exportData, null, 2)); } }) .then(() => { @@ -439,7 +349,7 @@ const Toggles: React.FC<{ data-spotlight-type="export-data-dropdown" data-testid="datahub-export-dropdown" options={['CSV', 'JSON', 'Excel']} - title={t('datahub.index.table.export')} + title={t('core.download')} onSelection={handleExportSelection} />
@@ -579,19 +489,10 @@ const MasterDataTable: React.FC<{ accessorFn: (subject) => summaries.get(subject.id)?.lastCollectedAt ?? null, cell: (ctx) => { const value = ctx.getValue() as Date | null; - return value ? toBasicISOString(value) : EMPTY_CELL; - }, - filterFn: (row, id, filter: CollectedFilter) => { - const value = row.getValue(id); - // A subject with nothing collected cannot satisfy a window, but must survive "any time". - if (!value) { - return filter.preset === 'all'; - } - if (filter.min && value < filter.min) { - return false; - } - return !(filter.max && value > filter.max); + return value ? toBasicISOString(value) : t({ en: 'None', es: 'Ninguno', fr: 'Aucun' }); }, + filterFn: (row, id, filter: CollectedFilter) => + matchesCollectedFilter(row.getValue(id), filter), header: ({ column }) => ( undefined, + beginPath: () => undefined, + drawImage: () => undefined, + fill: () => undefined, + fillRect: () => undefined, + fillStyle: '', + fillText: (text: string) => { + drawnText.push(text); + }, + font: '', + measureText: (text: string) => ({ width: text.length * 6 }), + scale: () => undefined, + textAlign: '' +}; + +/** A detached image never loads a data URL under happy-dom, so the decode is resolved here. */ +class FakeImage { + private onLoad: (() => void) | null = null; + + set src(value: string) { + imageSource = value; + queueMicrotask(() => this.onLoad?.()); + } + + addEventListener(type: string, listener: () => void) { + if (type === 'load') { + this.onLoad = listener; + } + } +} + +const chartCard = (markup = 'tick') => { + const container = document.createElement('div'); + container.innerHTML = markup; + return container; +}; + +beforeEach(() => { + encodedBlob = pngBlob; + drawnText = []; + imageSource = ''; + vi.stubGlobal('Image', FakeImage); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + fakeContext as unknown as CanvasRenderingContext2D + ); + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) => callback(encodedBlob)); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('renderChartPng', () => { + it('should encode the chart as a png', async () => { + await expect(renderChartPng(chartCard(), { mode: 'light' })).resolves.toBe(pngBlob); + }); + + // The chart is rasterized from its own markup, so the capture never depends on the page's CSS — + // which is what made html2canvas fail on the oklch the theme tokens resolve to. + it('should rasterize the chart from its own svg markup', async () => { + await renderChartPng(chartCard(), { mode: 'light' }); + expect(imageSource.startsWith('data:image/svg+xml;charset=utf-8,')).toBe(true); + expect(decodeURIComponent(imageSource)).toContain('tick'); + }); + + // A detached svg inherits no font, so one is declared rather than left to the renderer's default. + it('should declare a font on the serialized chart', async () => { + await renderChartPng(chartCard(), { mode: 'light' }); + expect(decodeURIComponent(imageSource)).toContain('font-family'); + }); + + it('should draw the headings above the chart and the footnotes below it', async () => { + await renderChartPng(chartCard(), { + footnotes: ['Timeframe: All time'], + headings: ['Happiness Questionnaire', 'Total Score'], + mode: 'light' + }); + expect(drawnText).toEqual(['Happiness Questionnaire', 'Total Score', 'Timeframe: All time']); + }); + + // Recharts renders its legend as HTML beside the svg, so rasterizing the svg alone drops it. + it('should redraw the legend, which is not part of the chart markup', async () => { + await renderChartPng(chartCard(), { + legend: [ + { color: '#111111', label: 'In Person' }, + { color: '#222222', label: 'Remote' } + ], + mode: 'light' + }); + expect(drawnText).toEqual(['In Person', 'Remote']); + }); + + it('should report that there is nothing to save when the card holds no chart', async () => { + await expect( + renderChartPng(chartCard('

No records match the current filters.

'), { mode: 'light' }) + ).rejects.toThrow('There is no chart on screen to save'); + }); + + it('should report a failure to encode rather than resolving with nothing', async () => { + encodedBlob = null; + await expect(renderChartPng(chartCard(), { mode: 'light' })).rejects.toThrow( + 'The chart could not be encoded as an image' + ); + }); +}); + +describe('chartImageFilename', () => { + it('should keep a readable stem and stamp it so repeated downloads do not collide', () => { + expect(chartImageFilename('Happiness Questionnaire')).toMatch(/^Happiness_Questionnaire_.+\.png$/); + }); + + it('should drop the characters a filesystem would object to', () => { + expect(chartImageFilename('A/B: "C"')).toMatch(/^A_B_C_.+\.png$/); + }); + + it('should fall back to a generic stem when the label contributes nothing', () => { + expect(chartImageFilename('///')).toMatch(/^chart_.+\.png$/); + }); +}); diff --git a/apps/web/src/utils/__tests__/chart-palette.test.ts b/apps/web/src/utils/__tests__/chart-palette.test.ts new file mode 100644 index 000000000..7bcde72dd --- /dev/null +++ b/apps/web/src/utils/__tests__/chart-palette.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { + CHART_PALETTE_NAMES, + CHART_PALETTE_THEMES, + getCategoricalColor, + getMeasureColor, + getPaletteSwatch, + MAX_CATEGORICAL_GROUPS, + NEUTRAL_MARK +} from '@/utils/chart-palette'; + +describe('CHART_PALETTE_NAMES', () => { + // The themes object is alphabetised by eslint, which would otherwise put `default` in the middle. + it('should offer the default theme first, since that is the one most users want', () => { + expect(CHART_PALETTE_NAMES[0]).toBe('default'); + }); + + it('should list every theme, so none is unreachable from the dropdown', () => { + expect([...CHART_PALETTE_NAMES].sort()).toStrictEqual(Object.keys(CHART_PALETTE_THEMES).sort()); + }); +}); + +describe('CHART_PALETTE_THEMES', () => { + // Both modes of every theme run the same length, so switching theme or mode never leaves a + // measure past the end of one column but not the other. + it.each(Object.keys(CHART_PALETTE_THEMES))('should give the %s theme the same slots in both modes', (name) => { + const theme = CHART_PALETTE_THEMES[name as keyof typeof CHART_PALETTE_THEMES]; + expect(theme.light).toHaveLength(CHART_PALETTE_THEMES.default.light.length); + expect(theme.dark).toHaveLength(CHART_PALETTE_THEMES.default.light.length); + }); + + it('should carry more slots than a grouping may use, so a measure selection is not forced to cycle early', () => { + expect(CHART_PALETTE_THEMES.default.light.length).toBeGreaterThan(MAX_CATEGORICAL_GROUPS); + }); + + // A theme is named for the hue it leads with, because slot one is what an ungrouped chart draws + // with — so a reordering would quietly change what the name promises. + it('should lead the jade theme with its green', () => { + expect(CHART_PALETTE_THEMES.jade.light[0]).toBe('#1fa85c'); + expect(CHART_PALETTE_THEMES.jade.dark[0]).toBe('#34c775'); + }); + + it('should lead the ember theme with its deep red', () => { + expect(CHART_PALETTE_THEMES.ember.light[0]).toBe('#a52a1f'); + expect(CHART_PALETTE_THEMES.ember.dark[0]).toBe('#c0392b'); + }); + + it.each(Object.keys(CHART_PALETTE_THEMES))('should not repeat a hue within the %s theme', (name) => { + const theme = CHART_PALETTE_THEMES[name as keyof typeof CHART_PALETTE_THEMES]; + expect(new Set(theme.light).size).toBe(theme.light.length); + expect(new Set(theme.dark).size).toBe(theme.dark.length); + }); + + it('should not reuse the neutral mark in any theme, which would blur a real group into the folded tail', () => { + for (const theme of Object.values(CHART_PALETTE_THEMES)) { + expect(theme.light).not.toContain(NEUTRAL_MARK.light); + expect(theme.dark).not.toContain(NEUTRAL_MARK.dark); + } + }); +}); + +describe('getCategoricalColor', () => { + it('should return the theme colour for a slot that exists', () => { + expect(getCategoricalColor(0, 'light', 'berry')).toBe(CHART_PALETTE_THEMES.berry.light[0]); + }); + + // Past the palette the caller is meant to have folded into "Other", so grey is the honest answer + // rather than cycling back round to slot one and repeating a colour. The theme still holds hues + // at that index for a measure selection to use, so the bound has to be explicit. + it('should fall back to the neutral mark past the grouping cap rather than reading a further slot', () => { + expect(CHART_PALETTE_THEMES.default.light[MAX_CATEGORICAL_GROUPS]).toBeTruthy(); + expect(getCategoricalColor(MAX_CATEGORICAL_GROUPS, 'light', 'default')).toBe(NEUTRAL_MARK.light); + }); +}); + +describe('getMeasureColor', () => { + it('should give each measure its own hue across the whole theme, not just the grouping cap', () => { + const colors = CHART_PALETTE_THEMES.jade.light.map((_, index) => getMeasureColor(index, 'light', 'jade')); + expect(colors).toStrictEqual([...CHART_PALETTE_THEMES.jade.light]); + }); + + // A measure line carries its own name in the legend, so a repeated hue is readable in a way a + // repeated hue across two unlabelled groups would not be. + it('should cycle back to the first hue once the theme runs out', () => { + const slots = CHART_PALETTE_THEMES.berry.dark.length; + expect(getMeasureColor(slots, 'dark', 'berry')).toBe(CHART_PALETTE_THEMES.berry.dark[0]); + }); + + it('should never fall through to the neutral mark, which is reserved for a folded tail', () => { + expect(getMeasureColor(99, 'light', 'default')).not.toBe(NEUTRAL_MARK.light); + }); +}); + +describe('getPaletteSwatch', () => { + it('should preview the theme in slot order, so the swatch matches what the chart will draw', () => { + expect(getPaletteSwatch('jade', 'light')).toStrictEqual(CHART_PALETTE_THEMES.jade.light); + }); +}); diff --git a/apps/web/src/utils/__tests__/collected-filter.test.ts b/apps/web/src/utils/__tests__/collected-filter.test.ts new file mode 100644 index 000000000..475417f42 --- /dev/null +++ b/apps/web/src/utils/__tests__/collected-filter.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_COLLECTED_FILTER, + matchesCollectedFilter, + presetMinDate, + selectCollectedPreset +} from '@/utils/collected-filter'; + +const daysAgo = (count: number) => new Date(Date.now() - count * 24 * 60 * 60 * 1000); + +describe('presetMinDate', () => { + it('should leave the window open at both ends for any time, so nothing is excluded by default', () => { + expect(presetMinDate('all')).toBeNull(); + }); + + // `custom` means the user owns the bounds, so the preset itself contributes none. + it('should contribute no lower bound for a custom window', () => { + expect(presetMinDate('custom')).toBeNull(); + }); + + it.each(['pastWeek', 'pastMonth', 'pastThreeMonths', 'pastSixMonths', 'pastYear', 'pastTwoYears'] as const)( + 'should put the %s lower bound in the past', + (preset) => { + expect(presetMinDate(preset)!.getTime()).toBeLessThan(Date.now()); + } + ); + + it('should order the windows so a longer one reaches further back', () => { + expect(presetMinDate('pastWeek')!.getTime()).toBeGreaterThan(presetMinDate('pastYear')!.getTime()); + }); +}); + +describe('matchesCollectedFilter', () => { + it('should admit a date inside the window', () => { + expect(matchesCollectedFilter(daysAgo(2), { max: null, min: daysAgo(7), preset: 'pastWeek' })).toBe(true); + }); + + it('should reject a date before the lower bound', () => { + expect(matchesCollectedFilter(daysAgo(30), { max: null, min: daysAgo(7), preset: 'pastWeek' })).toBe(false); + }); + + it('should reject a date after the upper bound', () => { + expect(matchesCollectedFilter(daysAgo(1), { max: daysAgo(7), min: null, preset: 'custom' })).toBe(false); + }); + + // A row with nothing collected has no date to compare, so only an open window can keep it. + it('should keep a row with no date only while the window is any time', () => { + expect(matchesCollectedFilter(null, DEFAULT_COLLECTED_FILTER)).toBe(true); + expect(matchesCollectedFilter(null, { max: null, min: daysAgo(7), preset: 'pastWeek' })).toBe(false); + }); +}); + +describe('selectCollectedPreset', () => { + it('should let a preset own both bounds, so an earlier custom window does not linger', () => { + const previous = { max: daysAgo(1), min: daysAgo(90), preset: 'custom' } as const; + const next = selectCollectedPreset('pastWeek', previous); + expect(next.max).toBeNull(); + expect(next.min).toStrictEqual(expect.any(Date)); + }); + + // Switching to custom seeds the dates from the window already on screen, so the inputs open as a + // nudge rather than blank. + it('should seed a custom window from the preset it replaces', () => { + const next = selectCollectedPreset('custom', DEFAULT_COLLECTED_FILTER); + expect(next.preset).toBe('custom'); + expect(next.min).toBe(presetMinDate('all')); + }); + + it('should keep the bounds the user typed while the window stays custom', () => { + const previous = { max: daysAgo(1), min: daysAgo(30), preset: 'custom' } as const; + expect(selectCollectedPreset('custom', previous)).toStrictEqual(previous); + }); +}); diff --git a/apps/web/src/utils/__tests__/distribution.test.ts b/apps/web/src/utils/__tests__/distribution.test.ts new file mode 100644 index 000000000..bf75c62da --- /dev/null +++ b/apps/web/src/utils/__tests__/distribution.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import type { RecordChartSeries } from '@/hooks/useRecordChartSeries'; +import { binDistribution } from '@/utils/distribution'; + +const seriesOf = (key: string, values: (number | string)[]): RecordChartSeries => ({ + color: '#000000', + key, + label: key, + points: values.map((value, index) => ({ time: index, value })) +}); + +describe('binDistribution', () => { + it('should return no rows for an empty set, so the chart can say there is nothing to show', () => { + expect(binDistribution([], true)).toStrictEqual([]); + }); + + it('should count the maximum value rather than letting it overflow past the last bin', () => { + const rows = binDistribution([seriesOf('all', [0, 10])], true); + const total = rows.reduce((sum, row) => sum + (row.all as number), 0); + expect(total).toBe(2); + expect(rows.at(-1)!.all).toBe(1); + }); + + it('should total every value exactly once across the bins', () => { + const values = [1, 2, 2, 3, 5, 8, 13, 21, 34, 55, 89]; + const rows = binDistribution([seriesOf('all', values)], true); + expect(rows.reduce((sum, row) => sum + (row.all as number), 0)).toBe(values.length); + }); + + it('should give a measure whose every value is identical a single bin, having no range to divide', () => { + const rows = binDistribution([seriesOf('all', [7, 7, 7])], true); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ all: 3, bin: '7' }); + }); + + // Bins come from the combined range so a bar in one group describes the same interval as the bar + // beside it; binning each series over its own range would make the groups incomparable. + it('should bin every series over the shared range, so bars from different groups line up', () => { + const rows = binDistribution([seriesOf('low', [0, 1]), seriesOf('high', [99, 100])], true); + expect(rows[0]).toMatchObject({ high: 0, low: 2 }); + expect(rows.at(-1)).toMatchObject({ high: 2, low: 0 }); + }); + + it('should give every series a zero in every bin, so a group absent from a bin renders as a gap rather than disappearing', () => { + const rows = binDistribution([seriesOf('a', [0, 10]), seriesOf('b', [5])], true); + for (const row of rows) { + expect(row).toHaveProperty('a'); + expect(row).toHaveProperty('b'); + } + }); + + it('should count a categorical measure per distinct value instead of binning it', () => { + const rows = binDistribution([seriesOf('all', ['yes', 'no', 'yes'])], false); + expect(rows).toStrictEqual([ + { all: 1, bin: 'no' }, + { all: 2, bin: 'yes' } + ]); + }); +}); diff --git a/apps/web/src/utils/__tests__/excel.test.ts b/apps/web/src/utils/__tests__/excel.test.ts index 4216804d3..d126dd59a 100644 --- a/apps/web/src/utils/__tests__/excel.test.ts +++ b/apps/web/src/utils/__tests__/excel.test.ts @@ -27,6 +27,8 @@ const recordsExport: InstrumentRecordsExport = [ instrumentEdition: 1, instrumentName: 'HAPPINESS_QUESTIONNAIRE', measure: 'overallHappiness', + seriesId: null, + seriesName: null, sessionDate: '2026-01-01', sessionId: 'session-1', sessionType: 'IN_PERSON', diff --git a/apps/web/src/utils/__tests__/instrument-editions.test.ts b/apps/web/src/utils/__tests__/instrument-editions.test.ts index 2ebff39b1..5e0348dde 100644 --- a/apps/web/src/utils/__tests__/instrument-editions.test.ts +++ b/apps/web/src/utils/__tests__/instrument-editions.test.ts @@ -1,9 +1,21 @@ +import type { TranslatedInstrumentInfo } from '@opendatacapture/schemas/instrument'; import { describe, expect, it } from 'vitest'; -import { selectLatestEditions } from '../instrument-editions'; +import { getEditionOptions, selectLatestEditions } from '../instrument-editions'; const instrument = (id: string, name: string, edition: number) => ({ id, internal: { edition, name } }); +const scalar = (id: string, name: string, edition: number) => + ({ + details: { title: name }, + id, + internal: { edition, name }, + kind: 'FORM' + }) as unknown as TranslatedInstrumentInfo; + +const series = (id: string) => + ({ details: { title: id }, id, kind: 'SERIES', seriesItems: [] }) as unknown as TranslatedInstrumentInfo; + describe('selectLatestEditions', () => { it('should keep only the highest edition of each instrument, whatever order the editions arrive in', () => { const result = selectLatestEditions([ @@ -14,3 +26,41 @@ describe('selectLatestEditions', () => { expect(result.map(({ id }) => id)).toEqual(['hq-2', 'bdi-1']); }); }); + +describe('getEditionOptions', () => { + it('should offer every edition sharing the selected instrument’s internal name', () => { + const infos = [scalar('bprs-2', 'BPRS', 2), scalar('bprs-1', 'BPRS', 1)]; + expect(getEditionOptions(infos, 'bprs-2', 'Edition')).toStrictEqual({ + 'bprs-1': 'Edition 1', + 'bprs-2': 'Edition 2' + }); + }); + + it('should order the options by edition rather than by the order the catalog returned them', () => { + const infos = [scalar('bprs-3', 'BPRS', 3), scalar('bprs-1', 'BPRS', 1), scalar('bprs-2', 'BPRS', 2)]; + expect(Object.values(getEditionOptions(infos, 'bprs-1', 'Edition'))).toStrictEqual([ + 'Edition 1', + 'Edition 2', + 'Edition 3' + ]); + }); + + // Editions are keyed on `internal.name`, so a different instrument that happens to be in the + // catalog alongside must not appear as an edition of this one. + it('should exclude instruments with a different internal name', () => { + const infos = [scalar('bprs-1', 'BPRS', 1), scalar('hq-1', 'HQ', 1)]; + expect(getEditionOptions(infos, 'bprs-1', 'Edition')).toStrictEqual({ 'bprs-1': 'Edition 1' }); + }); + + it('should offer nothing for a series, which has no internal name to have editions of', () => { + expect(getEditionOptions([series('series-1')], 'series-1', 'Edition')).toStrictEqual({}); + }); + + it('should offer nothing when the instrument is not in the catalog', () => { + expect(getEditionOptions([scalar('bprs-1', 'BPRS', 1)], 'missing', 'Edition')).toStrictEqual({}); + }); + + it('should offer nothing before an instrument has been chosen', () => { + expect(getEditionOptions([scalar('bprs-1', 'BPRS', 1)], null, 'Edition')).toStrictEqual({}); + }); +}); diff --git a/apps/web/src/utils/chart-image.ts b/apps/web/src/utils/chart-image.ts new file mode 100644 index 000000000..4e434eef7 --- /dev/null +++ b/apps/web/src/utils/chart-image.ts @@ -0,0 +1,176 @@ +import { CHART_SURFACE, inkColors } from '@/utils/chart-theme'; + +/** + * Charts are captured by rasterizing their own SVG rather than by screenshotting the page. + * + * html2canvas re-implements CSS parsing and understands no colour function newer than `rgb()`, while + * this app's tokens resolve to Tailwind v4's palette, authored in `oklch()`. Pinning each element's + * colours inline was not enough, because it also parses pseudo-element styles, which no inline + * declaration can reach — hence "Attempting to parse an unsupported color function". + * + * The SVG has no such problem: every colour recharts draws with arrives as a hex attribute from + * `chart-theme` or the palette, so the markup is already self-contained. Nothing here consults a + * stylesheet, which is what makes the capture independent of how the page is themed. + */ + +/** The capture's own typography, declared because a detached SVG inherits no font from the page. */ +const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'; + +const HEADING_FONT = `600 16px ${FONT_STACK}`; + +const CAPTION_FONT = `500 13px ${FONT_STACK}`; + +const LINE_HEIGHT = 24; + +const GUTTER = 16; + +const LEGEND_SWATCH = 10; + +const LEGEND_GAP = 20; + +type LegendEntry = { color: string; label: string }; + +type ChartImageOptions = { + /** Lines drawn below the chart, for the filters the capture was taken under */ + footnotes?: string[]; + /** Lines drawn above the chart, so the file says what it depicts once it is out of the app */ + headings?: string[]; + /** + * Redrawn from the series rather than captured: recharts renders its legend as HTML beside the + * SVG, so rasterizing the SVG alone would silently drop it. + */ + legend?: LegendEntry[]; + mode: 'dark' | 'light'; +}; + +/** The chart's own markup, detached and made to stand alone as a document. */ +function serializeChart(svg: SVGSVGElement, width: number, height: number): string { + const clone = svg.cloneNode(true) as SVGSVGElement; + clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + clone.setAttribute('width', String(width)); + clone.setAttribute('height', String(height)); + clone.setAttribute('viewBox', `0 0 ${width} ${height}`); + // Set on the root so every `` inherits it, since the page's own font never reaches here. + clone.style.fontFamily = FONT_STACK; + return new XMLSerializer().serializeToString(clone); +} + +async function loadImage(source: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener('load', () => resolve(image)); + image.addEventListener('error', () => reject(new Error('The chart could not be rasterized'))); + image.src = source; + }); +} + +function drawLegend( + context: CanvasRenderingContext2D, + entries: LegendEntry[], + { ink, width, y }: { ink: string; width: number; y: number } +): void { + context.font = CAPTION_FONT; + const entryWidth = (entry: LegendEntry) => LEGEND_SWATCH + 6 + context.measureText(entry.label).width; + const total = entries.reduce((sum, entry) => sum + entryWidth(entry), 0) + LEGEND_GAP * (entries.length - 1); + let x = Math.max(GUTTER, (width - total) / 2); + for (const entry of entries) { + context.fillStyle = entry.color; + context.beginPath(); + context.arc(x + LEGEND_SWATCH / 2, y - 4, LEGEND_SWATCH / 2, 0, Math.PI * 2); + context.fill(); + context.fillStyle = ink; + context.textAlign = 'left'; + context.fillText(entry.label, x + LEGEND_SWATCH + 6, y); + x += entryWidth(entry) + LEGEND_GAP; + } +} + +/** + * Rasterizes a chart to a PNG blob at twice its on-screen size. + * + * Throws rather than returning null on failure, so the caller reports it: a download that quietly + * does nothing is indistinguishable from a broken button. + */ +async function renderChartPng( + container: HTMLElement, + { footnotes = [], headings = [], legend = [], mode }: ChartImageOptions +): Promise { + const svg = container.querySelector('svg'); + if (!svg) { + throw new Error('There is no chart on screen to save'); + } + + const bounds = svg.getBoundingClientRect(); + const chartWidth = Math.round(bounds.width) || Number(svg.getAttribute('width')) || 800; + const chartHeight = Math.round(bounds.height) || Number(svg.getAttribute('height')) || 400; + + const image = await loadImage( + `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serializeChart(svg, chartWidth, chartHeight))}` + ); + + const legendHeight = legend.length > 0 ? LINE_HEIGHT : 0; + const width = chartWidth; + const height = + GUTTER * 2 + headings.length * LINE_HEIGHT + chartHeight + legendHeight + footnotes.length * LINE_HEIGHT; + + const scale = 2; + const canvas = document.createElement('canvas'); + canvas.width = width * scale; + canvas.height = height * scale; + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('This browser cannot encode the chart as an image'); + } + context.scale(scale, scale); + + const ink = inkColors[mode]; + context.fillStyle = CHART_SURFACE[mode]; + context.fillRect(0, 0, width, height); + + let y = GUTTER; + + context.font = HEADING_FONT; + context.fillStyle = ink; + context.textAlign = 'center'; + for (const heading of headings) { + y += LINE_HEIGHT - 6; + context.fillText(heading, width / 2, y); + y += 6; + } + + context.drawImage(image, 0, y, chartWidth, chartHeight); + y += chartHeight; + + if (legend.length > 0) { + y += LINE_HEIGHT - 6; + drawLegend(context, legend, { ink, width, y }); + y += 6; + } + + context.font = CAPTION_FONT; + context.fillStyle = ink; + context.textAlign = 'left'; + for (const footnote of footnotes) { + y += LINE_HEIGHT - 6; + context.fillText(footnote, GUTTER, y); + y += 6; + } + + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + return; + } + reject(new Error('The chart could not be encoded as an image')); + }, 'image/png'); + }); +} + +/** A filesystem-safe stem for a chart capture, stamped so repeated downloads do not collide. */ +function chartImageFilename(label: string): string { + const stem = label.replace(/[^\w-]+/g, '_').replace(/^_+|_+$/g, '') || 'chart'; + return `${stem}_${new Date().toISOString()}.png`; +} + +export { chartImageFilename, renderChartPng }; diff --git a/apps/web/src/utils/chart-palette.ts b/apps/web/src/utils/chart-palette.ts new file mode 100644 index 000000000..c7bec5362 --- /dev/null +++ b/apps/web/src/utils/chart-palette.ts @@ -0,0 +1,94 @@ +/** + * The categorical palettes every datahub chart draws with, so the instrument and subject graphs + * read the same way and a reader moving between them is not relearning the colours. + * + * Each theme leads with the three hues it is named for, then carries three more in the same key. + * The two consumers take different amounts of that run: a grouping dimension stops at + * `MAX_CATEGORICAL_GROUPS` and folds its tail into `OTHER_GROUP_KEY`, because an unbounded legend + * of near-hues is unreadable; a measure selection has a named line per entry and so spends the + * whole run before cycling. + * + * Each theme's dark column is the same hues re-stepped for the dark surface, not an inversion of + * the light one. + * + * - default: white / grey / light blue, then steel, slate and pale cyan. + * - berry: blue / purple / pink, then indigo, magenta and rose. + * - jade: green / yellow / orange, then teal, olive and red-orange. + * - ember: deep red / light yellow / beige, then burnt orange, olive and taupe. + */ +const CHART_PALETTE_THEMES = { + berry: { + dark: ['#5b9bd5', '#9b72cb', '#e57aad', '#6b5bd5', '#c850c8', '#e8859b'], + light: ['#3a7cc6', '#7b4fb8', '#d4548e', '#4a3cb5', '#a830a8', '#d4607a'] + }, + default: { + dark: ['#e0e0e0', '#9e9e9e', '#7db8e0', '#4a7fa5', '#6b7f8f', '#b5dce8'], + light: ['#b0b0b0', '#6b6b6b', '#4a90c4', '#2f6690', '#53697a', '#7fc4d8'] + }, + ember: { + dark: ['#c0392b', '#f5d76e', '#d8c3a5', '#e07b39', '#a8a847', '#8c6b52'], + light: ['#a52a1f', '#d4b63c', '#b39b7d', '#c2621f', '#86862f', '#6e5240'] + }, + jade: { + dark: ['#34c775', '#f0c040', '#e88a3a', '#2aa89a', '#b5c040', '#e05c2a'], + light: ['#1fa85c', '#d4a017', '#d06e1e', '#1a8f82', '#8f9a24', '#c04418'] + } +} as const; + +type ChartPaletteName = keyof typeof CHART_PALETTE_THEMES; + +/** Dropdown order, with `default` pinned first. `chart-palette.test.ts` pins both the leading entry and completeness. */ +const CHART_PALETTE_NAMES = ['default', 'berry', 'jade', 'ember'] as const satisfies readonly ChartPaletteName[]; + +/** + * Grey, deliberately outside every theme, for the folded tail — a bucket, not a group. An ungrouped + * chart takes the chosen theme's first slot instead, so the palette control is never inert. + */ +const NEUTRAL_MARK = { + dark: '#a3a3a3', + light: '#525252' +} as const; + +const OTHER_GROUP_KEY = '__other__'; + +/** + * How many groups a colour-by dimension may draw before the rest fold into `OTHER_GROUP_KEY`. Three + * rather than the whole run: a scatter puts every pair of groups on screen at once, and only the + * leading trio holds a colourblind-safe separation as an all-pairs set. + */ +const MAX_CATEGORICAL_GROUPS = 3; + +/** The colour of a grouping's nth slot, or the neutral mark once the caller should have folded. */ +function getCategoricalColor(index: number, theme: 'dark' | 'light', palette: ChartPaletteName): string { + if (index >= MAX_CATEGORICAL_GROUPS) { + return NEUTRAL_MARK[theme]; + } + return CHART_PALETTE_THEMES[palette][theme][index] ?? NEUTRAL_MARK[theme]; +} + +/** + * The colour of the nth measure in a selection, cycling once the theme runs out. Cycling is honest + * here in a way it would not be for a grouping, because every line carries its own name in the + * legend — whereas a repeated hue across two unlabelled groups is simply wrong. + */ +function getMeasureColor(index: number, theme: 'dark' | 'light', palette: ChartPaletteName): string { + const colors = CHART_PALETTE_THEMES[palette][theme]; + return colors[index % colors.length]!; +} + +/** The theme's colours in slot order, for rendering a swatch of what a palette looks like. */ +function getPaletteSwatch(palette: ChartPaletteName, theme: 'dark' | 'light'): readonly string[] { + return CHART_PALETTE_THEMES[palette][theme]; +} + +export { + CHART_PALETTE_NAMES, + CHART_PALETTE_THEMES, + getCategoricalColor, + getMeasureColor, + getPaletteSwatch, + MAX_CATEGORICAL_GROUPS, + NEUTRAL_MARK, + OTHER_GROUP_KEY +}; +export type { ChartPaletteName }; diff --git a/apps/web/src/utils/chart-theme.ts b/apps/web/src/utils/chart-theme.ts new file mode 100644 index 000000000..cdf12d2e3 --- /dev/null +++ b/apps/web/src/utils/chart-theme.ts @@ -0,0 +1,67 @@ +import type React from 'react'; + +import type { Theme } from '@douglasneuroinformatics/libui/hooks'; + +/** The recessive grey the grid, axis lines and ticks share */ +const AXIS_STROKE = '#64748b'; + +/** Axis and legend text. Never a series colour — identity comes from the mark beside the text. */ +const inkColors: { [K in Theme]: string } = { + dark: '#cbd5e1', + light: '#475569' +}; + +/** + * The page surface a chart is drawn on. + * + * Named separately from the tooltip that also uses it, because a mark outlined against the surface + * needs the surface colour — reading it off the tooltip's own style made the ring change whenever + * the tooltip was restyled. + */ +const CHART_SURFACE: { [K in Theme]: string } = { + dark: '#0f172a', + light: '#f1f5f9' +}; + +const tooltipStyles: { [K in Theme]: React.CSSProperties } = { + dark: { + backgroundColor: CHART_SURFACE.dark, + borderColor: inkColors.light, + borderRadius: '2px' + }, + light: { + backgroundColor: CHART_SURFACE.light, + borderColor: inkColors.dark, + borderRadius: '2px' + } +}; + +/** + * Axis bands sized to clear their own tick text before the axis title sits below it. + * + * The x band has to hold a tick row (`tickSize` + `tickMargin` + a line of text, about 28px) and + * then the title underneath with daylight between them, which is why it is a good deal taller than + * recharts' default. The y band is widened for the same reason on its own axis. + */ +const X_AXIS_HEIGHT = 72; + +/** Lifts the title off the very bottom edge of the band without closing the gap to the ticks */ +const X_AXIS_LABEL_OFFSET = 6; + +const Y_AXIS_WIDTH = 64; + +/** `useTheme` is typed more loosely than the two modes these maps are keyed on. */ +function resolveTheme(theme: string | undefined): Theme { + return theme === 'dark' ? 'dark' : 'light'; +} + +export { + AXIS_STROKE, + CHART_SURFACE, + inkColors, + resolveTheme, + tooltipStyles, + X_AXIS_HEIGHT, + X_AXIS_LABEL_OFFSET, + Y_AXIS_WIDTH +}; diff --git a/apps/web/src/utils/collected-filter.ts b/apps/web/src/utils/collected-filter.ts new file mode 100644 index 000000000..395aefd68 --- /dev/null +++ b/apps/web/src/utils/collected-filter.ts @@ -0,0 +1,63 @@ +/** A named window over the collection date, or an explicit one the user typed */ +type CollectedPreset = + 'all' | 'custom' | 'pastMonth' | 'pastSixMonths' | 'pastThreeMonths' | 'pastTwoYears' | 'pastWeek' | 'pastYear'; + +type CollectedFilter = { + max: Date | null; + min: Date | null; + preset: CollectedPreset; +}; + +const DEFAULT_COLLECTED_FILTER: CollectedFilter = { max: null, min: null, preset: 'all' }; + +/** The earliest date a preset admits, or null for no lower bound */ +function presetMinDate(preset: CollectedPreset): Date | null { + const now = new Date(); + const monthsAgo = (count: number) => new Date(new Date(now).setMonth(now.getMonth() - count)); + const yearsAgo = (count: number) => new Date(new Date(now).setFullYear(now.getFullYear() - count)); + switch (preset) { + case 'pastMonth': + return monthsAgo(1); + case 'pastSixMonths': + return monthsAgo(6); + case 'pastThreeMonths': + return monthsAgo(3); + case 'pastTwoYears': + return yearsAgo(2); + case 'pastWeek': + return new Date(new Date(now).setDate(now.getDate() - 7)); + case 'pastYear': + return yearsAgo(1); + default: + return null; + } +} + +/** + * Whether a row's collection date falls in the chosen window. A row with nothing collected cannot + * satisfy a window, but must survive "any time". + */ +function matchesCollectedFilter(value: Date | null | undefined, filter: CollectedFilter): boolean { + if (!value) { + return filter.preset === 'all'; + } + if (filter.min && value < filter.min) { + return false; + } + return !(filter.max && value > filter.max); +} + +/** + * The filter a preset selection produces. A preset owns both bounds; only `custom` leaves them to + * the user, and it starts from whatever window the preset had so the dates are a nudge, not blank. + */ +function selectCollectedPreset(preset: CollectedPreset, previous: CollectedFilter): CollectedFilter { + return { + max: preset === 'custom' ? previous.max : null, + min: preset === 'custom' ? (previous.min ?? presetMinDate(previous.preset)) : presetMinDate(preset), + preset + }; +} + +export { DEFAULT_COLLECTED_FILTER, matchesCollectedFilter, presetMinDate, selectCollectedPreset }; +export type { CollectedFilter, CollectedPreset }; diff --git a/apps/web/src/utils/distribution.ts b/apps/web/src/utils/distribution.ts new file mode 100644 index 000000000..abc177e16 --- /dev/null +++ b/apps/web/src/utils/distribution.ts @@ -0,0 +1,69 @@ +import type { RecordChartSeries } from '@/hooks/useRecordChartSeries'; + +/** One bar group: the bin's label plus a count per series key */ +type DistributionRow = { + [seriesKey: string]: number | string; + bin: string; +}; + +const TARGET_BIN_COUNT = 10; + +/** + * Counts each series' values into shared bins. + * + * Bins span the range of every series at once rather than each series' own range, so bars from + * different groups describe the same interval and can be compared. A categorical measure is counted + * per distinct value instead, since binning a category is meaningless. + */ +function binDistribution(series: RecordChartSeries[], isNumeric: boolean): DistributionRow[] { + if (!isNumeric) { + const categories = new Map(); + for (const item of series) { + for (const point of item.points) { + const category = String(point.value); + let row = categories.get(category); + if (!row) { + row = { bin: category }; + categories.set(category, row); + } + row[item.key] = ((row[item.key] as number | undefined) ?? 0) + 1; + } + } + return Array.from(categories.values()).sort((a, b) => a.bin.localeCompare(b.bin)); + } + + const values = series.flatMap((item) => item.points.map((point) => Number(point.value))); + if (values.length === 0) { + return []; + } + const min = Math.min(...values); + const max = Math.max(...values); + // A measure whose every record holds the same value has no range to divide, so it gets one bin. + const binCount = min === max ? 1 : TARGET_BIN_COUNT; + const binWidth = min === max ? 1 : (max - min) / binCount; + + const rows = Array.from({ length: binCount }, (_, index): DistributionRow => { + const start = min + index * binWidth; + const end = index === binCount - 1 ? max : start + binWidth; + const row: DistributionRow = { + bin: binCount === 1 ? String(min) : `${start.toFixed(1)}–${end.toFixed(1)}` + }; + for (const item of series) { + row[item.key] = 0; + } + return row; + }); + + for (const item of series) { + for (const point of item.points) { + // The final bin is closed at the top, so the maximum value is counted rather than overflowing. + const index = Math.min(binCount - 1, Math.floor((Number(point.value) - min) / binWidth)); + const row = rows[index]!; + row[item.key] = ((row[item.key] as number | undefined) ?? 0) + 1; + } + } + return rows; +} + +export { binDistribution }; +export type { DistributionRow }; diff --git a/apps/web/src/utils/instrument-editions.ts b/apps/web/src/utils/instrument-editions.ts index 6b6c44278..0db67daba 100644 --- a/apps/web/src/utils/instrument-editions.ts +++ b/apps/web/src/utils/instrument-editions.ts @@ -1,7 +1,35 @@ +import type { TranslatedInstrumentInfo } from '@opendatacapture/schemas/instrument'; + type EditionedInstrument = { internal: { edition: number; name: string }; }; +/** + * Every edition of the instrument the given id names, as dropdown options keyed by instrument id. + * + * Editions are a property of a scalar instrument's `internal.name`, so a series — which has no + * `internal` at all — offers none. Ordered by edition rather than by the catalog's own order. + */ +function getEditionOptions( + infos: TranslatedInstrumentInfo[], + instrumentId: null | string, + editionLabel: string +): { [id: string]: string } { + const selected = infos.find((info) => info.id === instrumentId); + if (!selected || selected.kind === 'SERIES') { + return {}; + } + const selectedName = selected.internal.name; + const options: { [id: string]: string } = {}; + infos + .filter((info): info is typeof selected => info.kind !== 'SERIES' && info.internal.name === selectedName) + .sort((a, b) => a.internal.edition - b.internal.edition) + .forEach((info) => { + options[info.id] = `${editionLabel} ${info.internal.edition}`; + }); + return options; +} + /** The highest edition of each instrument, in the order each name first appears. */ function selectLatestEditions(instruments: TInstrument[]): TInstrument[] { const latestByName = new Map(); @@ -14,4 +42,4 @@ function selectLatestEditions(instrumen return [...latestByName.values()]; } -export { selectLatestEditions }; +export { getEditionOptions, selectLatestEditions }; diff --git a/apps/web/src/utils/tag-colors.ts b/apps/web/src/utils/tag-colors.ts index d983510ad..d9072bc6e 100644 --- a/apps/web/src/utils/tag-colors.ts +++ b/apps/web/src/utils/tag-colors.ts @@ -1,29 +1,45 @@ -type Mode = 'dark' | 'light'; +import type { InstrumentKind } from '@opendatacapture/runtime-core'; -/** Grey, for a subject whose sex was never recorded */ -const NEUTRAL_MARK = { - dark: '#a3a3a3', - light: '#525252' -} as const; +type Mode = 'dark' | 'light'; /** - * Baby pink for female, baby blue for male. + * Baby pink for female, baby blue for male, grey for a subject whose sex was never recorded. * - * One value serves both modes, because a pastel reads well against the dark surface as-is. Against - * the light surface both sit near 1.6:1, far under the 3:1 a mark would normally need — which is why - * `ColorTagCell` rings the dot, and why the label is always beside it rather than the colour - * carrying the value. + * Asked for by name, so these sit outside the validated categorical palette, and one value serves + * both modes because a pastel reads well against the dark surface as-is. Against the light surface + * both are near 1.6:1, far under the 3:1 a mark would normally need — which is why `ColorTagCell` + * rings the dot, and why the label is always beside it rather than the colour carrying the value. */ const SEX_COLORS = { FEMALE: '#f9a8d4', MALE: '#89cff0' } as const; +/** + * One named colour per instrument kind, held here rather than taken from the chart palette: the + * palette is a per-user chart setting, and choosing one must not recolour a table's kind column. + */ +const INSTRUMENT_KIND_COLORS = { + FILE: { dark: '#22c55e', light: '#16a34a' }, + FORM: { dark: '#3b82f6', light: '#2563eb' }, + INTERACTIVE: { dark: '#ef4444', light: '#dc2626' }, + SERIES: { dark: '#eab308', light: '#ca8a04' } +} as const satisfies { [K in InstrumentKind]: { dark: string; light: string } }; + +const NEUTRAL_COLOR = { + dark: '#a3a3a3', + light: '#525252' +} as const; + function getSexColor(sex: null | string, mode: Mode): string { if (sex === 'FEMALE' || sex === 'MALE') { return SEX_COLORS[sex]; } - return NEUTRAL_MARK[mode]; + return NEUTRAL_COLOR[mode]; +} + +function getInstrumentKindColor(kind: InstrumentKind, mode: Mode): string { + return INSTRUMENT_KIND_COLORS[kind][mode]; } -export { getSexColor }; +export { getInstrumentKindColor, getSexColor }; diff --git a/packages/schemas/src/instrument-records/instrument-records.test.ts b/packages/schemas/src/instrument-records/instrument-records.test.ts index fe690d0ee..eab0f5574 100644 --- a/packages/schemas/src/instrument-records/instrument-records.test.ts +++ b/packages/schemas/src/instrument-records/instrument-records.test.ts @@ -5,6 +5,7 @@ import { $InstrumentRecord, $InstrumentRecordFile, $InstrumentRecordFiles, + $InstrumentRecordSummary, $LinearRegressionResults, $UpdateInstrumentRecordData, $UploadInstrumentRecordsData @@ -84,6 +85,50 @@ describe('$InstrumentRecord', () => { }); }); +describe('$InstrumentRecord session metadata', () => { + const base = { + createdAt: '2024-01-01', + data: { value: 1 }, + date: '2024-01-01', + id: 'record-1', + instrumentId: 'instrument-1', + sessionId: 'session-1', + subjectId: 'subject-1', + updatedAt: '2024-01-01' + }; + it('should accept the data collection method on the session, which is the column the datahub renders', () => { + const result = $InstrumentRecord.safeParse({ ...base, session: { type: 'REMOTE', user: { username: 'alice' } } }); + expect(result.success).toBe(true); + expect(result.data?.session?.type).toBe('REMOTE'); + }); + // A record whose session was deleted reads back without a type, which must not fail the response. + it('should accept a null collection method', () => { + expect($InstrumentRecord.safeParse({ ...base, session: { type: null, user: null } }).success).toBe(true); + }); + it('should reject a collection method that is not a session type', () => { + expect($InstrumentRecord.safeParse({ ...base, session: { type: 'IN_CLINIC' } }).success).toBe(false); + }); +}); + +describe('$InstrumentRecordSummary', () => { + const base = { instrumentId: 'instrument-1', lastCollectedAt: '2024-01-01', recordCount: 5, subjectCount: 3 }; + it('should accept a summary and coerce its collection date', () => { + const result = $InstrumentRecordSummary.safeParse(base); + expect(result.success).toBe(true); + expect(result.data?.lastCollectedAt).toStrictEqual(new Date('2024-01-01')); + }); + // An instrument accessible to the caller but never collected has no date to report. + it('should accept a null collection date', () => { + expect($InstrumentRecordSummary.safeParse({ ...base, lastCollectedAt: null }).success).toBe(true); + }); + it('should reject a negative count, which no aggregation can legitimately produce', () => { + expect($InstrumentRecordSummary.safeParse({ ...base, recordCount: -1 }).success).toBe(false); + }); + it('should reject a fractional subject count, since subjects are counted not measured', () => { + expect($InstrumentRecordSummary.safeParse({ ...base, subjectCount: 1.5 }).success).toBe(false); + }); +}); + describe('$LinearRegressionResults', () => { it('should accept a record of measure name to intercept, slope and stdErr', () => { expect($LinearRegressionResults.safeParse({ score: { intercept: 0, slope: 1, stdErr: 0.5 } }).success).toBe(true); diff --git a/packages/schemas/src/instrument-records/instrument-records.ts b/packages/schemas/src/instrument-records/instrument-records.ts index a349739bf..7b64824fa 100644 --- a/packages/schemas/src/instrument-records/instrument-records.ts +++ b/packages/schemas/src/instrument-records/instrument-records.ts @@ -3,6 +3,7 @@ import { z } from 'zod/v4'; import { $BaseModel, $Json } from '../core/core.js'; import { $InstrumentMeasureValue } from '../instrument/instrument.js'; +import { $SessionType } from '../session/session.js'; import { $FileMetadata } from '../storage/storage.js'; import type { SessionType } from '../session/session.js'; @@ -51,9 +52,13 @@ export const $InstrumentRecord = $BaseModel.extend({ instrumentId: z.string(), pending: z.boolean().nullish(), seriesInstrumentId: z.string().nullish(), - /** The user who conducted the session, resolved server-side so clients need not join sessions themselves */ + /** + * The session that collected this record, resolved server-side so clients need not join sessions + * themselves. `type` is the data collection method. + */ session: z .object({ + type: $SessionType.nullish(), user: z .object({ username: z.string().nullish() @@ -72,6 +77,10 @@ export type InstrumentRecordsExport = { instrumentEdition: number; instrumentName: string; measure: string; + /** The id of the series instrument that orchestrated collection, or null if collected individually */ + seriesId: null | string; + /** The title of that series instrument, or null if collected individually */ + seriesName: null | string; sessionDate: string; sessionId: string; sessionType: SessionType; @@ -99,6 +108,8 @@ export type InstrumentRecordQueryParams = { instrumentId?: string; kind?: InstrumentKind; minDate?: Date; + /** Every record a series orchestrated, which spans the scalar instruments it composes */ + seriesInstrumentId?: string; subjectId?: string; }; @@ -111,10 +122,20 @@ export const $InstrumentRecordFile = $FileMetadata.omit({ location: true }).exte export type $InstrumentRecordFiles = z.infer; export const $InstrumentRecordFiles = z.record(z.string(), z.array($InstrumentRecordFile)); -export type SubjectRecordSummary = z.infer; -export const $SubjectRecordSummary = z.object({ - /** The most recent record date, or null for a subject whose records all lack one */ +export type InstrumentRecordSummary = z.infer; +export const $InstrumentRecordSummary = z.object({ + instrumentId: z.string(), + /** The most recent record date, or null for an instrument whose records all lack one */ lastCollectedAt: z.coerce.date().nullable(), recordCount: z.int().nonnegative(), - subjectId: z.string() + /** Distinct subjects, not records — a subject completing an instrument twice counts once */ + subjectCount: z.int().nonnegative() }); + +export type SubjectRecordSummary = z.infer; +export const $SubjectRecordSummary = $InstrumentRecordSummary + .omit({ + instrumentId: true, + subjectCount: true + }) + .extend({ subjectId: z.string() }); diff --git a/testing/src/pages/_app/datahub/instruments/$instrumentId/graph.page.ts b/testing/src/pages/_app/datahub/instruments/$instrumentId/graph.page.ts new file mode 100644 index 000000000..49cc2fd34 --- /dev/null +++ b/testing/src/pages/_app/datahub/instruments/$instrumentId/graph.page.ts @@ -0,0 +1,41 @@ +import type { Locator, Page } from '@playwright/test'; + +import { AppPage } from '../../../route.page'; + +export class InstrumentHubGraphPage extends AppPage { + readonly chart: Locator; + readonly chartToggle: Locator; + readonly colourByTrigger: Locator; + readonly measureTrigger: Locator; + constructor(page: Page) { + super(page); + this.chart = page.getByTestId('instrument-hub-chart'); + this.chartToggle = page.getByTestId('instrument-hub-chart-toggle'); + this.colourByTrigger = page.getByTestId('instrument-hub-colour-by-trigger'); + this.measureTrigger = page.getByTestId('instrument-hub-measure-trigger'); + } + + /** The distribution bars recharts renders, one per bin per series. */ + get bars(): Locator { + return this.chart.locator('.recharts-bar-rectangle'); + } + + /** The scatter marks recharts renders, one per plotted record. */ + get scatterMarks(): Locator { + return this.chart.locator('.recharts-scatter-symbol'); + } + + async selectChart(chart: 'Distribution' | 'Over Time') { + await this.chartToggle.getByRole('tab', { name: chart }).click(); + } + + async selectColourBy(label: string) { + await this.colourByTrigger.click(); + await this.$ref.getByRole('option', { name: label }).click(); + } + + async selectMeasure(label: string) { + await this.measureTrigger.click(); + await this.$ref.getByRole('option', { name: label }).click(); + } +} diff --git a/testing/src/pages/_app/datahub/instruments/$instrumentId/table.page.ts b/testing/src/pages/_app/datahub/instruments/$instrumentId/table.page.ts new file mode 100644 index 000000000..9ba2e7597 --- /dev/null +++ b/testing/src/pages/_app/datahub/instruments/$instrumentId/table.page.ts @@ -0,0 +1,39 @@ +import type { Download, Locator, Page } from '@playwright/test'; + +import { AppPage } from '../../../route.page'; + +export class InstrumentHubTablePage extends AppPage { + readonly collectionMethodCells: Locator; + readonly exportDropdown: Locator; + readonly filtersTrigger: Locator; + readonly pageHeader: Locator; + readonly rows: Locator; + readonly seriesCells: Locator; + readonly table: Locator; + constructor(page: Page) { + super(page); + this.exportDropdown = page.getByTestId('instrument-hub-export-dropdown'); + this.filtersTrigger = page.getByTestId('instrument-hub-filters-trigger'); + this.pageHeader = page.getByTestId('page-header'); + this.table = page.getByTestId('instrument-hub-records-table'); + this.rows = this.table.getByTestId('data-table-body').getByTestId('data-table-row'); + this.collectionMethodCells = this.table.getByTestId('record-cell-collection-method'); + this.seriesCells = this.table.getByTestId('record-cell-series'); + } + + /** Unchecks one collection method in the filter menu, narrowing both tabs and the export. */ + async excludeCollectionMethod(method: 'IN_PERSON' | 'REMOTE' | 'RETROSPECTIVE') { + await this.filtersTrigger.click(); + await this.$ref.getByTestId(`instrument-hub-filter-method-${method}`).click(); + await this.$ref.keyboard.press('Escape'); + } + + /** Picks a format from the download menu and returns the file it produced. */ + async exportAs(format: 'CSV' | 'CSV Long' | 'Excel' | 'JSON' | 'TSV'): Promise { + const extension = { CSV: '.csv', 'CSV Long': '.csv', Excel: '.xlsx', JSON: '.json', TSV: '.tsv' }[format]; + const started = this.$ref.waitForEvent('download', (download) => download.suggestedFilename().endsWith(extension)); + await this.exportDropdown.click(); + await this.$ref.getByRole('menuitem', { exact: true, name: format }).click(); + return started; + } +} diff --git a/testing/src/pages/_app/datahub/instruments/index.page.ts b/testing/src/pages/_app/datahub/instruments/index.page.ts new file mode 100644 index 000000000..4f3fcb6fc --- /dev/null +++ b/testing/src/pages/_app/datahub/instruments/index.page.ts @@ -0,0 +1,37 @@ +import type { Locator, Page } from '@playwright/test'; + +import { AppPage } from '../../route.page'; + +export class InstrumentHubPage extends AppPage { + readonly pageHeader: Locator; + readonly rows: Locator; + readonly searchInput: Locator; + constructor(page: Page) { + super(page); + this.pageHeader = page.getByTestId('page-header'); + this.rows = page.getByTestId('data-table-body').getByTestId('data-table-row'); + this.searchInput = page.getByTestId('data-table-search-bar').getByRole('searchbox'); + } + + /** Opens an instrument's detail page through its row action, as a user would. */ + async open(title: string) { + await this.row(title).first().getByTestId('row-actions-trigger').click(); + await this.$ref.getByRole('menuitem', { exact: true, name: 'View' }).click(); + } + + /** The row for an instrument, found by the title shown in its first column. */ + row(title: string): Locator { + return this.rows.filter({ hasText: title }); + } + + /** Clicks a column header to cycle its sort. Removal is disabled, so this toggles asc ⇄ desc. */ + async sortBy(label: string) { + await this.$ref.getByTestId('data-table-head').getByRole('button', { name: label }).click(); + } + + /** The visible title of every listed instrument, top to bottom — the first cell of each row. */ + async titles(): Promise { + const rows = await this.rows.all(); + return Promise.all(rows.map(async (row) => (await row.locator('> div').first().innerText()).trim())); + } +} diff --git a/testing/src/specs/authorization.spec.ts b/testing/src/specs/authorization.spec.ts index 6ba9366c0..3e39ce75f 100644 --- a/testing/src/specs/authorization.spec.ts +++ b/testing/src/specs/authorization.spec.ts @@ -125,6 +125,7 @@ const PRIVILEGED_REQUESTS: PrivilegedRequest[] = [ const GROUP_MANAGER_ONLY_ROUTES = [ '/dashboard', '/datahub/subjects', + '/datahub/instruments', '/group/manage', '/session/remote-assignment' ] as const; diff --git a/testing/src/specs/instrument-hub.spec.ts b/testing/src/specs/instrument-hub.spec.ts new file mode 100644 index 000000000..31732a1b1 --- /dev/null +++ b/testing/src/specs/instrument-hub.spec.ts @@ -0,0 +1,218 @@ +import { readFile } from 'node:fs/promises'; + +import { InstrumentHubGraphPage } from '../pages/_app/datahub/instruments/$instrumentId/graph.page'; +import { InstrumentHubTablePage } from '../pages/_app/datahub/instruments/$instrumentId/table.page'; +import { InstrumentHubPage } from '../pages/_app/datahub/instruments/index.page'; +import { HAPPINESS_RECORD } from '../support/constants'; +import { expect, test } from '../support/fixtures'; + +const HAPPINESS = 'DNP_HAPPINESS_QUESTIONNAIRE'; + +test.describe('instrument hub', () => { + test('should list an instrument with the records and subjects collected for it @smoke', async ({ + api, + isolatedGroupManager, + page, + uniqueId + }) => { + // A group of its own, so the counts are exactly what this test seeds rather than whatever else + // the suite has written for the same instrument. + const group = await isolatedGroupManager(); + const instrumentId = await api.findInstrumentIdByName(HAPPINESS); + // Two records for one subject and one for another: 3 records, 2 subjects. A subject count that + // merely counted records would read 3. + await api.uploadRecords(group.id, instrumentId, [ + { data: HAPPINESS_RECORD, date: new Date(), subjectId: `hub-${uniqueId}-a` }, + { data: HAPPINESS_RECORD, date: new Date(), subjectId: `hub-${uniqueId}-a` }, + { data: HAPPINESS_RECORD, date: new Date(), subjectId: `hub-${uniqueId}-b` } + ]); + + const hubPage = new InstrumentHubPage(page); + await hubPage.goto('/datahub/instruments'); + + const row = hubPage.row('Happiness Questionnaire').first(); + await expect(row).toBeVisible(); + await expect(row).toContainText('3'); + await expect(row).toContainText('2'); + }); + + // A series' own counts are what it orchestrated, not its members' global totals, so the row is + // only meaningful once something has actually been collected through it. + test('should list a series with what it collected, and open it onto the instruments it is made of', async ({ + api, + isolatedGroupManager, + page, + uniqueId + }) => { + const group = await isolatedGroupManager(); + const seriesTitle = `Hub Series ${uniqueId}`; + const seriesId = await api.createSeries(group.id, seriesTitle); + const instrumentId = await api.findInstrumentIdByName(HAPPINESS); + const session = await api.createSession(group.id, { id: `series-${uniqueId}` }); + await api.createRecord(group.id, instrumentId, session, HAPPINESS_RECORD, seriesId); + + const hubPage = new InstrumentHubPage(page); + await hubPage.goto('/datahub/instruments'); + + const row = hubPage.row(seriesTitle).first(); + await expect(row).toBeVisible(); + await expect(row).toContainText('Series'); + + await hubPage.open(seriesTitle); + // The member list is the records' own instruments, so the instrument administered through the + // series is there even though the series itself holds no records under its own id. + await expect(page.getByTestId('instrument-hub-series-members')).toContainText('Happiness Questionnaire'); + }); + + // Sorting is the only way to find an instrument in a long catalog without searching for it, so + // the header affordance has to actually reorder the rows rather than merely render a chevron. + test('should reorder the list when a column header is clicked', async ({ isolatedGroupManager, page }) => { + await isolatedGroupManager(); + const hubPage = new InstrumentHubPage(page); + await hubPage.goto('/datahub/instruments'); + await expect(hubPage.rows.first()).toBeVisible(); + + await hubPage.sortBy('Instrument'); + const ascending = await hubPage.titles(); + expect(ascending.length).toBeGreaterThan(1); + // Compared case-insensitively because that is what tanstack's `text` sorting function does; + // asserting against `localeCompare` would flake on any mixed-case pair. + const caseInsensitive = [...ascending].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + expect(ascending).toStrictEqual(caseInsensitive); + + // Sort removal is disabled on these tables, so a second click is descending rather than unsorted. + await hubPage.sortBy('Instrument'); + expect(await hubPage.titles()).toStrictEqual([...ascending].reverse()); + }); + + test("should show every subject's records for one instrument, with how each was collected", async ({ + api, + isolatedGroupManager, + page, + uniqueId + }) => { + const group = await isolatedGroupManager(); + const instrumentId = await api.findInstrumentIdByName(HAPPINESS); + // One record per collection method, so the column has to vary rather than printing a constant. + await api.uploadRecords(group.id, instrumentId, [ + { data: HAPPINESS_RECORD, date: new Date(), subjectId: `method-${uniqueId}-retro` } + ]); + const session = await api.createSession(group.id, { id: `method-${uniqueId}-inperson` }, 'IN_PERSON'); + await api.createRecord(group.id, instrumentId, session, HAPPINESS_RECORD); + + const hubPage = new InstrumentHubPage(page); + await hubPage.goto('/datahub/instruments'); + await hubPage.open('Happiness Questionnaire'); + + const tablePage = new InstrumentHubTablePage(page); + await expect(tablePage.rows).toHaveCount(2); + await expect(tablePage.collectionMethodCells.filter({ hasText: 'Retrospective' })).toHaveCount(1); + await expect(tablePage.collectionMethodCells.filter({ hasText: 'In-Person' })).toHaveCount(1); + // Neither record came through a series, so both series cells stand empty. + await expect(tablePage.seriesCells).toHaveCount(2); + await expect(tablePage.seriesCells.first()).toHaveText('—'); + }); + + // The download is built from the rows the table holds, so a filter has to narrow both. Getting + // this wrong exports rows the user believed they had excluded. + test('should export only the records left by the collection method filter, with the metadata columns', async ({ + api, + isolatedGroupManager, + page, + uniqueId + }) => { + const group = await isolatedGroupManager(); + const instrumentId = await api.findInstrumentIdByName(HAPPINESS); + const retrospective = `export-${uniqueId}-retro`; + await api.uploadRecords(group.id, instrumentId, [ + { data: HAPPINESS_RECORD, date: new Date(), subjectId: retrospective } + ]); + const session = await api.createSession(group.id, { id: `export-${uniqueId}-inperson` }, 'IN_PERSON'); + await api.createRecord(group.id, instrumentId, session, HAPPINESS_RECORD); + + const hubPage = new InstrumentHubPage(page); + await hubPage.goto('/datahub/instruments'); + await hubPage.open('Happiness Questionnaire'); + + const tablePage = new InstrumentHubTablePage(page); + await expect(tablePage.rows).toHaveCount(2); + + await tablePage.excludeCollectionMethod('IN_PERSON'); + await expect(tablePage.rows).toHaveCount(1); + + const download = await tablePage.exportAs('JSON'); + const payload = JSON.parse(await readFile(await download.path(), 'utf8')) as { + CollectionMethod: string; + SeriesID: null | string; + SubjectID: string; + }[]; + + expect(payload).toHaveLength(1); + // Exports carry the stored value, not the translated label, so analysis keys on something that + // does not change with the reader's language. + expect(payload[0]!.CollectionMethod).toBe('RETROSPECTIVE'); + expect(payload[0]!.SeriesID).toBeNull(); + expect(payload[0]!.SubjectID).toBe(retrospective); + }); + + test('should plot the records of the instrument on its graph tab', async ({ + api, + isolatedGroupManager, + page, + uniqueId + }) => { + const group = await isolatedGroupManager(); + const instrumentId = await api.findInstrumentIdByName(HAPPINESS); + await api.uploadRecords( + group.id, + instrumentId, + ['a', 'b'].map((suffix) => ({ + data: HAPPINESS_RECORD, + date: new Date(), + subjectId: `graph-${uniqueId}-${suffix}` + })) + ); + + const hubPage = new InstrumentHubPage(page); + await hubPage.goto('/datahub/instruments'); + await hubPage.open('Happiness Questionnaire'); + + // Reached by clicking the tab rather than through `getPageModel`, whose exact-URL assertion the + // route's own search-param defaults would fail. + await page.getByTestId('instrument-hub-graph-tab').click(); + const graphPage = new InstrumentHubGraphPage(page); + await expect(graphPage.chart).toBeVisible(); + await expect(graphPage.scatterMarks).toHaveCount(2); + + await graphPage.selectChart('Distribution'); + await expect(graphPage.bars.first()).toBeVisible(); + }); + + test('should keep the filters when switching between the table and graph tabs, so both describe one set', async ({ + api, + isolatedGroupManager, + page, + uniqueId + }) => { + const group = await isolatedGroupManager(); + const instrumentId = await api.findInstrumentIdByName(HAPPINESS); + await api.uploadRecords(group.id, instrumentId, [ + { data: HAPPINESS_RECORD, date: new Date(), subjectId: `shared-${uniqueId}` } + ]); + + const hubPage = new InstrumentHubPage(page); + await hubPage.goto('/datahub/instruments'); + await hubPage.open('Happiness Questionnaire'); + + const tablePage = new InstrumentHubTablePage(page); + await expect(tablePage.rows).toHaveCount(1); + await tablePage.excludeCollectionMethod('RETROSPECTIVE'); + await expect(page.getByTestId('data-table-empty-state')).toBeVisible(); + + // The filter lives in the URL, so it survives the tab change rather than resetting with the + // newly mounted tab's own state. + await page.getByTestId('instrument-hub-graph-tab').click(); + await expect(page).toHaveURL(/methods=/); + await expect(page.getByTestId('instrument-hub-chart')).toContainText('No records match'); + }); +}); diff --git a/testing/src/support/api-client.ts b/testing/src/support/api-client.ts index 78a2d443f..6bd59b1fa 100644 --- a/testing/src/support/api-client.ts +++ b/testing/src/support/api-client.ts @@ -5,10 +5,14 @@ import type { $LoginCredentials } from '@opendatacapture/schemas/auth'; import type { Permissions } from '@opendatacapture/schemas/core'; import type { $CreateGroupData, Group } from '@opendatacapture/schemas/group'; import type { InstrumentInfo } from '@opendatacapture/schemas/instrument'; -import type { UploadInstrumentRecordsData } from '@opendatacapture/schemas/instrument-records'; +import type { + $CreateInstrumentRecordData, + InstrumentRecord, + UploadInstrumentRecordsData +} from '@opendatacapture/schemas/instrument-records'; import { MAIL_CLIENT_TIMEOUT } from '@opendatacapture/schemas/mail'; import type { $SendAssignmentEmailData, EmailDeliveryResult } from '@opendatacapture/schemas/mail'; -import type { $CreateSessionData, Session } from '@opendatacapture/schemas/session'; +import type { $CreateSessionData, Session, SessionType } from '@opendatacapture/schemas/session'; import type { $CreateSubjectData } from '@opendatacapture/schemas/subject'; import type { $CreateUserData, $UpdateUserData, User } from '@opendatacapture/schemas/user'; import type { APIRequestContext } from '@playwright/test'; @@ -75,6 +79,34 @@ export class ApiClient { return group; } + /** + * Creates one record against a session, the way the app does when an instrument is completed in a + * sitting. Unlike {@link uploadRecords}, which always writes a RETROSPECTIVE session, this lets a + * spec choose the collection method the record will report. + */ + async createRecord( + groupId: string, + instrumentId: string, + session: Session, + data: $CreateInstrumentRecordData['data'], + seriesInstrumentId?: string + ): Promise { + const body: $CreateInstrumentRecordData = { + data, + date: new Date(), + groupId, + instrumentId, + seriesInstrumentId, + sessionId: session.id, + subjectId: session.subjectId + }; + return this.expectJson( + this.request.post(`${API}/instrument-records`, { data: body, headers: this.authHeaders }), + 201, + 'create instrument record' + ); + } + /** Assembles a series owned by the group out of the first two forms available, returning its id. */ async createSeries(groupId: string, title: string): Promise { const items = (await this.getInstrumentInfo()) @@ -95,8 +127,12 @@ export class ApiClient { * Creates a session, and with it the subject it names. A subject seeded this way holds no * instrument records, which is what distinguishes it under the "with records only" filter. */ - async createSession(groupId: null | string, subjectData: $CreateSubjectData): Promise { - const data: $CreateSessionData = { date: new Date(), groupId, subjectData, type: 'IN_PERSON' }; + async createSession( + groupId: null | string, + subjectData: $CreateSubjectData, + type: SessionType = 'IN_PERSON' + ): Promise { + const data: $CreateSessionData = { date: new Date(), groupId, subjectData, type }; return this.expectJson( this.request.post(`${API}/sessions`, { data, headers: this.authHeaders }), 201, diff --git a/testing/src/support/fixtures.ts b/testing/src/support/fixtures.ts index 1866ce5ec..d0e1c089f 100644 --- a/testing/src/support/fixtures.ts +++ b/testing/src/support/fixtures.ts @@ -16,6 +16,9 @@ import { AdminSettingsPage } from '../pages/_app/admin/settings.page'; import { AdminUserPage } from '../pages/_app/admin/users/$userId.page'; import { ContactPage } from '../pages/_app/contact.page'; import { DashboardPage } from '../pages/_app/dashboard.page'; +import { InstrumentHubGraphPage } from '../pages/_app/datahub/instruments/$instrumentId/graph.page'; +import { InstrumentHubTablePage } from '../pages/_app/datahub/instruments/$instrumentId/table.page'; +import { InstrumentHubPage } from '../pages/_app/datahub/instruments/index.page'; import { SubjectAssignmentsPage } from '../pages/_app/datahub/subjects/$subjectId/assignments.page'; import { SubjectGraphPage } from '../pages/_app/datahub/subjects/$subjectId/graph.page'; import { SubjectRecordDetailPage } from '../pages/_app/datahub/subjects/$subjectId/table/$recordId.page'; @@ -53,6 +56,9 @@ const pageModels = { '/auth/reset-password': ResetPasswordPage, '/contact': ContactPage, '/dashboard': DashboardPage, + '/datahub/instruments': InstrumentHubPage, + '/datahub/instruments/$instrumentId/graph': InstrumentHubGraphPage, + '/datahub/instruments/$instrumentId/table': InstrumentHubTablePage, '/datahub/subjects': DatahubPage, '/datahub/subjects/$subjectId/assignments': SubjectAssignmentsPage, '/datahub/subjects/$subjectId/graph': SubjectGraphPage,