From 17df2ce6d61e75d7079af1628b8373bb8e2a67ec Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Mon, 5 Oct 2026 13:16:13 -0400 Subject: [PATCH 1/4] feat: add the instrument hub and record provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the datahub revamp, on top of the subject hub. Record provenance: `Session.type` reaches the client on every record, and the export carries `seriesId` beside a `seriesName` resolved to `en` by the worker, which has no request language. Every record table gains COLLECTION_METHOD and SERIES columns. The instrument hub at `/datahub/instruments` lists one row per edition of each FORM, INTERACTIVE and FILE instrument, with a detail page holding a table and a chart over the records of that one edition. Filters live in the URL and are applied inside the data hook, so the download cannot disagree with the table. `summarizeBySubject` is generalised into a private `summarizeRecords(by, …)` now shared with `summary/by-instrument` and `summary/by-series`; the subject view alone did not justify the parameter, which is why PR1 shipped it inline. Co-Authored-By: Claude Opus 5 --- .../__tests__/export-worker.spec.ts | 32 +- .../instrument-records.controller.spec.ts | 19 +- .../instrument-records.service.spec.ts | 115 ++++++- .../src/instrument-records/export-worker.js | 18 +- .../instrument-records.controller.ts | 20 +- .../instrument-records.service.ts | 282 +++++++++++------ .../src/instrument-records/thread-types.ts | 13 +- .../__tests__/datahub-subject-table.test.tsx | 8 +- .../__tests__/datahub-table-controls.test.tsx | 2 + .../web/src/__tests__/truncated-cell.test.tsx | 31 ++ .../InstrumentTable/InstrumentTable.tsx | 299 ++++++++++++++++++ .../src/components/InstrumentTable/index.ts | 1 + .../RecordDistributionChart.tsx | 86 +++++ .../RecordDistributionChart/index.ts | 1 + .../RecordScatterChart/RecordScatterChart.tsx | 118 +++++++ .../components/RecordScatterChart/index.ts | 1 + .../TruncatedCell/TruncatedCell.tsx | 21 ++ .../web/src/components/TruncatedCell/index.ts | 1 + .../src/hooks/__tests__/useGraphData.test.ts | 5 + .../useInstrumentVisualization.test.ts | 41 ++- .../src/hooks/__tests__/useNavItems.test.ts | 16 +- .../__tests__/useRecordChartSeries.test.ts | 147 +++++++++ .../src/hooks/useCollectionMethodLabels.ts | 17 + apps/web/src/hooks/useInstrumentHubFacets.ts | 64 ++++ apps/web/src/hooks/useInstrumentHubRecords.ts | 48 +++ .../hooks/useInstrumentRecordSummaryQuery.ts | 28 ++ apps/web/src/hooks/useInstrumentTitles.ts | 17 + .../src/hooks/useInstrumentVisualization.ts | 246 ++++++++------ apps/web/src/hooks/useMeasureOptions.ts | 20 +- apps/web/src/hooks/useNavItems.ts | 11 +- apps/web/src/hooks/useRecordChartSeries.ts | 126 ++++++++ .../src/hooks/useRecordMetadataColumns.tsx | 58 ++++ apps/web/src/hooks/useSeriesNames.ts | 18 ++ apps/web/src/route-tree.ts | 124 ++++++++ .../instruments/$instrumentId/graph.tsx | 189 +++++++++++ .../instruments/$instrumentId/index.tsx | 7 + .../instruments/$instrumentId/route.tsx | 176 +++++++++++ .../instruments/$instrumentId/table.tsx | 198 ++++++++++++ .../routes/_app/datahub/instruments/index.tsx | 123 +++++++ .../datahub/subjects/$subjectId/graph.tsx | 3 +- .../subjects/$subjectId/table/index.tsx | 15 +- apps/web/src/translations/datahub.json | 6 +- apps/web/src/translations/session.json | 5 + .../src/utils/__tests__/chart-palette.test.ts | 61 ++++ .../src/utils/__tests__/distribution.test.ts | 60 ++++ apps/web/src/utils/__tests__/excel.test.ts | 2 + .../__tests__/instrument-editions.test.ts | 52 ++- apps/web/src/utils/chart-palette.ts | 84 +++++ apps/web/src/utils/chart-theme.ts | 46 +++ apps/web/src/utils/distribution.ts | 69 ++++ apps/web/src/utils/instrument-editions.ts | 30 +- apps/web/src/utils/tag-colors.ts | 34 +- .../instrument-records.test.ts | 45 +++ .../instrument-records/instrument-records.ts | 31 +- .../instruments/$instrumentId/graph.page.ts | 41 +++ .../instruments/$instrumentId/table.page.ts | 39 +++ .../_app/datahub/instruments/index.page.ts | 37 +++ testing/src/specs/authorization.spec.ts | 1 + testing/src/specs/instrument-hub.spec.ts | 199 ++++++++++++ testing/src/support/api-client.ts | 44 ++- testing/src/support/fixtures.ts | 6 + 61 files changed, 3394 insertions(+), 263 deletions(-) create mode 100644 apps/web/src/__tests__/truncated-cell.test.tsx create mode 100644 apps/web/src/components/InstrumentTable/InstrumentTable.tsx create mode 100644 apps/web/src/components/InstrumentTable/index.ts create mode 100644 apps/web/src/components/RecordDistributionChart/RecordDistributionChart.tsx create mode 100644 apps/web/src/components/RecordDistributionChart/index.ts create mode 100644 apps/web/src/components/RecordScatterChart/RecordScatterChart.tsx create mode 100644 apps/web/src/components/RecordScatterChart/index.ts create mode 100644 apps/web/src/components/TruncatedCell/TruncatedCell.tsx create mode 100644 apps/web/src/components/TruncatedCell/index.ts create mode 100644 apps/web/src/hooks/__tests__/useRecordChartSeries.test.ts create mode 100644 apps/web/src/hooks/useCollectionMethodLabels.ts create mode 100644 apps/web/src/hooks/useInstrumentHubFacets.ts create mode 100644 apps/web/src/hooks/useInstrumentHubRecords.ts create mode 100644 apps/web/src/hooks/useInstrumentRecordSummaryQuery.ts create mode 100644 apps/web/src/hooks/useInstrumentTitles.ts create mode 100644 apps/web/src/hooks/useRecordChartSeries.ts create mode 100644 apps/web/src/hooks/useRecordMetadataColumns.tsx create mode 100644 apps/web/src/hooks/useSeriesNames.ts create mode 100644 apps/web/src/routes/_app/datahub/instruments/$instrumentId/graph.tsx create mode 100644 apps/web/src/routes/_app/datahub/instruments/$instrumentId/index.tsx create mode 100644 apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx create mode 100644 apps/web/src/routes/_app/datahub/instruments/$instrumentId/table.tsx create mode 100644 apps/web/src/routes/_app/datahub/instruments/index.tsx create mode 100644 apps/web/src/utils/__tests__/chart-palette.test.ts create mode 100644 apps/web/src/utils/__tests__/distribution.test.ts create mode 100644 apps/web/src/utils/chart-palette.ts create mode 100644 apps/web/src/utils/chart-theme.ts create mode 100644 apps/web/src/utils/distribution.ts create mode 100644 testing/src/pages/_app/datahub/instruments/$instrumentId/graph.page.ts create mode 100644 testing/src/pages/_app/datahub/instruments/$instrumentId/table.page.ts create mode 100644 testing/src/pages/_app/datahub/instruments/index.page.ts create mode 100644 testing/src/specs/instrument-hub.spec.ts 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..7598fe1f4 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 @@ -212,6 +214,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 = []; @@ -234,7 +238,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 +270,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 +279,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 +302,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 +312,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 +382,46 @@ 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 { + const summaries = await this.summarizeRecords('instrumentId', groupId, ability); + return Array.from(summaries, ([instrumentId, summary]) => ({ + instrumentId, + lastCollectedAt: summary.lastCollectedAt, + recordCount: summary.recordCount, + subjectCount: summary.subjectIds.size + })); + } - 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 { + const summaries = await this.summarizeRecords('seriesInstrumentId', groupId, ability); + return Array.from(summaries, ([instrumentId, summary]) => ({ + instrumentId, + lastCollectedAt: summary.lastCollectedAt, + recordCount: summary.recordCount, + subjectCount: summary.subjectIds.size + })); + } + /** 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 +635,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 +690,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 = 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 +827,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 +838,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..ba5bd40ca 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; @@ -28,8 +31,14 @@ export type InitData = { name: string; }[]; +export type InitPayload = { + instruments: InitData; + /** 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-subject-table.test.tsx b/apps/web/src/__tests__/datahub-subject-table.test.tsx index e03edb878..7723ff72d 100644 --- a/apps/web/src/__tests__/datahub-subject-table.test.tsx +++ b/apps/web/src/__tests__/datahub-subject-table.test.tsx @@ -65,12 +65,14 @@ afterEach(() => { describe('subject table route', () => { it('should add a snake-cased column for each record field, so the headers match the exported column names', () => { renderTable([record()]); - expect(headers()).toEqual(['DATE_COLLECTED', 'TOTAL_SCORE']); + expect(headers()).toEqual(['DATE_COLLECTED', 'COLLECTION_METHOD', 'SERIES', 'TOTAL_SCORE']); }); - it('should show only the collection date column when there are no records', () => { + // The provenance columns describe where a record came from rather than what it measured, so they + // are fixed rather than derived from the records — they stand even with nothing to describe. + it('should show the date and provenance columns when there are no records', () => { renderTable([]); - expect(headers()).toEqual(['DATE_COLLECTED']); + expect(headers()).toEqual(['DATE_COLLECTED', 'COLLECTION_METHOD', 'SERIES']); }); it('should render a multiple-choice answer as comma-separated text', () => { diff --git a/apps/web/src/__tests__/datahub-table-controls.test.tsx b/apps/web/src/__tests__/datahub-table-controls.test.tsx index 50491f057..a35356306 100644 --- a/apps/web/src/__tests__/datahub-table-controls.test.tsx +++ b/apps/web/src/__tests__/datahub-table-controls.test.tsx @@ -85,6 +85,8 @@ const exportEntry = (subjectId: string): InstrumentRecordsExport[number] => ({ 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/__tests__/truncated-cell.test.tsx b/apps/web/src/__tests__/truncated-cell.test.tsx new file mode 100644 index 000000000..9be9531bb --- /dev/null +++ b/apps/web/src/__tests__/truncated-cell.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { TruncatedCell } from '@/components/TruncatedCell'; + +describe('TruncatedCell', () => { + it('should render the value', () => { + render(); + expect(screen.getByText('Baseline Battery')).toBeTruthy(); + }); + + // A clipped cell is unreadable without this: the ellipsis hides exactly the part the user wants. + it('should expose the full value on hover, so a clipped cell is still recoverable', () => { + render(); + expect(screen.getByText('A very long series name').getAttribute('title')).toBe('A very long series name'); + }); + + // Wrapping is what pushed rows to two lines; clipping to one is the whole point of the component. + it('should clip to one line rather than wrapping the row taller', () => { + render(); + const className = screen.getByText('x').getAttribute('class') ?? ''; + expect(className).toContain('whitespace-nowrap'); + expect(className).toContain('overflow-hidden'); + expect(className).toContain('text-ellipsis'); + }); + + it('should carry a test id through for the e2e suite to select on', () => { + render(); + expect(screen.getByTestId('record-cell-series')).toBeTruthy(); + }); +}); diff --git a/apps/web/src/components/InstrumentTable/InstrumentTable.tsx b/apps/web/src/components/InstrumentTable/InstrumentTable.tsx new file mode 100644 index 000000000..631afd406 --- /dev/null +++ b/apps/web/src/components/InstrumentTable/InstrumentTable.tsx @@ -0,0 +1,299 @@ +import { useMemo, useState } from 'react'; + +import { toBasicISOString } from '@douglasneuroinformatics/libjs'; +import { ActionDropdown, Button, DataTable, DropdownMenu } from '@douglasneuroinformatics/libui/components'; +import type { TanstackTable } from '@douglasneuroinformatics/libui/components'; +import { useDownload, useNotificationsStore, useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { ChevronDownIcon } from 'lucide-react'; +import { unparse } from 'papaparse'; + +import { ColorTagCell } from '@/components/ColorTagCell'; +import { SortableHeader } from '@/components/SortableHeader'; +import { TruncatedCell } from '@/components/TruncatedCell'; +import { resolveTheme } from '@/utils/chart-theme'; +import { downloadSubjectTableExcel } from '@/utils/excel'; +import { getInstrumentKindColor } from '@/utils/tag-colors'; + +/** Shown where an instrument has no source repository, or has never been collected */ +const EMPTY_CELL = '—'; + +const INSTRUMENT_KINDS = ['FORM', 'INTERACTIVE', 'FILE', 'SERIES'] as const; + +type InstrumentRow = { + edition: null | number; + id: string; + kind: string; + lastCollectedAt: Date | null; + recordCount: number; + source: null | string; + subjectCount: number; + title: string; +}; + +type KindFilter = string[]; + +/** A kind tag, matching the treatment the subject table gives sex. */ +const InstrumentKindCell = ({ kind }: { kind: string }) => { + const [theme] = useTheme(); + const { t } = useTranslation(); + const labels: { [key: string]: string } = { + 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' }) + }; + return ( + + ); +}; + +/** + * The toolbar every instrument listing shares: filter by kind and by whether anything was ever + * collected, then export exactly the rows left on screen. + */ +const Toolbar = ({ rows, table }: { rows: InstrumentRow[]; table: TanstackTable.Table }) => { + const { t } = useTranslation(); + const download = useDownload(); + const addNotification = useNotificationsStore((store) => store.addNotification); + const [isOpen, setIsOpen] = useState(false); + + const kindColumn = table.getAllColumns().find((column) => column.id === 'kind'); + const kindFilter = (kindColumn?.getFilterValue() ?? []) as KindFilter; + const recordColumn = table.getAllColumns().find((column) => column.id === 'recordCount'); + const withRecordsOnly = (recordColumn?.getFilterValue() as boolean | undefined) ?? false; + + /** Export what the table is listing, so a filtered view and its download agree. */ + const listed = useMemo(() => { + const visible = new Set(table.getFilteredRowModel().rows.map((row) => row.original.id)); + return rows.filter((row) => visible.has(row.id)); + }, [rows, table.getFilteredRowModel().rows]); + + const handleExport = (option: 'CSV' | 'Excel' | 'JSON') => { + if (listed.length === 0) { + addNotification({ + message: t({ + en: 'There is nothing to export', + es: 'No hay nada que exportar', + fr: "Il n'y a rien à exporter" + }), + type: 'error' + }); + return; + } + const exported = listed.map((row) => ({ + Edition: row.edition ?? '', + Instrument: row.title, + Kind: row.kind, + LastCollected: row.lastCollectedAt ? toBasicISOString(row.lastCollectedAt) : '', + Records: row.recordCount, + Source: row.source ?? 'manual', + Subjects: row.subjectCount + })); + const filename = `instruments_${new Date().toISOString()}`; + switch (option) { + case 'CSV': + void download(`${filename}.csv`, () => unparse(exported)); + break; + case 'Excel': + downloadSubjectTableExcel(`${filename}.xlsx`, exported, 'Instruments'); + break; + case 'JSON': + void download(`${filename}.json`, () => JSON.stringify(exported, null, 2)); + break; + } + }; + + return ( +
+ + + + + + {t({ en: 'Kind', es: 'Tipo', fr: 'Type' })} + + {INSTRUMENT_KINDS.map((kind) => ( + { + kindColumn?.setFilterValue((previous: KindFilter) => + checked ? [...previous, kind] : previous.filter((item) => item !== kind) + ); + }} + onSelect={(e) => e.preventDefault()} + > + {kind} + + ))} + + {t({ en: 'Records', es: 'Registros', fr: 'Enregistrements' })} + + recordColumn?.setFilterValue(checked)} + onSelect={(e) => e.preventDefault()} + > + {t({ en: 'Collected only', es: 'Solo con registros', fr: 'Avec enregistrements' })} + + + + + +
+ ); +}; + +type InstrumentTableProps = { + 'data-testid'?: string; + onOpen: (row: InstrumentRow) => void; + rowActions?: { label: string; onSelect: (row: InstrumentRow) => 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, rowActions = [], rows }: InstrumentTableProps) => { + const { t } = useTranslation(); + const [highlightedRowId, setHighlightedRowId] = useState(null); + + const ToolbarWithRows = useMemo(() => { + const Component = (props: { table: TanstackTable.Table }) => ; + Component.displayName = 'InstrumentTableToolbar'; + return Component; + }, [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 "collected only" filter: the value is a boolean, not a count to match. + filterFn: (row, id, onlyCollected: boolean) => !onlyCollected || row.getValue(id) > 0, + header: ({ column }) => ( + + ) + }, + { + accessorKey: 'lastCollectedAt', + cell: (ctx) => { + const value = ctx.getValue() as Date | null; + return value ? toBasicISOString(value) : EMPTY_CELL; + }, + header: ({ column }) => ( + + ) + }, + { + 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) ?? EMPTY_CELL, + 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: false } + ] + }} + rowActions={[{ label: t('common.view'), onSelect: onOpen }, ...rowActions]} + togglesComponent={ToolbarWithRows} + onRowClick={(row) => setHighlightedRowId(row.id)} + onRowDoubleClick={onOpen} + /> +
+ ); +}; + +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..8a06ed55e --- /dev/null +++ b/apps/web/src/components/RecordDistributionChart/RecordDistributionChart.tsx @@ -0,0 +1,86 @@ +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..f4f2aed77 --- /dev/null +++ b/apps/web/src/components/RecordScatterChart/RecordScatterChart.tsx @@ -0,0 +1,118 @@ +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, + 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/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__/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__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index 910fb3526..a763d4c27 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,Date,CollectionMethod,SeriesID,SeriesName,Username,someValue\r\ntestGroupId,subject-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\tDate\tCollectionMethod\tSeriesID\tSeriesName\tUsername\tsomeValue\r\ntestGroupId\tsubject-1\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,Date,CollectionMethod,SeriesID,SeriesName,Username,Variable,Value\r\ntestGroupId,subject-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\tDate\tCollectionMethod\tSeriesID\tSeriesName\tUsername\tVariable\tValue\r\ntestGroupId\tsubject-1\t${toBasicISOString(FIXED_TEST_DATE)}\t\t\t\ttestusername\tsomeValue\tabc` ); }); }); @@ -214,9 +216,12 @@ describe('useInstrumentVisualization', () => { expect(excelContents).toEqual([ { + CollectionMethod: null, Date: '2025-04-30', GroupID: 'testGroupId', - subjectId: 'testId', + SeriesID: null, + SeriesName: null, + SubjectID: 'subject-1', // eslint-disable-next-line perfectionist/sort-objects someValue: 'abc', Username: 'testusername' @@ -241,9 +246,12 @@ describe('useInstrumentVisualization', () => { expect(excelContents).toEqual([ { + CollectionMethod: null, Date: '2025-04-30', GroupID: 'testGroupId', - SubjectID: 'testId', + SeriesID: null, + SeriesName: null, + SubjectID: 'subject-1', Username: 'testusername', Value: 'abc', Variable: 'someValue' @@ -317,7 +325,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 +391,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/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/useInstrumentHubFacets.ts b/apps/web/src/hooks/useInstrumentHubFacets.ts new file mode 100644 index 000000000..0c1d1d3a1 --- /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 { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; +import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; +import { useSeriesNames } from '@/hooks/useSeriesNames'; +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 seriesNames = useSeriesNames(); + + 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 ? (seriesNames[seriesId] ?? null) : null); + } + return options; + }, [recordsQuery.data, seriesNames]); + + 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..7ad64660e --- /dev/null +++ b/apps/web/src/hooks/useInstrumentHubRecords.ts @@ -0,0 +1,48 @@ +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) => { + if (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/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/useInstrumentTitles.ts b/apps/web/src/hooks/useInstrumentTitles.ts new file mode 100644 index 000000000..ab9c9e856 --- /dev/null +++ b/apps/web/src/hooks/useInstrumentTitles.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react'; + +import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; + +/** + * Every accessible instrument's id mapped to its title. + * + * A record names only the instrument's id, which is all a per-instrument view needs — but a series + * view shows records from several instruments at once and has to name each one. + */ +export function useInstrumentTitles(): { [id: string]: string } { + const query = useInstrumentInfoQuery({ params: { allEditions: true } }); + return useMemo( + () => Object.fromEntries((query.data ?? []).map((info) => [info.id, info.details.title])), + [query.data] + ); +} diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 212bdf177..ab93502cc 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -3,7 +3,9 @@ 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'; @@ -11,21 +13,60 @@ import { unparse } from 'papaparse'; import { useInstrument } from '@/hooks/useInstrument'; import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { useInstrumentRecords } from '@/hooks/useInstrumentRecords'; +import { useSeriesNames } from '@/hooks/useSeriesNames'; 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,47 @@ 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 seriesNames = useSeriesNames(); 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 +126,81 @@ 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, + 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.Date = meta.date; + row.CollectionMethod = meta.collectionMethod; + row.SeriesID = meta.seriesId; + row.SeriesName = meta.seriesName; + row.Username = meta.username; + return row; + }; 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, value] of Object.entries(measures)) { + row[key] = 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 +233,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 +270,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 ? (seriesNames[seriesId] ?? null) : null, + __subjectId__: record.subjectId, __time__: record.date.getTime(), username: record.session?.user?.username ?? 'N/A', ...record.computedMeasures, @@ -241,7 +300,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio type: 'error' }); } - }, [recordsQuery.data]); + }, [recordsQuery.data, seriesNames]); const instrumentOptions: { [key: string]: string } = useMemo(() => { // only show the latest edition of each instrument; older editions are selectable via editionOptions @@ -262,24 +321,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..f2e414dc6 --- /dev/null +++ b/apps/web/src/hooks/useRecordChartSeries.ts @@ -0,0 +1,126 @@ +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'; + +type ColourBy = 'method' | 'none' | 'series'; + +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 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..db36100a4 --- /dev/null +++ b/apps/web/src/hooks/useRecordMetadataColumns.tsx @@ -0,0 +1,58 @@ +import { useMemo } from 'react'; + +import { TanstackTable } from '@douglasneuroinformatics/libui/components'; +import type { SessionType } from '@opendatacapture/schemas/session'; + +import { TruncatedCell } from '@/components/TruncatedCell'; +import { useCollectionMethodLabels } from '@/hooks/useCollectionMethodLabels'; +import type { InstrumentVisualizationRecord } from '@/hooks/useInstrumentVisualization'; + +/** Shown where a record carries no series, or a session whose type could not be resolved */ +const EMPTY_CELL = '—'; + +/** + * 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(); + return useMemo(() => { + const columns: TanstackTable.ColumnDef[] = [ + { + accessorKey: '__method__', + cell: (ctx) => { + const value = ctx.getValue() as null | SessionType; + const label = value ? collectionMethodLabels[value] : EMPTY_CELL; + return ; + }, + filterFn: (row, id, filter: SessionType[]) => filter.includes(row.getValue(id)), + header: 'COLLECTION_METHOD', + 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) ?? EMPTY_CELL; + 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__ ?? ''), + header: 'SERIES', + id: '__seriesName__' + }); + return columns; + }, [collectionMethodLabels, omitSeries]); +} diff --git a/apps/web/src/hooks/useSeriesNames.ts b/apps/web/src/hooks/useSeriesNames.ts new file mode 100644 index 000000000..242b36d04 --- /dev/null +++ b/apps/web/src/hooks/useSeriesNames.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; + +import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; + +/** + * Maps every accessible series instrument's id to its title. + * + * A record names the series that collected it by id alone, and a series instrument carries no + * `internal.name` to fall back on, so the title has to be looked up separately from the scalar + * instrument the record was collected with. + */ +export function useSeriesNames(): { [id: string]: string } { + const query = useInstrumentInfoQuery({ params: { kind: 'SERIES' } }); + return useMemo( + () => Object.fromEntries((query.data ?? []).map((info) => [info.id, info.details.title])), + [query.data] + ); +} 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..69856c354 --- /dev/null +++ b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/graph.tsx @@ -0,0 +1,189 @@ +import { Select, Tabs } from '@douglasneuroinformatics/libui/components'; +import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { createFileRoute } from '@tanstack/react-router'; + +import { RecordDistributionChart } from '@/components/RecordDistributionChart'; +import { RecordScatterChart } from '@/components/RecordScatterChart'; +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 { 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. Shows + * one dot when there is no grouping, because only the first slot is used then. + */ +const PaletteSwatch = ({ palette, slots }: { palette: ChartPaletteName; slots: number }) => { + const [theme] = useTheme(); + return ( + + {getPaletteSwatch(palette, resolveTheme(theme)) + .slice(0, slots) + .map((color) => ( + + ))} + + ); +}; + +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 paletteLabels: { [K in ChartPaletteName]: string } = { + berry: t({ en: 'Berry', es: 'Baya', fr: 'Baie' }), + default: t({ en: 'Default', es: 'Predeterminada', fr: 'Par défaut' }), + jade: t({ en: 'Jade', es: 'Jade', fr: 'Jade' }) + }; + + const measureOptions = useMeasureOptions(instrument); + const selectedMeasure = measure ?? measureOptions[0]?.key; + const measureLabel = measureOptions.find((option) => option.key === selectedMeasure)?.label ?? ''; + + 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' })} + + + +
+ + + +
+
+
+ {!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..2a855e4f7 --- /dev/null +++ b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx @@ -0,0 +1,176 @@ +import React, { useState } from 'react'; + +import { Button, DropdownMenu, Heading } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { $SessionType } from '@opendatacapture/schemas/session'; +import type { SessionType } from '@opendatacapture/schemas/session'; +import { createFileRoute, Outlet } from '@tanstack/react-router'; +import { ChevronDownIcon } from 'lucide-react'; +import { z } from 'zod/v4'; + +import { LoadingFallback } from '@/components/LoadingFallback'; +import { PageHeader } from '@/components/PageHeader'; +import { SelectEdition } from '@/components/SelectEdition'; +import { TabLink } from '@/components/TabLink'; +import { TimeDropdown } from '@/components/TimeDropdown'; +import { useCollectionMethodLabels } from '@/hooks/useCollectionMethodLabels'; +import { NO_SERIES, useInstrumentHubFacets } from '@/hooks/useInstrumentHubFacets'; +import { CHART_PALETTE_NAMES } from '@/utils/chart-palette'; + +const COLLECTION_METHODS = $SessionType.options; + +/** Shown for the series option standing in for records collected outside any series */ +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); + + // An absent `series` search param means every series; the dropdown has to materialise that into + // an explicit list before it can remove one from it. + 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 ? [...methods, method] : methods.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()} + > + {/* A record may name a series the caller cannot read, leaving the id as the only label */} + {id === NO_SERIES ? t({ en: 'Individual', es: 'Individual', fr: 'Individuel' }) : (name ?? id)} + + ))} + + + + ); +}; + +const RouteComponent = () => { + const navigate = Route.useNavigate(); + const params = Route.useParams(); + const search = Route.useSearch(); + const { t } = useTranslation(); + const { editionOptions, isSeries, seriesOptions, title } = useInstrumentHubFacets(); + + const basePathname = `/datahub/instruments/${params.instrumentId}`; + + return ( + + + + {title ?? t({ en: 'Instrument', es: 'Instrumento', fr: 'Instrument' })} + + +
+
+ {!isSeries && ( + { + void navigate({ params: { instrumentId: id }, search, to: '.' }); + }} + /> + )} +
+
+ { + void navigate({ search: (prev) => ({ ...prev, minDate: minDate ?? undefined }) }); + }} + /> + +
+
+ {/* A series has one view, so a lone "Table" tab would label nothing. */} +
+ {!isSeries && ( + + )} + {/* A series spans instruments with no measure in common, so there is nothing to plot. */} + {!isSeries && ( + + )} +
+ }> + + +
+ ); +}; + +export const Route = createFileRoute('/_app/datahub/instruments/$instrumentId')({ + component: RouteComponent, + validateSearch: z.object({ + chart: z.enum(['scatter', 'distribution']).default('scatter'), + colourBy: z.enum(['none', 'method', 'series']).default('none'), + /** + * One measure, not several: both chart forms put the measure on an axis and spend colour on the + * grouping dimension instead, and a second measure of a different scale would need a second + * y-axis. + */ + measure: z.string().optional(), + methods: $SessionType.array().default(COLLECTION_METHODS), + minDate: z.coerce.date().optional(), + palette: z.enum(CHART_PALETTE_NAMES).default('default'), + /** Absent means every series, which an empty array deliberately does not */ + 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..38545b2e1 --- /dev/null +++ b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/table.tsx @@ -0,0 +1,198 @@ +import { useMemo, useState } from 'react'; + +import { camelToSnakeCase, toBasicISOString } from '@douglasneuroinformatics/libjs'; +import { ActionDropdown, DataTable, TanstackTable } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; + +import { InstrumentTable } from '@/components/InstrumentTable'; +import type { InstrumentRow } from '@/components/InstrumentTable'; +import { TruncatedCell } from '@/components/TruncatedCell'; +import { useInstrumentHubFacets } from '@/hooks/useInstrumentHubFacets'; +import { useInstrumentHubRecords } from '@/hooks/useInstrumentHubRecords'; +import { useInstrumentTitles } from '@/hooks/useInstrumentTitles'; +import type { InstrumentVisualizationRecord } from '@/hooks/useInstrumentVisualization'; +import { useRecordMetadataColumns } from '@/hooks/useRecordMetadataColumns'; +import { useAppStore } from '@/store'; +import { formatRecordValue } from '@/utils/record-value'; + +/** The record fields rendered as fixed columns rather than as the instrument's own measures */ +const FIXED_COLUMN_PREFIX = '__'; + +/** + * The instruments a series is made of, counted from the records that series orchestrated. + * + * Deliberately derived from the already-loaded records rather than fetched: these totals have to be + * what this series collected, not the instrument's global ones, which is what a per-instrument + * summary would give. + */ +function useSeriesMemberRows(records: InstrumentVisualizationRecord[], titles: { [id: string]: 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: null, + id, + kind: 'FORM', + lastCollectedAt: member.lastCollectedAt, + recordCount: member.total, + source: null, + subjectCount: member.subjectIds.size, + title: titles[id] ?? id + })); + }, [records, titles]); +} + +const RouteComponent = () => { + const navigate = useNavigate(); + const [highlightedRowId, setHighlightedRowId] = useState(null); + const { dl, records } = useInstrumentHubRecords(); + const { isSeries } = useInstrumentHubFacets(); + const instrumentTitles = useInstrumentTitles(); + const metadataColumns = useRecordMetadataColumns({ omitSeries: isSeries }); + const subjectIdDisplaySetting = useAppStore((store) => store.currentGroup?.settings.subjectIdDisplayLength); + + const { t } = useTranslation(); + const seriesMembers = useSeriesMemberRows(records, instrumentTitles); + + const measureColumns = useMemo[]>(() => { + const columns: TanstackTable.ColumnDef[] = []; + // A series spans instruments whose measures have nothing in common, so a shared measure column + // would be empty for most rows. It names the instrument each record came from instead. + if (isSeries) { + return [ + { + accessorFn: (record) => instrumentTitles[record.__instrumentId__] ?? record.__instrumentId__, + cell: (ctx) => { + const title = ctx.getValue() as string; + return ; + }, + header: 'INSTRUMENT', + 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: camelToSnakeCase(key).toUpperCase(), + id: key + }); + } + } + return columns; + }, [instrumentTitles, isSeries, records[0]]); + + const viewRecord = (record: InstrumentVisualizationRecord) => { + void navigate({ + params: { recordId: record.__id__, subjectId: record.__subjectId__ }, + to: '/datahub/subjects/$subjectId/table/$recordId' + }); + }; + + // A series lists what it is made of, and opening a member goes to that instrument's own page — + // where its subjects and records read exactly as any other instrument's do. + if (isSeries) { + return ( + { + void navigate({ params: { instrumentId: row.id }, to: '/datahub/instruments/$instrumentId/table' }); + }} + /> + ); + } + + return ( +
+
+ +
+
+ ( + + {removeSubjectIdScope(ctx.getValue() as string).slice(0, subjectIdDisplaySetting ?? 9)} + + + ), + header: 'SUBJECT', + id: '__subjectId__' + }, + { + accessorKey: '__date__', + cell: (ctx) => { + const value = ctx.getValue(); + return value instanceof Date ? toBasicISOString(value) : value; + }, + header: 'DATE_COLLECTED' + }, + ...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__ }, + to: '/datahub/subjects/$subjectId/table' + }); + } + } + ]} + // libui gates the row hover highlight on the presence of `onRowClick`, so recording the + // clicked row is what turns it on — the same thing the subject table already does. + 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..b280e754f --- /dev/null +++ b/apps/web/src/routes/_app/datahub/instruments/index.tsx @@ -0,0 +1,123 @@ +import React, { useMemo, useState } from 'react'; + +import { Heading } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; + +import { InstrumentPreviewDialog } from '@/components/InstrumentPreviewDialog'; +import type { InstrumentPreviewItem } from '@/components/InstrumentPreviewDialog'; +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 [preview, setPreview] = useState(null); + 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 infoById = useMemo( + () => new Map((instrumentInfoQuery.data ?? []).map((info) => [info.id, info])), + [instrumentInfoQuery.data] + ); + + /** + * The shape the shared preview dialog takes. `items` is only consulted to name a series' children, + * and this list has no series in it, so the dialog is handed an empty one. + */ + 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 + }; + }; + + 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' + })} + + + setPreview(toPreviewItem(row)) + } + ]} + rows={data} + onOpen={openInstrument} + /> + {preview && setPreview(null)} />} + + ); +}; + +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..c3a4b0452 100644 --- a/apps/web/src/routes/_app/datahub/subjects/$subjectId/graph.tsx +++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/graph.tsx @@ -4,7 +4,6 @@ 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 { createFileRoute } from '@tanstack/react-router'; import html2canvas from 'html2canvas'; @@ -31,7 +30,7 @@ const RouteComponent = () => { } }); const { t } = useTranslation(); - const measureOptions = useMeasureOptions(instrument as AnyUnilingualFormInstrument); + const measureOptions = useMeasureOptions(instrument); const [selectedMeasures, setSelectedMeasures] = useState([]); const linearModelQuery = useLinearModelQuery({ 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/translations/datahub.json b/apps/web/src/translations/datahub.json index 4a5ee6756..0b8a9096f 100644 --- a/apps/web/src/translations/datahub.json +++ b/apps/web/src/translations/datahub.json @@ -105,9 +105,9 @@ "fr": "Exporter" }, "exportHelpText": { - "en": "This data is provided in an ultra-long format. Each row corresponds to the value for a single measure. Rows corresponding to the same record will have matching subject identifiers and timestamps.", - "es": "Estos datos se proporcionan en un formato ultralargo. Cada fila corresponde al valor de una sola medida. Las filas correspondientes al mismo registro tendrán identificadores de sujeto y marcas de tiempo coincidentes.", - "fr": "Ces données sont fournies dans un format ultra-long. Chaque ligne correspond à la valeur d'une seule mesure. Les lignes correspondant à un même enregistrement auront des identifiants de sujet et des horodatages correspondants." + "en": "This data is provided in an ultra-long format. Each row corresponds to the value for a single measure. Rows corresponding to the same record will have matching subject identifiers and timestamps. The sessionType column is the data collection method. The seriesId and seriesName columns name the series that collected the record, and are empty for a record collected individually.", + "es": "Estos datos se proporcionan en un formato ultralargo. Cada fila corresponde al valor de una sola medida. Las filas correspondientes al mismo registro tendrán identificadores de sujeto y marcas de tiempo coincidentes. La columna sessionType es el método de recopilación de datos. Las columnas seriesId y seriesName indican la serie que recopiló el registro y están vacías si el registro se recopiló individualmente.", + "fr": "Ces données sont fournies dans un format ultra-long. Chaque ligne correspond à la valeur d'une seule mesure. Les lignes correspondant à un même enregistrement auront des identifiants de sujet et des horodatages correspondants. La colonne sessionType correspond à la méthode de collecte des données. Les colonnes seriesId et seriesName désignent la série qui a collecté l'enregistrement et sont vides pour un enregistrement collecté individuellement." }, "filters": { "en": "Filters", diff --git a/apps/web/src/translations/session.json b/apps/web/src/translations/session.json index a3e43fa5c..51ce7d1ca 100644 --- a/apps/web/src/translations/session.json +++ b/apps/web/src/translations/session.json @@ -50,6 +50,11 @@ "en": "Retrospective", "es": "Retrospectiva", "fr": "Rétrospective" + }, + "remote": { + "en": "Remote", + "es": "A distancia", + "fr": "À distance" } } } 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..c685f88b7 --- /dev/null +++ b/apps/web/src/utils/__tests__/chart-palette.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { + CHART_PALETTE_NAMES, + CHART_PALETTE_THEMES, + getCategoricalColor, + 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', () => { + it.each(Object.keys(CHART_PALETTE_THEMES))('should give the %s theme a colour per slot in both modes', (name) => { + const theme = CHART_PALETTE_THEMES[name as keyof typeof CHART_PALETTE_THEMES]; + expect(theme.light).toHaveLength(MAX_CATEGORICAL_GROUPS); + expect(theme.dark).toHaveLength(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-teal rather than the orange', () => { + expect(CHART_PALETTE_THEMES.jade.light[0]).toBe('#1baf7a'); + expect(CHART_PALETTE_THEMES.jade.dark[0]).toBe('#199e70'); + }); + + 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. + it('should fall back to the neutral mark past the last slot rather than cycling', () => { + expect(getCategoricalColor(MAX_CATEGORICAL_GROUPS, 'light', 'default')).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__/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-palette.ts b/apps/web/src/utils/chart-palette.ts new file mode 100644 index 000000000..568583c92 --- /dev/null +++ b/apps/web/src/utils/chart-palette.ts @@ -0,0 +1,84 @@ +/** + * The categorical palettes for charts that colour marks by a grouping dimension. + * + * Three slots per theme, not more: a scatter puts every pair of series on screen at once, and only + * a three-colour set clears the colourblind and normal-vision separation floors against both + * surfaces as an all-pairs set. A grouping with more values folds its tail into `OTHER_GROUP_KEY` + * rather than growing the palette. Each theme's dark column is the same three hues re-stepped for + * the dark surface, not an automatic inversion of the light one. + * + * Every theme was verified with the data-visualization skill's palette validator at `--pairs all` + * in both modes, and all three clear the 6–8 CVD band outright rather than relying on secondary + * encoding — worst-pair CVD ΔE: default 9.2 light / 9.4 dark, berry 13.0 / 13.0, jade 9.2 / 9.4. + * + * Slot order matters as well as membership: slot one is what an ungrouped chart draws with and what + * the first group takes, so each theme leads with the hue it is named for. `jade` therefore carries + * the same three hues as the set it replaced, led by the green-teal instead of the orange — it is + * the only green-led trio that passes, since jade with magenta and yellow collapses to ΔE 1.6 in + * dark and green with blue and orange fails outright in both modes. + * + * The hues of a theme are deliberately spread around the wheel. Sets drawn from one side of it — + * an all-warm orange/red/yellow, or a blue/violet/aqua — were tried and rejected: they fail the + * separation floors outright (orange↔red ΔE 5.6, blue↔violet 1.9 in dark), because small hue gaps + * cannot survive a colourblind transform. A single-hue ramp is likewise absent: it fails the + * ordinal light-end contrast check, and a nominal grouping is not a magnitude to shade anyway. + * + * Several light-mode slots sit below 3:1 against the light surface, which obliges the relief the + * charts already carry: a legend, and the sibling table showing the same records. + */ +const CHART_PALETTE_THEMES = { + berry: { + dark: ['#d55181', '#008300', '#3987e5'], + light: ['#e87ba4', '#008300', '#2a78d6'] + }, + default: { + dark: ['#3987e5', '#d95926', '#199e70'], + light: ['#2a78d6', '#eb6834', '#1baf7a'] + }, + jade: { + dark: ['#199e70', '#9085e9', '#d95926'], + light: ['#1baf7a', '#4a3aa7', '#eb6834'] + } +} as const; + +type ChartPaletteName = keyof typeof CHART_PALETTE_THEMES; + +/** + * Dropdown order, with `default` pinned first. Written out rather than derived from the themes + * object, whose key order is alphabetical because eslint sorts object literals — which would bury + * the default in the middle. `chart-palette.test.ts` pins both the leading entry and completeness. + */ +const CHART_PALETTE_NAMES = ['default', 'berry', 'jade'] 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__'; + +const MAX_CATEGORICAL_GROUPS = CHART_PALETTE_THEMES.default.light.length; + +function getCategoricalColor(index: number, theme: 'dark' | 'light', palette: ChartPaletteName): string { + return CHART_PALETTE_THEMES[palette][theme][index] ?? NEUTRAL_MARK[theme]; +} + +/** 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, + 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..eeaeed70d --- /dev/null +++ b/apps/web/src/utils/chart-theme.ts @@ -0,0 +1,46 @@ +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' +}; + +const tooltipStyles: { [K in Theme]: React.CSSProperties } = { + dark: { + backgroundColor: '#0f172a', + borderColor: inkColors.light, + borderRadius: '2px' + }, + light: { + backgroundColor: '#f1f5f9', + 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, inkColors, resolveTheme, tooltipStyles, X_AXIS_HEIGHT, X_AXIS_LABEL_OFFSET, Y_AXIS_WIDTH }; 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..6e8722139 100644 --- a/apps/web/src/utils/tag-colors.ts +++ b/apps/web/src/utils/tag-colors.ts @@ -1,18 +1,23 @@ +import { getCategoricalColor, NEUTRAL_MARK } from '@/utils/chart-palette'; + type Mode = 'dark' | 'light'; -/** Grey, for a subject whose sex was never recorded */ -const NEUTRAL_MARK = { - dark: '#a3a3a3', - light: '#525252' -} as const; +/** + * Colours for the nominal values tagged in tables. + * + * Values come from the validated categorical palette rather than new hex, so the app holds one set + * of nominal colours instead of a second ad-hoc one that drifts. Each is paired with its label + * wherever it is rendered, so the colour is redundant rather than load-bearing — which is what makes + * it safe for a colourblind reader, and why a theme's slot separation is not a constraint here. + */ /** - * 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', @@ -26,4 +31,11 @@ function getSexColor(sex: null | string, mode: Mode): string { return NEUTRAL_MARK[mode]; } -export { getSexColor }; +/** One slot per instrument kind, in the order a reader is most likely to meet them. */ +function getInstrumentKindColor(kind: string, mode: Mode): string { + const slots: { [key: string]: number } = { FILE: 2, FORM: 0, INTERACTIVE: 1 }; + const slot = slots[kind]; + return slot === undefined ? NEUTRAL_MARK[mode] : getCategoricalColor(slot, mode, 'default'); +} + +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..de7b6ecfc --- /dev/null +++ b/testing/src/specs/instrument-hub.spec.ts @@ -0,0 +1,199 @@ +import { readFile } from 'node:fs/promises'; + +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'); + }); + + test('should not list series instruments, which hold no records of their own', async ({ + isolatedGroupManager, + page + }) => { + await isolatedGroupManager(); + const hubPage = new InstrumentHubPage(page); + await hubPage.goto('/datahub/instruments'); + + await expect(hubPage.rows.first()).toBeVisible(); + await expect(hubPage.rows).not.toContainText('SERIES'); + }); + + // 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, + getPageModel, + 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'); + + const graphPage = await getPageModel('/datahub/instruments/$instrumentId/graph', { instrumentId }); + 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, From f6054c1727c66bd7884352150f0d81bbab8e52dd Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Mon, 5 Oct 2026 16:52:01 -0400 Subject: [PATCH 2/4] fix(web): address the instrument hub review Correctness: - the kind filter rendered the raw enum, so the menu offered "FORM" while the cell beside it said "Form". Both now read one `useInstrumentKindLabels` map, keyed over `InstrumentKind` so a new kind fails to compile rather than reaching the screen untranslated - the method filter exempted a record carrying no method, so unchecking every method still left rows on screen. `methods` becomes optional, matching `series`: absent means every method, and a record with none survives the unfiltered view but no explicit selection - a series' member rows were all tagged FORM; the member's own kind is carried through instead - the scatter chart read its mark ring off the tooltip's background, so restyling the tooltip moved the ring. `CHART_SURFACE` names it Dead code from the WIP snapshot: an export-options ternary after the early return that made it unreachable, and a doc block attached to nothing in `tag-colors.ts`. De-duplication: `useSeriesNames` and `useInstrumentTitles` were the same four lines twice, so they collapse into `useInstrumentInfoById(params)`, which carries the kind the member table needed. The two identical summary mappers share `toInstrumentSummaries`. `COLOUR_BY_OPTIONS` is now the single source the search param derives from. `InitData` is renamed `InitInstruments`, since it names only the instruments. `useSubjectRecordSummaryQuery` and `useInstrumentRecordSummaryQuery` are deliberately left alone: the shape they share is the queryOptions-plus- hook pairing that apps/web/AGENTS.md requires of every prefetched hook. Tests: the seven new hooks and `InstrumentTable` gain unit tests. The e2e asserting the hub lists no series is replaced by one that drives what it now does, since the original passed only because the fixture group had none. Co-Authored-By: Claude Opus 5 --- .../instrument-records.service.ts | 40 +++--- .../src/instrument-records/thread-types.ts | 4 +- .../src/__tests__/instrument-table.test.tsx | 120 ++++++++++++++++ .../InstrumentTable/InstrumentTable.tsx | 26 ++-- .../RecordScatterChart/RecordScatterChart.tsx | 3 +- .../useCollectionMethodLabels.test.ts | 32 +++++ .../__tests__/useInstrumentHubFacets.test.ts | 99 +++++++++++++ .../__tests__/useInstrumentHubRecords.test.ts | 132 ++++++++++++++++++ .../__tests__/useInstrumentInfoById.test.ts | 60 ++++++++ .../__tests__/useInstrumentKindLabels.test.ts | 34 +++++ .../useInstrumentRecordSummaryQuery.test.ts | 81 +++++++++++ .../useRecordMetadataColumns.test.tsx | 97 +++++++++++++ apps/web/src/hooks/useInstrumentHubFacets.ts | 8 +- apps/web/src/hooks/useInstrumentHubRecords.ts | 5 +- apps/web/src/hooks/useInstrumentInfoById.ts | 28 ++++ apps/web/src/hooks/useInstrumentKindLabels.ts | 18 +++ apps/web/src/hooks/useInstrumentTitles.ts | 17 --- .../src/hooks/useInstrumentVisualization.ts | 8 +- apps/web/src/hooks/useRecordChartSeries.ts | 6 +- .../src/hooks/useRecordMetadataColumns.tsx | 3 +- apps/web/src/hooks/useSeriesNames.ts | 18 --- .../instruments/$instrumentId/route.tsx | 15 +- .../instruments/$instrumentId/table.tsx | 30 ++-- apps/web/src/utils/chart-theme.ts | 27 +++- apps/web/src/utils/tag-colors.ts | 24 ++-- testing/src/specs/instrument-hub.spec.ts | 26 +++- 26 files changed, 839 insertions(+), 122 deletions(-) create mode 100644 apps/web/src/__tests__/instrument-table.test.tsx create mode 100644 apps/web/src/hooks/__tests__/useCollectionMethodLabels.test.ts create mode 100644 apps/web/src/hooks/__tests__/useInstrumentHubFacets.test.ts create mode 100644 apps/web/src/hooks/__tests__/useInstrumentHubRecords.test.ts create mode 100644 apps/web/src/hooks/__tests__/useInstrumentInfoById.test.ts create mode 100644 apps/web/src/hooks/__tests__/useInstrumentKindLabels.test.ts create mode 100644 apps/web/src/hooks/__tests__/useInstrumentRecordSummaryQuery.test.ts create mode 100644 apps/web/src/hooks/__tests__/useRecordMetadataColumns.test.tsx create mode 100644 apps/web/src/hooks/useInstrumentInfoById.ts create mode 100644 apps/web/src/hooks/useInstrumentKindLabels.ts delete mode 100644 apps/web/src/hooks/useInstrumentTitles.ts delete mode 100644 apps/web/src/hooks/useSeriesNames.ts diff --git a/apps/api/src/instrument-records/instrument-records.service.ts b/apps/api/src/instrument-records/instrument-records.service.ts index 7598fe1f4..625df8453 100644 --- a/apps/api/src/instrument-records/instrument-records.service.ts +++ b/apps/api/src/instrument-records/instrument-records.service.ts @@ -45,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( @@ -224,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) => { @@ -390,13 +406,7 @@ export class InstrumentRecordsService { { groupId }: { groupId?: string } = {}, { ability }: Required> ): Promise { - const summaries = await this.summarizeRecords('instrumentId', groupId, ability); - return Array.from(summaries, ([instrumentId, summary]) => ({ - instrumentId, - lastCollectedAt: summary.lastCollectedAt, - recordCount: summary.recordCount, - subjectCount: summary.subjectIds.size - })); + return toInstrumentSummaries(await this.summarizeRecords('instrumentId', groupId, ability)); } /** @@ -407,13 +417,7 @@ export class InstrumentRecordsService { { groupId }: { groupId?: string } = {}, { ability }: Required> ): Promise { - const summaries = await this.summarizeRecords('seriesInstrumentId', groupId, ability); - return Array.from(summaries, ([instrumentId, summary]) => ({ - instrumentId, - lastCollectedAt: summary.lastCollectedAt, - recordCount: summary.recordCount, - subjectCount: summary.subjectIds.size - })); + return toInstrumentSummaries(await this.summarizeRecords('seriesInstrumentId', groupId, ability)); } /** Per-subject collection statistics for the subject hub's listing. */ @@ -728,7 +732,7 @@ export class InstrumentRecordsService { by: 'instrumentId' | 'seriesInstrumentId' | 'subjectId', groupId: string | undefined, ability: AppAbility - ): Promise }>> { + ): Promise { if (groupId) { await this.groupsService.findById(groupId); } @@ -782,7 +786,7 @@ export class InstrumentRecordsService { subjectId: null | string; }[]; - const summaries = new Map }>(); + 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 }))) { diff --git a/apps/api/src/instrument-records/thread-types.ts b/apps/api/src/instrument-records/thread-types.ts index ba5bd40ca..96040526f 100644 --- a/apps/api/src/instrument-records/thread-types.ts +++ b/apps/api/src/instrument-records/thread-types.ts @@ -25,14 +25,14 @@ export type RecordType = { }; }; -export type InitData = { +export type InitInstruments = { edition: number; id: string; name: string; }[]; export type InitPayload = { - instruments: InitData; + instruments: InitInstruments; /** Series instrument id to its title, since a series carries no language-independent name */ seriesNames: { [id: string]: string }; }; diff --git a/apps/web/src/__tests__/instrument-table.test.tsx b/apps/web/src/__tests__/instrument-table.test.tsx new file mode 100644 index 000000000..6e4d94a8a --- /dev/null +++ b/apps/web/src/__tests__/instrument-table.test.tsx @@ -0,0 +1,120 @@ +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { InstrumentTable } from '@/components/InstrumentTable'; +import type { InstrumentRow } from '@/components/InstrumentTable'; + +import '@/services/i18n'; + +const mocks = vi.hoisted(() => ({ + addNotification: vi.fn(), + download: vi.fn(), + downloadExcel: vi.fn() +})); + +vi.mock('@douglasneuroinformatics/libui/hooks', async (importOriginal) => ({ + ...(await importOriginal()), + useDownload: () => mocks.download, + useNotificationsStore: (selector: (store: { addNotification: typeof mocks.addNotification }) => unknown) => + selector({ addNotification: mocks.addNotification }) +})); +vi.mock('@/utils/excel', () => ({ downloadSubjectTableExcel: mocks.downloadExcel })); + +const row = (overrides: Partial = {}): InstrumentRow => ({ + edition: 1, + id: 'hq-1', + kind: 'FORM', + lastCollectedAt: new Date('2025-02-02'), + recordCount: 3, + source: null, + subjectCount: 2, + title: 'Happiness Questionnaire', + ...overrides +}); + +const renderTable = (rows: InstrumentRow[], onOpen = vi.fn()) => { + render(); + return onOpen; +}; + +const listedTitles = () => + screen.queryAllByTestId('data-table-row').map((tableRow) => tableRow.querySelector('div')!.textContent?.trim() ?? ''); + +const openFilters = () => fireEvent.keyDown(screen.getByTestId('instrument-table-filters-trigger'), { key: 'Enter' }); + +describe('InstrumentTable', () => { + beforeEach(() => { + vi.clearAllMocks(); + i18n.changeLanguage('en'); + }); + + afterEach(cleanup); + + it('should list a row per instrument it is given', () => { + renderTable([row(), row({ id: 'stroop-1', kind: 'INTERACTIVE', title: 'Stroop Task' })]); + expect(listedTitles()).toStrictEqual(['Happiness Questionnaire', 'Stroop Task']); + }); + + it('should show the record and subject counts as given, not one derived from the other', () => { + renderTable([row({ recordCount: 3, subjectCount: 2 })]); + const cells = screen.getAllByTestId('data-table-row')[0]!.textContent; + expect(cells).toContain('3'); + expect(cells).toContain('2'); + }); + + it('should tag the kind with its translated name rather than the stored enum', () => { + renderTable([row({ kind: 'INTERACTIVE' })]); + expect(screen.getByTestId('instrument-cell-kind').textContent).toBe('Interactive'); + }); + + // The filter menu narrows on the same values the cells show, so offering the raw enum there while + // the cell is translated would read as two different vocabularies for one column. + it('should offer translated kind names in the filter menu', () => { + renderTable([row()]); + openFilters(); + expect(screen.getByTestId('instrument-table-filter-kind-INTERACTIVE').textContent).toBe('Interactive'); + expect(screen.getByTestId('instrument-table-filter-kind-SERIES').textContent).toBe('Series'); + }); + + it('should hide an instrument whose kind is unchecked', () => { + renderTable([row(), row({ id: 'stroop-1', kind: 'INTERACTIVE', title: 'Stroop Task' })]); + openFilters(); + fireEvent.click(screen.getByTestId('instrument-table-filter-kind-FORM')); + expect(listedTitles()).toStrictEqual(['Stroop Task']); + }); + + it('should hide instruments nothing was ever collected for when that filter is checked', () => { + renderTable([row(), row({ id: 'never-1', recordCount: 0, subjectCount: 0, title: 'Never Collected' })]); + openFilters(); + fireEvent.click(screen.getByTestId('instrument-table-filter-has-records')); + expect(listedTitles()).toStrictEqual(['Happiness Questionnaire']); + }); + + it('should open an instrument when its row is double-clicked', () => { + const onOpen = renderTable([row()]); + fireEvent.doubleClick(screen.getAllByTestId('data-table-row')[0]!); + expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ id: 'hq-1' })); + }); + + it('should match the search against the instrument title', () => { + renderTable([row(), row({ id: 'stroop-1', title: 'Stroop Task' })]); + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'stroop' } }); + expect(listedTitles()).toStrictEqual(['Stroop Task']); + }); + + // The export is of what the table is listing, so a filtered view and its download agree. + it('should export only the rows left after filtering', () => { + renderTable([row(), row({ id: 'stroop-1', kind: 'INTERACTIVE', title: 'Stroop Task' })]); + openFilters(); + fireEvent.click(screen.getByTestId('instrument-table-filter-kind-FORM')); + // The open filter menu makes the rest of the page inert, so it has to be dismissed first. + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }); + fireEvent.keyDown(screen.getByRole('button', { name: 'Export' }), { key: 'Enter' }); + fireEvent.click(screen.getByRole('menuitem', { name: 'CSV' })); + const [, getContents] = mocks.download.mock.lastCall!; + const csv = (getContents as () => string)(); + expect(csv).toContain('Stroop Task'); + expect(csv).not.toContain('Happiness Questionnaire'); + }); +}); diff --git a/apps/web/src/components/InstrumentTable/InstrumentTable.tsx b/apps/web/src/components/InstrumentTable/InstrumentTable.tsx index 631afd406..1d76334c2 100644 --- a/apps/web/src/components/InstrumentTable/InstrumentTable.tsx +++ b/apps/web/src/components/InstrumentTable/InstrumentTable.tsx @@ -4,12 +4,14 @@ import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import { ActionDropdown, Button, DataTable, DropdownMenu } from '@douglasneuroinformatics/libui/components'; import type { TanstackTable } from '@douglasneuroinformatics/libui/components'; import { useDownload, useNotificationsStore, useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { InstrumentKind } from '@opendatacapture/runtime-core'; import { ChevronDownIcon } from 'lucide-react'; import { unparse } from 'papaparse'; import { ColorTagCell } from '@/components/ColorTagCell'; import { SortableHeader } from '@/components/SortableHeader'; import { TruncatedCell } from '@/components/TruncatedCell'; +import { useInstrumentKindLabels } from '@/hooks/useInstrumentKindLabels'; import { resolveTheme } from '@/utils/chart-theme'; import { downloadSubjectTableExcel } from '@/utils/excel'; import { getInstrumentKindColor } from '@/utils/tag-colors'; @@ -17,12 +19,13 @@ import { getInstrumentKindColor } from '@/utils/tag-colors'; /** Shown where an instrument has no source repository, or has never been collected */ const EMPTY_CELL = '—'; -const INSTRUMENT_KINDS = ['FORM', 'INTERACTIVE', 'FILE', 'SERIES'] as const; +/** Listed in the order a reader is most likely to meet them, not the enum's own order. */ +const INSTRUMENT_KINDS = ['FORM', 'INTERACTIVE', 'FILE', 'SERIES'] as const satisfies readonly InstrumentKind[]; type InstrumentRow = { edition: null | number; id: string; - kind: string; + kind: InstrumentKind; lastCollectedAt: Date | null; recordCount: number; source: null | string; @@ -30,23 +33,17 @@ type InstrumentRow = { title: string; }; -type KindFilter = string[]; +type KindFilter = InstrumentKind[]; /** A kind tag, matching the treatment the subject table gives sex. */ -const InstrumentKindCell = ({ kind }: { kind: string }) => { +const InstrumentKindCell = ({ kind }: { kind: InstrumentKind }) => { const [theme] = useTheme(); - const { t } = useTranslation(); - const labels: { [key: string]: string } = { - 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' }) - }; + const labels = useInstrumentKindLabels(); return ( ); }; @@ -59,6 +56,7 @@ const Toolbar = ({ rows, table }: { rows: InstrumentRow[]; table: TanstackTable. const { t } = useTranslation(); const download = useDownload(); const addNotification = useNotificationsStore((store) => store.addNotification); + const kindLabels = useInstrumentKindLabels(); const [isOpen, setIsOpen] = useState(false); const kindColumn = table.getAllColumns().find((column) => column.id === 'kind'); @@ -135,7 +133,7 @@ const Toolbar = ({ rows, table }: { rows: InstrumentRow[]; table: TanstackTable. }} onSelect={(e) => e.preventDefault()} > - {kind} + {kindLabels[kind]} ))} @@ -251,7 +249,7 @@ export const InstrumentTable = ({ 'data-testid': testId, onOpen, rowActions = [] }, { accessorKey: 'kind', - cell: (ctx) => , + cell: (ctx) => , filterFn: (row, id, filter: KindFilter) => filter.includes(row.getValue(id)), header: ({ column }) => ( diff --git a/apps/web/src/components/RecordScatterChart/RecordScatterChart.tsx b/apps/web/src/components/RecordScatterChart/RecordScatterChart.tsx index f4f2aed77..ef68c8ebc 100644 --- a/apps/web/src/components/RecordScatterChart/RecordScatterChart.tsx +++ b/apps/web/src/components/RecordScatterChart/RecordScatterChart.tsx @@ -16,6 +16,7 @@ import { import type { RecordChartSeries } from '@/hooks/useRecordChartSeries'; import { AXIS_STROKE, + CHART_SURFACE, inkColors, resolveTheme, tooltipStyles, @@ -107,7 +108,7 @@ export const RecordScatterChart = ({ measureLabel, series, trend, xLabel }: Reco fill={item.color} key={item.key} name={item.label} - stroke={tooltipStyles[resolvedTheme].backgroundColor} + stroke={CHART_SURFACE[resolvedTheme]} strokeWidth={2} /> ))} 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__/useInstrumentHubFacets.test.ts b/apps/web/src/hooks/__tests__/useInstrumentHubFacets.test.ts new file mode 100644 index 000000000..580d27926 --- /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': { 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..2e2aa8fe6 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useInstrumentInfoById.test.ts @@ -0,0 +1,60 @@ +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; 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', kind: 'FORM' }, + { details: { title: 'Stroop Task' }, id: 'stroop-1', kind: 'INTERACTIVE' } + ]; + const { result } = renderHook(() => useInstrumentInfoById()); + expect(result.current).toStrictEqual({ + 'hq-1': { kind: 'FORM', title: 'Happiness Questionnaire' }, + 'stroop-1': { 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', kind: 'FILE' }]; + const { result } = renderHook(() => useInstrumentInfoById()); + expect(result.current['file-1']?.kind).toBe('FILE'); + }); + + 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__/useRecordMetadataColumns.test.tsx b/apps/web/src/hooks/__tests__/useRecordMetadataColumns.test.tsx new file mode 100644 index 000000000..f9c13775f --- /dev/null +++ b/apps/web/src/hooks/__tests__/useRecordMetadataColumns.test.tsx @@ -0,0 +1,97 @@ +import type { ReactNode } from 'react'; + +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import type { SessionType } from '@opendatacapture/schemas/session'; +import { cleanup, render, renderHook, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } 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 } })}); +}; + +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.header)).toStrictEqual(['COLLECTION_METHOD', '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.header)).toStrictEqual(['COLLECTION_METHOD']); + }); + + 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 show an em dash for a record whose collection method is unknown', () => { + renderCell('__method__', null, record({ __method__: null })); + expect(screen.getByTestId('record-cell-collection-method').textContent).toBe('—'); + }); + + 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 show an em dash for a record collected individually', () => { + renderCell('__seriesName__', null, record()); + expect(screen.getByTestId('record-cell-series').textContent).toBe('—'); + }); + + 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/useInstrumentHubFacets.ts b/apps/web/src/hooks/useInstrumentHubFacets.ts index 0c1d1d3a1..7fd8e655c 100644 --- a/apps/web/src/hooks/useInstrumentHubFacets.ts +++ b/apps/web/src/hooks/useInstrumentHubFacets.ts @@ -3,9 +3,9 @@ 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 { useSeriesNames } from '@/hooks/useSeriesNames'; import { useAppStore } from '@/store'; import { getEditionOptions } from '@/utils/instrument-editions'; @@ -31,7 +31,7 @@ export function useInstrumentHubFacets() { const infoQuery = useInstrumentInfoQuery({ params: { allEditions: true } }); const recordsQuery = useInstrumentRecords({ params: { groupId: currentGroup?.id, instrumentId } }); - const seriesNames = useSeriesNames(); + const seriesInfoById = useInstrumentInfoById({ kind: 'SERIES' }); const editionLabel = t({ en: 'Edition', es: 'Edición', fr: 'Édition' }); const editionOptions = useMemo( @@ -53,10 +53,10 @@ export function useInstrumentHubFacets() { const options = new Map(); for (const record of recordsQuery.data ?? []) { const seriesId = record.seriesInstrumentId ?? null; - options.set(seriesId ?? NO_SERIES, seriesId ? (seriesNames[seriesId] ?? null) : null); + options.set(seriesId ?? NO_SERIES, seriesId ? (seriesInfoById[seriesId]?.title ?? null) : null); } return options; - }, [recordsQuery.data, seriesNames]); + }, [recordsQuery.data, seriesInfoById]); return { editionOptions, isSeries, seriesOptions, title }; } diff --git a/apps/web/src/hooks/useInstrumentHubRecords.ts b/apps/web/src/hooks/useInstrumentHubRecords.ts index 7ad64660e..6f9861b7f 100644 --- a/apps/web/src/hooks/useInstrumentHubRecords.ts +++ b/apps/web/src/hooks/useInstrumentHubRecords.ts @@ -21,7 +21,10 @@ export function useInstrumentHubRecords() { const filterRecord = useCallback( (record: InstrumentVisualizationRecord) => { - if (record.__method__ && !methods.includes(record.__method__)) { + // 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. diff --git a/apps/web/src/hooks/useInstrumentInfoById.ts b/apps/web/src/hooks/useInstrumentInfoById.ts new file mode 100644 index 000000000..4d13e7023 --- /dev/null +++ b/apps/web/src/hooks/useInstrumentInfoById.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; + +import type { InstrumentKind } from '@opendatacapture/runtime-core'; + +import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; + +type InstrumentInfoById = { [id: string]: { 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, { 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/useInstrumentTitles.ts b/apps/web/src/hooks/useInstrumentTitles.ts deleted file mode 100644 index ab9c9e856..000000000 --- a/apps/web/src/hooks/useInstrumentTitles.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useMemo } from 'react'; - -import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; - -/** - * Every accessible instrument's id mapped to its title. - * - * A record names only the instrument's id, which is all a per-instrument view needs — but a series - * view shows records from several instruments at once and has to name each one. - */ -export function useInstrumentTitles(): { [id: string]: string } { - const query = useInstrumentInfoQuery({ params: { allEditions: true } }); - return useMemo( - () => Object.fromEntries((query.data ?? []).map((info) => [info.id, info.details.title])), - [query.data] - ); -} diff --git a/apps/web/src/hooks/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index ab93502cc..0035e1bda 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -11,9 +11,9 @@ 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 { useSeriesNames } from '@/hooks/useSeriesNames'; import { useAppStore } from '@/store'; import { downloadSubjectTableExcel } from '@/utils/excel'; import { getEditionOptions } from '@/utils/instrument-editions'; @@ -100,7 +100,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio }, [params.instrumentId]); const instrument = useInstrument(instrumentId) as AnyUnilingualScalarInstrument; - const seriesNames = useSeriesNames(); + const seriesInfoById = useInstrumentInfoById({ kind: 'SERIES' }); const instrumentInfoQuery = useInstrumentInfoQuery({ params: { allEditions: true, kind: params.kind, subjectId: params.subjectId } @@ -278,7 +278,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio __instrumentId__: record.instrumentId, __method__: record.session?.type ?? null, __seriesId__: seriesId, - __seriesName__: seriesId ? (seriesNames[seriesId] ?? null) : null, + __seriesName__: seriesId ? (seriesInfoById[seriesId]?.title ?? null) : null, __subjectId__: record.subjectId, __time__: record.date.getTime(), username: record.session?.user?.username ?? 'N/A', @@ -300,7 +300,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio type: 'error' }); } - }, [recordsQuery.data, seriesNames]); + }, [recordsQuery.data, seriesInfoById]); const instrumentOptions: { [key: string]: string } = useMemo(() => { // only show the latest edition of each instrument; older editions are selectable via editionOptions diff --git a/apps/web/src/hooks/useRecordChartSeries.ts b/apps/web/src/hooks/useRecordChartSeries.ts index f2e414dc6..ae009d526 100644 --- a/apps/web/src/hooks/useRecordChartSeries.ts +++ b/apps/web/src/hooks/useRecordChartSeries.ts @@ -7,7 +7,10 @@ import type { InstrumentVisualizationRecord } from '@/hooks/useInstrumentVisuali import { getCategoricalColor, MAX_CATEGORICAL_GROUPS, NEUTRAL_MARK, OTHER_GROUP_KEY } from '@/utils/chart-palette'; import type { ChartPaletteName } from '@/utils/chart-palette'; -type ColourBy = 'method' | 'none' | 'series'; +/** 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; @@ -123,4 +126,5 @@ export function useRecordChartSeries({ colourBy, measure, palette, records }: Us }, [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 index db36100a4..8e33106b7 100644 --- a/apps/web/src/hooks/useRecordMetadataColumns.tsx +++ b/apps/web/src/hooks/useRecordMetadataColumns.tsx @@ -5,6 +5,7 @@ import type { SessionType } from '@opendatacapture/schemas/session'; import { TruncatedCell } from '@/components/TruncatedCell'; import { useCollectionMethodLabels } from '@/hooks/useCollectionMethodLabels'; +import { NO_SERIES } from '@/hooks/useInstrumentHubFacets'; import type { InstrumentVisualizationRecord } from '@/hooks/useInstrumentVisualization'; /** Shown where a record carries no series, or a session whose type could not be resolved */ @@ -49,7 +50,7 @@ export function useRecordMetadataColumns({ }, // 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__ ?? ''), + filterFn: (row, _id, filter: string[]) => filter.includes(row.original.__seriesId__ ?? NO_SERIES), header: 'SERIES', id: '__seriesName__' }); diff --git a/apps/web/src/hooks/useSeriesNames.ts b/apps/web/src/hooks/useSeriesNames.ts deleted file mode 100644 index 242b36d04..000000000 --- a/apps/web/src/hooks/useSeriesNames.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useMemo } from 'react'; - -import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; - -/** - * Maps every accessible series instrument's id to its title. - * - * A record names the series that collected it by id alone, and a series instrument carries no - * `internal.name` to fall back on, so the title has to be looked up separately from the scalar - * instrument the record was collected with. - */ -export function useSeriesNames(): { [id: string]: string } { - const query = useInstrumentInfoQuery({ params: { kind: 'SERIES' } }); - return useMemo( - () => Object.fromEntries((query.data ?? []).map((info) => [info.id, info.details.title])), - [query.data] - ); -} diff --git a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx index 2a855e4f7..1682c0c27 100644 --- a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx +++ b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx @@ -15,6 +15,7 @@ import { TabLink } from '@/components/TabLink'; import { TimeDropdown } from '@/components/TimeDropdown'; import { useCollectionMethodLabels } from '@/hooks/useCollectionMethodLabels'; import { NO_SERIES, useInstrumentHubFacets } from '@/hooks/useInstrumentHubFacets'; +import { COLOUR_BY_OPTIONS } from '@/hooks/useRecordChartSeries'; import { CHART_PALETTE_NAMES } from '@/utils/chart-palette'; const COLLECTION_METHODS = $SessionType.options; @@ -30,8 +31,9 @@ const Filters = ({ seriesOptions }: { seriesOptions: Map const [isOpen, setIsOpen] = useState(false); - // An absent `series` search param means every series; the dropdown has to materialise that into - // an explicit list before it can remove one from it. + // An absent `methods`/`series` search param means every one of them; the dropdown has to + // materialise that into an explicit list before it can remove one from it. + const selectedMethods = methods ?? COLLECTION_METHODS; const selectedSeries = series ?? Array.from(seriesOptions.keys()); const setMethods = (next: SessionType[]) => { @@ -60,11 +62,11 @@ const Filters = ({ seriesOptions }: { seriesOptions: Map {COLLECTION_METHODS.map((method) => ( { - setMethods(checked ? [...methods, method] : methods.filter((item) => item !== method)); + setMethods(checked ? [...selectedMethods, method] : selectedMethods.filter((item) => item !== method)); }} onSelect={(e) => e.preventDefault()} > @@ -160,14 +162,15 @@ export const Route = createFileRoute('/_app/datahub/instruments/$instrumentId')( component: RouteComponent, validateSearch: z.object({ chart: z.enum(['scatter', 'distribution']).default('scatter'), - colourBy: z.enum(['none', 'method', 'series']).default('none'), + colourBy: z.enum(COLOUR_BY_OPTIONS).default('none'), /** * One measure, not several: both chart forms put the measure on an axis and spend colour on the * grouping dimension instead, and a second measure of a different scale would need a second * y-axis. */ measure: z.string().optional(), - methods: $SessionType.array().default(COLLECTION_METHODS), + /** Absent means every method, which an empty array deliberately does not */ + methods: $SessionType.array().optional(), minDate: z.coerce.date().optional(), palette: z.enum(CHART_PALETTE_NAMES).default('default'), /** Absent means every series, which an empty array deliberately does not */ diff --git a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/table.tsx b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/table.tsx index 38545b2e1..a12b83669 100644 --- a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/table.tsx +++ b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/table.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from 'react'; import { camelToSnakeCase, toBasicISOString } from '@douglasneuroinformatics/libjs'; import { ActionDropdown, DataTable, TanstackTable } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { InstrumentKind } from '@opendatacapture/runtime-core'; import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; @@ -11,7 +12,7 @@ import type { InstrumentRow } from '@/components/InstrumentTable'; import { TruncatedCell } from '@/components/TruncatedCell'; import { useInstrumentHubFacets } from '@/hooks/useInstrumentHubFacets'; import { useInstrumentHubRecords } from '@/hooks/useInstrumentHubRecords'; -import { useInstrumentTitles } from '@/hooks/useInstrumentTitles'; +import { useInstrumentInfoById } from '@/hooks/useInstrumentInfoById'; import type { InstrumentVisualizationRecord } from '@/hooks/useInstrumentVisualization'; import { useRecordMetadataColumns } from '@/hooks/useRecordMetadataColumns'; import { useAppStore } from '@/store'; @@ -27,7 +28,10 @@ const FIXED_COLUMN_PREFIX = '__'; * what this series collected, not the instrument's global ones, which is what a per-instrument * summary would give. */ -function useSeriesMemberRows(records: InstrumentVisualizationRecord[], titles: { [id: string]: string }) { +function useSeriesMemberRows( + records: InstrumentVisualizationRecord[], + infoById: { [id: string]: { kind: InstrumentKind; title: string } } +) { return useMemo(() => { const members = new Map; total: number }>(); for (const record of records) { @@ -45,14 +49,16 @@ function useSeriesMemberRows(records: InstrumentVisualizationRecord[], titles: { return Array.from(members, ([id, member]) => ({ edition: null, id, - kind: 'FORM', + // A series composes scalar instruments of any kind, so the member's own kind is carried + // through rather than assumed — a FILE or INTERACTIVE member is not a form. + kind: infoById[id]?.kind ?? 'FORM', lastCollectedAt: member.lastCollectedAt, recordCount: member.total, source: null, subjectCount: member.subjectIds.size, - title: titles[id] ?? id + title: infoById[id]?.title ?? id })); - }, [records, titles]); + }, [records, infoById]); } const RouteComponent = () => { @@ -60,12 +66,12 @@ const RouteComponent = () => { const [highlightedRowId, setHighlightedRowId] = useState(null); const { dl, records } = useInstrumentHubRecords(); const { isSeries } = useInstrumentHubFacets(); - const instrumentTitles = useInstrumentTitles(); + const instrumentInfoById = useInstrumentInfoById({ allEditions: true }); const metadataColumns = useRecordMetadataColumns({ omitSeries: isSeries }); const subjectIdDisplaySetting = useAppStore((store) => store.currentGroup?.settings.subjectIdDisplayLength); const { t } = useTranslation(); - const seriesMembers = useSeriesMemberRows(records, instrumentTitles); + const seriesMembers = useSeriesMemberRows(records, instrumentInfoById); const measureColumns = useMemo[]>(() => { const columns: TanstackTable.ColumnDef[] = []; @@ -74,7 +80,7 @@ const RouteComponent = () => { if (isSeries) { return [ { - accessorFn: (record) => instrumentTitles[record.__instrumentId__] ?? record.__instrumentId__, + accessorFn: (record) => instrumentInfoById[record.__instrumentId__]?.title ?? record.__instrumentId__, cell: (ctx) => { const title = ctx.getValue() as string; return ; @@ -98,7 +104,7 @@ const RouteComponent = () => { } } return columns; - }, [instrumentTitles, isSeries, records[0]]); + }, [instrumentInfoById, isSeries, records[0]]); const viewRecord = (record: InstrumentVisualizationRecord) => { void navigate({ @@ -128,11 +134,7 @@ const RouteComponent = () => { data-spotlight-type="export-data-dropdown" data-testid="instrument-hub-export-dropdown" disabled={records.length === 0} - options={ - isSeries - ? ['TSV Long', 'CSV Long', 'Excel Long'] - : ['TSV', 'TSV Long', 'JSON', 'CSV', 'CSV Long', 'Excel', 'Excel Long'] - } + options={['TSV', 'TSV Long', 'JSON', 'CSV', 'CSV Long', 'Excel', 'Excel Long']} title={t('core.download')} triggerClassName="min-w-32" onSelection={dl} diff --git a/apps/web/src/utils/chart-theme.ts b/apps/web/src/utils/chart-theme.ts index eeaeed70d..cdf12d2e3 100644 --- a/apps/web/src/utils/chart-theme.ts +++ b/apps/web/src/utils/chart-theme.ts @@ -11,14 +11,26 @@ const inkColors: { [K in Theme]: string } = { 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: '#0f172a', + backgroundColor: CHART_SURFACE.dark, borderColor: inkColors.light, borderRadius: '2px' }, light: { - backgroundColor: '#f1f5f9', + backgroundColor: CHART_SURFACE.light, borderColor: inkColors.dark, borderRadius: '2px' } @@ -43,4 +55,13 @@ function resolveTheme(theme: string | undefined): Theme { return theme === 'dark' ? 'dark' : 'light'; } -export { AXIS_STROKE, inkColors, resolveTheme, tooltipStyles, X_AXIS_HEIGHT, X_AXIS_LABEL_OFFSET, Y_AXIS_WIDTH }; +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/tag-colors.ts b/apps/web/src/utils/tag-colors.ts index 6e8722139..5f6dc30c8 100644 --- a/apps/web/src/utils/tag-colors.ts +++ b/apps/web/src/utils/tag-colors.ts @@ -1,16 +1,9 @@ +import type { InstrumentKind } from '@opendatacapture/runtime-core'; + import { getCategoricalColor, NEUTRAL_MARK } from '@/utils/chart-palette'; type Mode = 'dark' | 'light'; -/** - * Colours for the nominal values tagged in tables. - * - * Values come from the validated categorical palette rather than new hex, so the app holds one set - * of nominal colours instead of a second ad-hoc one that drifts. Each is paired with its label - * wherever it is rendered, so the colour is redundant rather than load-bearing — which is what makes - * it safe for a colourblind reader, and why a theme's slot separation is not a constraint here. - */ - /** * Baby pink for female, baby blue for male, grey for a subject whose sex was never recorded. * @@ -31,11 +24,16 @@ function getSexColor(sex: null | string, mode: Mode): string { return NEUTRAL_MARK[mode]; } -/** One slot per instrument kind, in the order a reader is most likely to meet them. */ -function getInstrumentKindColor(kind: string, mode: Mode): string { - const slots: { [key: string]: number } = { FILE: 2, FORM: 0, INTERACTIVE: 1 }; +/** + * One palette slot per instrument kind. + * + * A series is the neutral mark rather than a fourth hue: the palette holds three validated slots, + * and a generated fourth is never the answer. + */ +function getInstrumentKindColor(kind: InstrumentKind, mode: Mode): string { + const slots: { [K in InstrumentKind]: null | number } = { FILE: 2, FORM: 0, INTERACTIVE: 1, SERIES: null }; const slot = slots[kind]; - return slot === undefined ? NEUTRAL_MARK[mode] : getCategoricalColor(slot, mode, 'default'); + return slot === null ? NEUTRAL_MARK[mode] : getCategoricalColor(slot, mode, 'default'); } export { getInstrumentKindColor, getSexColor }; diff --git a/testing/src/specs/instrument-hub.spec.ts b/testing/src/specs/instrument-hub.spec.ts index de7b6ecfc..417857d4c 100644 --- a/testing/src/specs/instrument-hub.spec.ts +++ b/testing/src/specs/instrument-hub.spec.ts @@ -35,16 +35,32 @@ test.describe('instrument hub', () => { await expect(row).toContainText('2'); }); - test('should not list series instruments, which hold no records of their own', async ({ + // 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 + page, + uniqueId }) => { - await isolatedGroupManager(); + 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'); - await expect(hubPage.rows.first()).toBeVisible(); - await expect(hubPage.rows).not.toContainText('SERIES'); + 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 From cc709653319c153cfb9095e63c684b0353a217c2 Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Mon, 5 Oct 2026 17:14:58 -0400 Subject: [PATCH 3/4] fix(web): keep the search params when switching datahub tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TabLink` navigated with `to` alone, so TanStack dropped the current search params and the newly mounted tab silently widened the selection — the opposite of what putting the filters in the URL was for. The e2e that exists to catch this was passing anyway: it asserted the URL matched /methods=/, and `methods` carried a default of all three collection methods, so the param was present whether or not the filter had survived. Making `methods` optional removed the default and exposed the bug underneath. Also stops the graph spec reaching the tab through `getPageModel`, whose exact-URL assertion the route's own search-param defaults fail; it clicks the tab as a user would instead. Co-Authored-By: Claude Opus 5 --- apps/web/src/components/TabLink/TabLink.tsx | 3 +++ testing/src/specs/instrument-hub.spec.ts | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) 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/testing/src/specs/instrument-hub.spec.ts b/testing/src/specs/instrument-hub.spec.ts index 417857d4c..31732a1b1 100644 --- a/testing/src/specs/instrument-hub.spec.ts +++ b/testing/src/specs/instrument-hub.spec.ts @@ -1,5 +1,6 @@ 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'; @@ -156,7 +157,6 @@ test.describe('instrument hub', () => { test('should plot the records of the instrument on its graph tab', async ({ api, - getPageModel, isolatedGroupManager, page, uniqueId @@ -177,7 +177,10 @@ test.describe('instrument hub', () => { await hubPage.goto('/datahub/instruments'); await hubPage.open('Happiness Questionnaire'); - const graphPage = await getPageModel('/datahub/instruments/$instrumentId/graph', { instrumentId }); + // 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); From f6d3f8295da19b7cca8e9bcf82d5fdfc1a94acfc Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Wed, 7 Oct 2026 18:16:05 -0400 Subject: [PATCH 4/4] feat(web): improve datahub filters, navigation, and chart exports Co-Authored-By: GPT-6 --- .../datahub-instrument-table.test.tsx | 148 +++++++++++ .../__tests__/datahub-subject-graph.test.tsx | 137 +++++++--- .../__tests__/datahub-subject-layout.test.tsx | 16 ++ .../__tests__/datahub-table-controls.test.tsx | 16 +- .../src/__tests__/instrument-table.test.tsx | 165 +++++++++++- .../CollectedFilterMenu.tsx | 72 ++++++ .../components/CollectedFilterMenu/index.ts | 1 + .../InstrumentTable/InstrumentTable.tsx | 239 ++++++++++++++---- .../RecordDistributionChart.tsx | 1 + .../SelectChartPalette/SelectChartPalette.tsx | 63 +++++ .../components/SelectChartPalette/index.ts | 1 + .../src/hooks/__tests__/useGraphLines.test.ts | 31 ++- .../__tests__/useInstrumentHubFacets.test.ts | 2 +- .../__tests__/useInstrumentInfoById.test.ts | 14 +- .../useInstrumentVisualization.test.ts | 105 +++++++- .../useRecordMetadataColumns.test.tsx | 35 ++- apps/web/src/hooks/useGraphLines.ts | 43 ++-- apps/web/src/hooks/useInstrumentInfoById.ts | 13 +- .../src/hooks/useInstrumentVisualization.ts | 23 +- .../src/hooks/useRecordMetadataColumns.tsx | 17 +- .../instruments/$instrumentId/graph.tsx | 102 ++++---- .../instruments/$instrumentId/route.tsx | 145 ++--------- .../instruments/$instrumentId/table.tsx | 183 +++++++++++--- .../routes/_app/datahub/instruments/index.tsx | 43 +--- .../datahub/subjects/$subjectId/graph.tsx | 112 ++++---- .../datahub/subjects/$subjectId/route.tsx | 25 +- .../routes/_app/datahub/subjects/index.tsx | 137 ++-------- .../src/utils/__tests__/chart-image.test.ts | 131 ++++++++++ .../src/utils/__tests__/chart-palette.test.ts | 54 +++- .../utils/__tests__/collected-filter.test.ts | 74 ++++++ apps/web/src/utils/chart-image.ts | 176 +++++++++++++ apps/web/src/utils/chart-palette.ts | 82 +++--- apps/web/src/utils/collected-filter.ts | 63 +++++ apps/web/src/utils/tag-colors.ts | 30 ++- 34 files changed, 1854 insertions(+), 645 deletions(-) create mode 100644 apps/web/src/__tests__/datahub-instrument-table.test.tsx create mode 100644 apps/web/src/components/CollectedFilterMenu/CollectedFilterMenu.tsx create mode 100644 apps/web/src/components/CollectedFilterMenu/index.ts create mode 100644 apps/web/src/components/SelectChartPalette/SelectChartPalette.tsx create mode 100644 apps/web/src/components/SelectChartPalette/index.ts create mode 100644 apps/web/src/utils/__tests__/chart-image.test.ts create mode 100644 apps/web/src/utils/__tests__/collected-filter.test.ts create mode 100644 apps/web/src/utils/chart-image.ts create mode 100644 apps/web/src/utils/collected-filter.ts 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 } }) => ( - ({ + + + {/* 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} + + ))} + + + )} + + ); }; @@ -166,7 +261,12 @@ const Toolbar = ({ rows, table }: { rows: InstrumentRow[]; table: TanstackTable. type InstrumentTableProps = { 'data-testid'?: string; onOpen: (row: InstrumentRow) => void; - rowActions?: { label: string; onSelect: (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[]; }; @@ -174,15 +274,48 @@ type InstrumentTableProps = { * 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, rowActions = [], rows }: InstrumentTableProps) => { +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 }) => ; + const Component = (props: { table: TanstackTable.Table }) => ( + + ); Component.displayName = 'InstrumentTableToolbar'; return Component; - }, [rows]); + }, [recordExport, rows]); return (
@@ -228,8 +361,9 @@ export const InstrumentTable = ({ 'data-testid': testId, onOpen, rowActions = [] }, { accessorKey: 'recordCount', - // Carries the "collected only" filter: the value is a boolean, not a count to match. - filterFn: (row, id, onlyCollected: boolean) => !onlyCollected || row.getValue(id) > 0, + // 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 }) => ( ) @@ -238,14 +372,17 @@ export const InstrumentTable = ({ 'data-testid': testId, onOpen, rowActions = [] accessorKey: 'lastCollectedAt', cell: (ctx) => { const value = ctx.getValue() as Date | null; - return value ? toBasicISOString(value) : EMPTY_CELL; + 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', @@ -258,7 +395,7 @@ export const InstrumentTable = ({ 'data-testid': testId, onOpen, rowActions = [] }, { accessorKey: 'edition', - cell: (ctx) => (ctx.getValue() as null | number) ?? EMPTY_CELL, + cell: (ctx) => (ctx.getValue() as null | number) ?? t({ en: 'None', es: 'Ninguno', fr: 'Aucun' }), header: ({ column }) => ( ) @@ -282,14 +419,22 @@ export const InstrumentTable = ({ 'data-testid': testId, onOpen, rowActions = [] // eslint-disable-next-line perfectionist/sort-objects columnFilters: [ { id: 'kind', value: [...INSTRUMENT_KINDS] satisfies KindFilter }, - { id: 'recordCount', value: false } + { id: 'recordCount', value: 0 }, + { id: 'lastCollectedAt', value: DEFAULT_COLLECTED_FILTER } ] }} - rowActions={[{ label: t('common.view'), onSelect: onOpen }, ...rowActions]} + 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)} />}
); }; diff --git a/apps/web/src/components/RecordDistributionChart/RecordDistributionChart.tsx b/apps/web/src/components/RecordDistributionChart/RecordDistributionChart.tsx index 8a06ed55e..4045874a1 100644 --- a/apps/web/src/components/RecordDistributionChart/RecordDistributionChart.tsx +++ b/apps/web/src/components/RecordDistributionChart/RecordDistributionChart.tsx @@ -74,6 +74,7 @@ export const RecordDistributionChart = ({ {series.map((item) => ( 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/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 index 580d27926..2fc5bba77 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentHubFacets.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentHubFacets.test.ts @@ -79,7 +79,7 @@ describe('useInstrumentHubFacets', () => { // 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': { kind: 'SERIES', title: 'Happiness Series' } }; + 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']]); }); diff --git a/apps/web/src/hooks/__tests__/useInstrumentInfoById.test.ts b/apps/web/src/hooks/__tests__/useInstrumentInfoById.test.ts index 2e2aa8fe6..b6b3b4986 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentInfoById.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentInfoById.test.ts @@ -6,7 +6,8 @@ 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; kind: string }[], + 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 })); @@ -28,22 +29,23 @@ describe('useInstrumentInfoById', () => { it('should key every instrument by its id, carrying the kind beside the title', () => { mocks.data = [ - { details: { title: 'Happiness Questionnaire' }, id: 'hq-1', kind: 'FORM' }, - { details: { title: 'Stroop Task' }, id: 'stroop-1', kind: 'INTERACTIVE' } + { 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': { kind: 'FORM', title: 'Happiness Questionnaire' }, - 'stroop-1': { kind: 'INTERACTIVE', title: 'Stroop Task' } + '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', kind: 'FILE' }]; + 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', () => { diff --git a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts index a763d4c27..8c6a3914f 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentVisualization.test.ts @@ -140,7 +140,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.csv'); const csvContents = getContentFn(); expect(csvContents).toMatch( - `GroupID,SubjectID,Date,CollectionMethod,SeriesID,SeriesName,Username,someValue\r\ntestGroupId,subject-1,${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` ); }); }); @@ -158,7 +158,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.tsv'); const tsvContents = getContentFn(); expect(tsvContents).toMatch( - `GroupID\tSubjectID\tDate\tCollectionMethod\tSeriesID\tSeriesName\tUsername\tsomeValue\r\ntestGroupId\tsubject-1\t${toBasicISOString(FIXED_TEST_DATE)}\t\t\t\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` ); }); }); @@ -177,7 +177,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toContain('.csv'); const csvLongContents = getContentFn(); expect(csvLongContents).toMatch( - `GroupID,SubjectID,Date,CollectionMethod,SeriesID,SeriesName,Username,Variable,Value\r\ntestGroupId,subject-1,${toBasicISOString(FIXED_TEST_DATE)},,,,testusername,someValue,abc` + `GroupID,SubjectID,InstrumentName,InstrumentEdition,Date,CollectionMethod,SeriesID,SeriesName,Username,Variable,Value\r\ntestGroupId,subject-1,instrument-1,,${toBasicISOString(FIXED_TEST_DATE)},,,,testusername,someValue,abc` ); }); }); @@ -196,7 +196,7 @@ describe('useInstrumentVisualization', () => { expect(filename).toMatch('.tsv'); const tsvLongContents = getContentFn(); expect(tsvLongContents).toMatch( - `GroupID\tSubjectID\tDate\tCollectionMethod\tSeriesID\tSeriesName\tUsername\tVariable\tValue\r\ntestGroupId\tsubject-1\t${toBasicISOString(FIXED_TEST_DATE)}\t\t\t\ttestusername\tsomeValue\tabc` + `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` ); }); }); @@ -219,6 +219,8 @@ describe('useInstrumentVisualization', () => { CollectionMethod: null, Date: '2025-04-30', GroupID: 'testGroupId', + InstrumentEdition: null, + InstrumentName: 'instrument-1', SeriesID: null, SeriesName: null, SubjectID: 'subject-1', @@ -249,6 +251,8 @@ describe('useInstrumentVisualization', () => { CollectionMethod: null, Date: '2025-04-30', GroupID: 'testGroupId', + InstrumentEdition: null, + InstrumentName: 'instrument-1', SeriesID: null, SeriesName: null, SubjectID: 'subject-1', @@ -259,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 = [ diff --git a/apps/web/src/hooks/__tests__/useRecordMetadataColumns.test.tsx b/apps/web/src/hooks/__tests__/useRecordMetadataColumns.test.tsx index f9c13775f..fb75c4667 100644 --- a/apps/web/src/hooks/__tests__/useRecordMetadataColumns.test.tsx +++ b/apps/web/src/hooks/__tests__/useRecordMetadataColumns.test.tsx @@ -2,8 +2,8 @@ import type { ReactNode } from 'react'; import { i18n } from '@douglasneuroinformatics/libui/i18n'; import type { SessionType } from '@opendatacapture/schemas/session'; -import { cleanup, render, renderHook, screen } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useRecordMetadataColumns } from '../useRecordMetadataColumns'; @@ -32,6 +32,15 @@ const renderCell = (columnId: string, value: unknown, original: InstrumentVisual 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'); @@ -40,12 +49,22 @@ describe('useRecordMetadataColumns', () => { afterEach(cleanup); it('should show both provenance columns under the names the export uses', () => { - expect(columns().map((column) => column.header)).toStrictEqual(['COLLECTION_METHOD', 'SERIES']); + 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.header)).toStrictEqual(['COLLECTION_METHOD']); + 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', () => { @@ -55,9 +74,9 @@ describe('useRecordMetadataColumns', () => { // A record whose session was deleted reads back without a type, which must not render as blank // or as "null". - it('should show an em dash for a record whose collection method is unknown', () => { + 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('—'); + expect(screen.getByTestId('record-cell-collection-method').textContent).toBe('None'); }); it('should show the series name when the record was collected under one', () => { @@ -67,9 +86,9 @@ describe('useRecordMetadataColumns', () => { // 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 show an em dash for a record collected individually', () => { + it('should name the absence for a record collected individually', () => { renderCell('__seriesName__', null, record()); - expect(screen.getByTestId('record-cell-series').textContent).toBe('—'); + expect(screen.getByTestId('record-cell-series').textContent).toBe('None'); }); it('should filter the method column on the stored value, not the translated label', () => { 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/useInstrumentInfoById.ts b/apps/web/src/hooks/useInstrumentInfoById.ts index 4d13e7023..57c05c701 100644 --- a/apps/web/src/hooks/useInstrumentInfoById.ts +++ b/apps/web/src/hooks/useInstrumentInfoById.ts @@ -4,7 +4,7 @@ import type { InstrumentKind } from '@opendatacapture/runtime-core'; import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; -type InstrumentInfoById = { [id: string]: { kind: InstrumentKind; title: string } }; +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. @@ -22,7 +22,16 @@ export function useInstrumentInfoById(params?: { const query = useInstrumentInfoQuery({ params }); return useMemo( () => - Object.fromEntries((query.data ?? []).map((info) => [info.id, { kind: info.kind, title: info.details.title }])), + 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/useInstrumentVisualization.ts b/apps/web/src/hooks/useInstrumentVisualization.ts index 0035e1bda..7c345a61f 100644 --- a/apps/web/src/hooks/useInstrumentVisualization.ts +++ b/apps/web/src/hooks/useInstrumentVisualization.ts @@ -101,6 +101,9 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio 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 } @@ -143,6 +146,9 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio 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__), @@ -155,6 +161,8 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio 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; @@ -163,11 +171,22 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio 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 = () => { return exportRecords.map(({ measures, meta }) => { const row = withMetadataColumns(meta); - for (const [key, value] of Object.entries(measures)) { - row[key] = typeof value === 'object' ? JSON.stringify(value) : value; + 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 row; }); diff --git a/apps/web/src/hooks/useRecordMetadataColumns.tsx b/apps/web/src/hooks/useRecordMetadataColumns.tsx index 8e33106b7..6ea96eab1 100644 --- a/apps/web/src/hooks/useRecordMetadataColumns.tsx +++ b/apps/web/src/hooks/useRecordMetadataColumns.tsx @@ -1,16 +1,15 @@ 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'; -/** Shown where a record carries no series, or a session whose type could not be resolved */ -const EMPTY_CELL = '—'; - /** * 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 @@ -23,17 +22,19 @@ export function useRecordMetadataColumns({ 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] : EMPTY_CELL; + const label = value ? collectionMethodLabels[value] : noneLabel; return ; }, filterFn: (row, id, filter: SessionType[]) => filter.includes(row.getValue(id)), - header: 'COLLECTION_METHOD', + header: ({ column }) => , id: '__method__' } ]; @@ -45,15 +46,15 @@ export function useRecordMetadataColumns({ 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) ?? EMPTY_CELL; + 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: 'SERIES', + header: ({ column }) => , id: '__seriesName__' }); return columns; - }, [collectionMethodLabels, omitSeries]); + }, [collectionMethodLabels, noneLabel, omitSeries]); } diff --git a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/graph.tsx b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/graph.tsx index 69856c354..51a382e84 100644 --- a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/graph.tsx +++ b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/graph.tsx @@ -1,53 +1,66 @@ -import { Select, Tabs } from '@douglasneuroinformatics/libui/components'; -import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +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 { CHART_PALETTE_NAMES, getPaletteSwatch } from '@/utils/chart-palette'; -import type { ChartPaletteName } from '@/utils/chart-palette'; +import { chartImageFilename, renderChartPng } from '@/utils/chart-image'; +import { MAX_CATEGORICAL_GROUPS } 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. Shows - * one dot when there is no grouping, because only the first slot is used then. - */ -const PaletteSwatch = ({ palette, slots }: { palette: ChartPaletteName; slots: number }) => { - const [theme] = useTheme(); - return ( - - {getPaletteSwatch(palette, resolveTheme(theme)) - .slice(0, slots) - .map((color) => ( - - ))} - - ); -}; - 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 paletteLabels: { [K in ChartPaletteName]: string } = { - berry: t({ en: 'Berry', es: 'Baya', fr: 'Baie' }), - default: t({ en: 'Default', es: 'Predeterminada', fr: 'Par défaut' }), - jade: t({ en: 'Jade', es: 'Jade', fr: 'Jade' }) - }; + 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 @@ -120,33 +133,30 @@ const RouteComponent = () => { - + /> + { + void handlePngDownload(); + }} + />
{!hasData ? (

diff --git a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx index 1682c0c27..5fb06accd 100644 --- a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx +++ b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx @@ -1,107 +1,24 @@ -import React, { useState } from 'react'; +import React from 'react'; -import { Button, DropdownMenu, Heading } from '@douglasneuroinformatics/libui/components'; +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 type { SessionType } from '@opendatacapture/schemas/session'; -import { createFileRoute, Outlet } from '@tanstack/react-router'; -import { ChevronDownIcon } from 'lucide-react'; +import { createFileRoute, Link, Outlet } from '@tanstack/react-router'; import { z } from 'zod/v4'; import { LoadingFallback } from '@/components/LoadingFallback'; import { PageHeader } from '@/components/PageHeader'; -import { SelectEdition } from '@/components/SelectEdition'; import { TabLink } from '@/components/TabLink'; -import { TimeDropdown } from '@/components/TimeDropdown'; -import { useCollectionMethodLabels } from '@/hooks/useCollectionMethodLabels'; -import { NO_SERIES, useInstrumentHubFacets } from '@/hooks/useInstrumentHubFacets'; +import { useInstrumentHubFacets } from '@/hooks/useInstrumentHubFacets'; import { COLOUR_BY_OPTIONS } from '@/hooks/useRecordChartSeries'; import { CHART_PALETTE_NAMES } from '@/utils/chart-palette'; -const COLLECTION_METHODS = $SessionType.options; - -/** Shown for the series option standing in for records collected outside any series */ -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); - - // An absent `methods`/`series` search param means every one of them; the dropdown has to - // materialise that into an explicit list before it can remove one from it. - 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()} - > - {/* A record may name a series the caller cannot read, leaving the id as the only label */} - {id === NO_SERIES ? t({ en: 'Individual', es: 'Individual', fr: 'Individuel' }) : (name ?? id)} - - ))} - - - - ); -}; - const RouteComponent = () => { - const navigate = Route.useNavigate(); const params = Route.useParams(); const search = Route.useSearch(); const { t } = useTranslation(); - const { editionOptions, isSeries, seriesOptions, title } = useInstrumentHubFacets(); + const { isSeries, title } = useInstrumentHubFacets(); const basePathname = `/datahub/instruments/${params.instrumentId}`; @@ -112,45 +29,29 @@ const RouteComponent = () => { {title ?? t({ en: 'Instrument', es: 'Instrumento', fr: 'Instrument' })} -

-
- {!isSeries && ( - { - void navigate({ params: { instrumentId: id }, search, to: '.' }); - }} - /> - )} -
-
- { - void navigate({ search: (prev) => ({ ...prev, minDate: minDate ?? undefined }) }); - }} - /> - -
-
- {/* A series has one view, so a lone "Table" tab would label nothing. */} -
- {!isSeries && ( + + + {t({ en: 'Return', es: 'Volver', fr: 'Retour' })} + + {!isSeries && ( +
- )} - {/* A series spans instruments with no measure in common, so there is nothing to plot. */} - {!isSeries && ( - )} -
+
+ )} }> @@ -163,17 +64,11 @@ export const Route = createFileRoute('/_app/datahub/instruments/$instrumentId')( validateSearch: z.object({ chart: z.enum(['scatter', 'distribution']).default('scatter'), colourBy: z.enum(COLOUR_BY_OPTIONS).default('none'), - /** - * One measure, not several: both chart forms put the measure on an axis and spend colour on the - * grouping dimension instead, and a second measure of a different scale would need a second - * y-axis. - */ + fromSeries: z.string().optional(), measure: z.string().optional(), - /** Absent means every method, which an empty array deliberately does not */ methods: $SessionType.array().optional(), minDate: z.coerce.date().optional(), palette: z.enum(CHART_PALETTE_NAMES).default('default'), - /** Absent means every series, which an empty array deliberately does not */ 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 index a12b83669..1fc296258 100644 --- a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/table.tsx +++ b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/table.tsx @@ -1,16 +1,29 @@ import { useMemo, useState } from 'react'; import { camelToSnakeCase, toBasicISOString } from '@douglasneuroinformatics/libjs'; -import { ActionDropdown, DataTable, TanstackTable } from '@douglasneuroinformatics/libui/components'; +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, useNavigate } from '@tanstack/react-router'; +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 { useInstrumentHubFacets } from '@/hooks/useInstrumentHubFacets'; +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'; @@ -18,19 +31,85 @@ import { useRecordMetadataColumns } from '@/hooks/useRecordMetadataColumns'; import { useAppStore } from '@/store'; import { formatRecordValue } from '@/utils/record-value'; -/** The record fields rendered as fixed columns rather than as the instrument's own measures */ +const COLLECTION_METHODS = $SessionType.options; + const FIXED_COLUMN_PREFIX = '__'; -/** - * The instruments a series is made of, counted from the records that series orchestrated. - * - * Deliberately derived from the already-loaded records rather than fetched: these totals have to be - * what this series collected, not the instrument's global ones, which is what a per-instrument - * summary would give. - */ +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]: { kind: InstrumentKind; title: string } } + infoById: { [id: string]: { edition: null | number; kind: InstrumentKind; title: string } } ) { return useMemo(() => { const members = new Map; total: number }>(); @@ -47,10 +126,8 @@ function useSeriesMemberRows( } } return Array.from(members, ([id, member]) => ({ - edition: null, + edition: infoById[id]?.edition ?? null, id, - // A series composes scalar instruments of any kind, so the member's own kind is carried - // through rather than assumed — a FILE or INTERACTIVE member is not a form. kind: infoById[id]?.kind ?? 'FORM', lastCollectedAt: member.lastCollectedAt, recordCount: member.total, @@ -62,21 +139,21 @@ function useSeriesMemberRows( } const RouteComponent = () => { - const navigate = useNavigate(); + const navigate = Route.useNavigate(); + const { instrumentId: seriesInstrumentId } = Route.useParams(); const [highlightedRowId, setHighlightedRowId] = useState(null); const { dl, records } = useInstrumentHubRecords(); - const { isSeries } = useInstrumentHubFacets(); + 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[] = []; - // A series spans instruments whose measures have nothing in common, so a shared measure column - // would be empty for most rows. It names the instrument each record came from instead. if (isSeries) { return [ { @@ -85,7 +162,7 @@ const RouteComponent = () => { const title = ctx.getValue() as string; return ; }, - header: 'INSTRUMENT', + header: ({ column }) => , id: '__instrument__' } ]; @@ -98,7 +175,7 @@ const RouteComponent = () => { const value = formatRecordValue(ctx.getValue()); return ; }, - header: camelToSnakeCase(key).toUpperCase(), + header: ({ column }) => , id: key }); } @@ -106,22 +183,33 @@ const RouteComponent = () => { 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' }); }; - // A series lists what it is made of, and opening a member goes to that instrument's own page — - // where its subjects and records read exactly as any other instrument's do. if (isSeries) { return ( { - void navigate({ params: { instrumentId: row.id }, to: '/datahub/instruments/$instrumentId/table' }); + 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' + }); }} /> ); @@ -129,16 +217,35 @@ const RouteComponent = () => { return (
-
- +
+
+
+ { + void navigate({ params: { instrumentId: id }, to: '/datahub/instruments/$instrumentId/table' }); + }} + /> +
+
+ { + void navigate({ search: (prev) => ({ ...prev, minDate: minDate ?? undefined }) }); + }} + /> + + +
+
{ /> ), - header: 'SUBJECT', + header: ({ column }) => , id: '__subjectId__' }, { @@ -164,7 +271,8 @@ const RouteComponent = () => { const value = ctx.getValue(); return value instanceof Date ? toBasicISOString(value) : value; }, - header: 'DATE_COLLECTED' + header: ({ column }) => , + id: '__date__' }, ...metadataColumns, ...measureColumns @@ -180,13 +288,12 @@ const RouteComponent = () => { onSelect: (record) => { void navigate({ params: { subjectId: record.__subjectId__ }, + search: fromHere, to: '/datahub/subjects/$subjectId/table' }); } } ]} - // libui gates the row hover highlight on the presence of `onRowClick`, so recording the - // clicked row is what turns it on — the same thing the subject table already does. onRowClick={(row) => setHighlightedRowId(row.__id__)} onRowDoubleClick={viewRecord} /> diff --git a/apps/web/src/routes/_app/datahub/instruments/index.tsx b/apps/web/src/routes/_app/datahub/instruments/index.tsx index b280e754f..3ed79f02e 100644 --- a/apps/web/src/routes/_app/datahub/instruments/index.tsx +++ b/apps/web/src/routes/_app/datahub/instruments/index.tsx @@ -1,11 +1,9 @@ -import React, { useMemo, useState } from 'react'; +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 { InstrumentPreviewDialog } from '@/components/InstrumentPreviewDialog'; -import type { InstrumentPreviewItem } from '@/components/InstrumentPreviewDialog'; import { InstrumentTable } from '@/components/InstrumentTable'; import type { InstrumentRow } from '@/components/InstrumentTable'; import { PageHeader } from '@/components/PageHeader'; @@ -18,7 +16,6 @@ import { useAppStore } from '@/store'; const RouteComponent = () => { const navigate = useNavigate(); - const [preview, setPreview] = useState(null); const currentGroup = useAppStore((store) => store.currentGroup); const { t } = useTranslation(); @@ -31,31 +28,6 @@ const RouteComponent = () => { params: { bySeries: true, groupId: currentGroup?.id } }); - const infoById = useMemo( - () => new Map((instrumentInfoQuery.data ?? []).map((info) => [info.id, info])), - [instrumentInfoQuery.data] - ); - - /** - * The shape the shared preview dialog takes. `items` is only consulted to name a series' children, - * and this list has no series in it, so the dialog is handed an empty one. - */ - 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 - }; - }; - 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])); @@ -91,18 +63,7 @@ const RouteComponent = () => { })} - setPreview(toPreviewItem(row)) - } - ]} - rows={data} - onOpen={openInstrument} - /> - {preview && setPreview(null)} />} + ); }; 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 c3a4b0452..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,11 +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 { 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'; @@ -17,15 +18,21 @@ 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 } }); @@ -46,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 ( @@ -143,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/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 index c685f88b7..7bcde72dd 100644 --- a/apps/web/src/utils/__tests__/chart-palette.test.ts +++ b/apps/web/src/utils/__tests__/chart-palette.test.ts @@ -4,6 +4,7 @@ import { CHART_PALETTE_NAMES, CHART_PALETTE_THEMES, getCategoricalColor, + getMeasureColor, getPaletteSwatch, MAX_CATEGORICAL_GROUPS, NEUTRAL_MARK @@ -21,17 +22,34 @@ describe('CHART_PALETTE_NAMES', () => { }); describe('CHART_PALETTE_THEMES', () => { - it.each(Object.keys(CHART_PALETTE_THEMES))('should give the %s theme a colour per slot in both modes', (name) => { + // 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(MAX_CATEGORICAL_GROUPS); - expect(theme.dark).toHaveLength(MAX_CATEGORICAL_GROUPS); + 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-teal rather than the orange', () => { - expect(CHART_PALETTE_THEMES.jade.light[0]).toBe('#1baf7a'); - expect(CHART_PALETTE_THEMES.jade.dark[0]).toBe('#199e70'); + 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', () => { @@ -48,12 +66,32 @@ describe('getCategoricalColor', () => { }); // 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. - it('should fall back to the neutral mark past the last slot rather than cycling', () => { + // 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/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 index 568583c92..c7bec5362 100644 --- a/apps/web/src/utils/chart-palette.ts +++ b/apps/web/src/utils/chart-palette.ts @@ -1,54 +1,44 @@ /** - * The categorical palettes for charts that colour marks by a grouping dimension. + * 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. * - * Three slots per theme, not more: a scatter puts every pair of series on screen at once, and only - * a three-colour set clears the colourblind and normal-vision separation floors against both - * surfaces as an all-pairs set. A grouping with more values folds its tail into `OTHER_GROUP_KEY` - * rather than growing the palette. Each theme's dark column is the same three hues re-stepped for - * the dark surface, not an automatic inversion of the light one. + * 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. * - * Every theme was verified with the data-visualization skill's palette validator at `--pairs all` - * in both modes, and all three clear the 6–8 CVD band outright rather than relying on secondary - * encoding — worst-pair CVD ΔE: default 9.2 light / 9.4 dark, berry 13.0 / 13.0, jade 9.2 / 9.4. + * Each theme's dark column is the same hues re-stepped for the dark surface, not an inversion of + * the light one. * - * Slot order matters as well as membership: slot one is what an ungrouped chart draws with and what - * the first group takes, so each theme leads with the hue it is named for. `jade` therefore carries - * the same three hues as the set it replaced, led by the green-teal instead of the orange — it is - * the only green-led trio that passes, since jade with magenta and yellow collapses to ΔE 1.6 in - * dark and green with blue and orange fails outright in both modes. - * - * The hues of a theme are deliberately spread around the wheel. Sets drawn from one side of it — - * an all-warm orange/red/yellow, or a blue/violet/aqua — were tried and rejected: they fail the - * separation floors outright (orange↔red ΔE 5.6, blue↔violet 1.9 in dark), because small hue gaps - * cannot survive a colourblind transform. A single-hue ramp is likewise absent: it fails the - * ordinal light-end contrast check, and a nominal grouping is not a magnitude to shade anyway. - * - * Several light-mode slots sit below 3:1 against the light surface, which obliges the relief the - * charts already carry: a legend, and the sibling table showing the same records. + * - 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: ['#d55181', '#008300', '#3987e5'], - light: ['#e87ba4', '#008300', '#2a78d6'] + dark: ['#5b9bd5', '#9b72cb', '#e57aad', '#6b5bd5', '#c850c8', '#e8859b'], + light: ['#3a7cc6', '#7b4fb8', '#d4548e', '#4a3cb5', '#a830a8', '#d4607a'] }, default: { - dark: ['#3987e5', '#d95926', '#199e70'], - light: ['#2a78d6', '#eb6834', '#1baf7a'] + 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: ['#199e70', '#9085e9', '#d95926'], - light: ['#1baf7a', '#4a3aa7', '#eb6834'] + 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. Written out rather than derived from the themes - * object, whose key order is alphabetical because eslint sorts object literals — which would bury - * the default in the middle. `chart-palette.test.ts` pins both the leading entry and completeness. - */ -const CHART_PALETTE_NAMES = ['default', 'berry', 'jade'] as const satisfies readonly ChartPaletteName[]; +/** 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 @@ -61,12 +51,31 @@ const NEUTRAL_MARK = { const OTHER_GROUP_KEY = '__other__'; -const MAX_CATEGORICAL_GROUPS = CHART_PALETTE_THEMES.default.light.length; +/** + * 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]; @@ -76,6 +85,7 @@ export { CHART_PALETTE_NAMES, CHART_PALETTE_THEMES, getCategoricalColor, + getMeasureColor, getPaletteSwatch, MAX_CATEGORICAL_GROUPS, NEUTRAL_MARK, 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/tag-colors.ts b/apps/web/src/utils/tag-colors.ts index 5f6dc30c8..d9072bc6e 100644 --- a/apps/web/src/utils/tag-colors.ts +++ b/apps/web/src/utils/tag-colors.ts @@ -1,7 +1,5 @@ import type { InstrumentKind } from '@opendatacapture/runtime-core'; -import { getCategoricalColor, NEUTRAL_MARK } from '@/utils/chart-palette'; - type Mode = 'dark' | 'light'; /** @@ -17,23 +15,31 @@ const SEX_COLORS = { 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]; } -/** - * One palette slot per instrument kind. - * - * A series is the neutral mark rather than a fourth hue: the palette holds three validated slots, - * and a generated fourth is never the answer. - */ function getInstrumentKindColor(kind: InstrumentKind, mode: Mode): string { - const slots: { [K in InstrumentKind]: null | number } = { FILE: 2, FORM: 0, INTERACTIVE: 1, SERIES: null }; - const slot = slots[kind]; - return slot === null ? NEUTRAL_MARK[mode] : getCategoricalColor(slot, mode, 'default'); + return INSTRUMENT_KIND_COLORS[kind][mode]; } export { getInstrumentKindColor, getSexColor };