From f7bf395cb9cffbeb760dce5abf37520ab8210f75 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Fri, 9 Jan 2026 13:51:41 -0600 Subject: [PATCH 1/4] migrate checklist definitions and logic to shared package --- .claude/settings.json | 5 + .../docs/audits/yjs-state-inspector-plan.md | 713 +++++++++++ .../src/checklists/__tests__/amstar2.test.ts | 267 ++++ .../src/checklists/__tests__/robins-i.test.ts | 274 ++++ .../src/checklists/__tests__/status.test.ts | 111 ++ .../shared/src/checklists/amstar2/answers.ts | 145 +++ .../shared/src/checklists/amstar2/compare.ts | 352 +++++ .../shared/src/checklists/amstar2/create.ts | 175 +++ .../shared/src/checklists/amstar2/index.ts | 21 + .../shared/src/checklists/amstar2/schema.ts | 449 +++++++ .../shared/src/checklists/amstar2/score.ts | 139 ++ packages/shared/src/checklists/domain.ts | 283 ++++ packages/shared/src/checklists/index.ts | 45 + .../shared/src/checklists/robins-i/answers.ts | 203 +++ .../shared/src/checklists/robins-i/create.ts | 144 +++ .../shared/src/checklists/robins-i/index.ts | 18 + .../shared/src/checklists/robins-i/schema.ts | 611 +++++++++ .../shared/src/checklists/robins-i/scoring.ts | 1030 +++++++++++++++ packages/shared/src/checklists/status.ts | 115 ++ packages/shared/src/checklists/types.ts | 173 +++ packages/shared/src/index.ts | 3 +- packages/web/src/Routes.jsx | 20 +- .../AMSTAR2Checklist/checklist-compare.js | 352 +---- .../AMSTAR2Checklist/checklist-map.js | 389 +----- .../checklist/AMSTAR2Checklist/checklist.js | 314 +---- .../ROBINSIChecklist/checklist-map.js | 620 +-------- .../checklist/ROBINSIChecklist/checklist.js | 413 +----- .../scoring/robins-scoring.js | 1136 +---------------- .../web/src/components/mock/MockIndex.jsx | 38 - .../src/components/mocks/AddStudiesInline.jsx | 532 ++++++++ .../src/components/mocks/AddStudiesPanel.jsx | 554 ++++++++ .../src/components/mocks/AddStudiesWizard.jsx | 662 ++++++++++ .../web/src/components/mocks/MockIndex.jsx | 204 +++ .../components/mocks/ProjectViewComplete.jsx | 831 ++++++++++++ .../components/mocks/ProjectViewDashboard.jsx | 436 +++++++ .../components/mocks/ProjectViewEditorial.jsx | 343 +++++ .../components/mocks/ProjectViewKanban.jsx | 495 +++++++ 37 files changed, 9481 insertions(+), 3134 deletions(-) create mode 100644 .claude/settings.json create mode 100644 packages/docs/audits/yjs-state-inspector-plan.md create mode 100644 packages/shared/src/checklists/__tests__/amstar2.test.ts create mode 100644 packages/shared/src/checklists/__tests__/robins-i.test.ts create mode 100644 packages/shared/src/checklists/__tests__/status.test.ts create mode 100644 packages/shared/src/checklists/amstar2/answers.ts create mode 100644 packages/shared/src/checklists/amstar2/compare.ts create mode 100644 packages/shared/src/checklists/amstar2/create.ts create mode 100644 packages/shared/src/checklists/amstar2/index.ts create mode 100644 packages/shared/src/checklists/amstar2/schema.ts create mode 100644 packages/shared/src/checklists/amstar2/score.ts create mode 100644 packages/shared/src/checklists/domain.ts create mode 100644 packages/shared/src/checklists/index.ts create mode 100644 packages/shared/src/checklists/robins-i/answers.ts create mode 100644 packages/shared/src/checklists/robins-i/create.ts create mode 100644 packages/shared/src/checklists/robins-i/index.ts create mode 100644 packages/shared/src/checklists/robins-i/schema.ts create mode 100644 packages/shared/src/checklists/robins-i/scoring.ts create mode 100644 packages/shared/src/checklists/status.ts create mode 100644 packages/shared/src/checklists/types.ts delete mode 100644 packages/web/src/components/mock/MockIndex.jsx create mode 100644 packages/web/src/components/mocks/AddStudiesInline.jsx create mode 100644 packages/web/src/components/mocks/AddStudiesPanel.jsx create mode 100644 packages/web/src/components/mocks/AddStudiesWizard.jsx create mode 100644 packages/web/src/components/mocks/MockIndex.jsx create mode 100644 packages/web/src/components/mocks/ProjectViewComplete.jsx create mode 100644 packages/web/src/components/mocks/ProjectViewDashboard.jsx create mode 100644 packages/web/src/components/mocks/ProjectViewEditorial.jsx create mode 100644 packages/web/src/components/mocks/ProjectViewKanban.jsx diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..903088848 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "frontend-design@claude-plugins-official": true + } +} diff --git a/packages/docs/audits/yjs-state-inspector-plan.md b/packages/docs/audits/yjs-state-inspector-plan.md new file mode 100644 index 000000000..64771f9e9 --- /dev/null +++ b/packages/docs/audits/yjs-state-inspector-plan.md @@ -0,0 +1,713 @@ +# Yjs State Inspector & Editor - Development Tool Plan + +**Purpose**: Create a development tool to inspect, edit, and seed Yjs document state for faster iteration during development. + +**Date**: 2026-01-09 +**Updated**: 2026-01-10 + +--- + +## Executive Summary + +This plan outlines a comprehensive development tool that provides visibility into Yjs document structures stored in Cloudflare Durable Objects. The tool will enable developers to: + +1. **Inspect** current project state (studies, checklists, answers, PDFs, members) +2. **Edit** existing data at any level of the hierarchy +3. **Import/Export** project state as JSON for backup, sharing, and seeding +4. **Seed mock data** to quickly reach specific application states + +--- + +## Implementation Status + +| Phase | Status | Description | +| ------- | ----------- | -------------------------------------- | +| Phase 0 | COMPLETE | Refactor checklist logic to shared pkg | +| Phase 1 | Not Started | Backend API endpoints | +| Phase 2 | Not Started | CLI tool | +| Phase 3 | Not Started | Mock data templates | +| Phase 4 | Not Started | UI panel | + +--- + +## Current Architecture Analysis + +### Yjs Document Structure (ProjectDoc Durable Object) + +``` +Project (Y.Doc persisted in DO storage as 'yjs-state') +| ++-- meta (Y.Map) +| +-- name: string +| +-- description: string +| +-- createdAt: number +| +-- updatedAt: number +| ++-- members (Y.Map) +| +-- [userId] (Y.Map) +| +-- role: 'owner' | 'admin' | 'member' +| +-- joinedAt: number +| +-- name: string | null +| +-- email: string | null +| +-- displayName: string | null +| +-- image: string | null +| ++-- reviews (Y.Map) // Note: key is 'reviews' for backward compat + +-- [studyId] (Y.Map) + +-- name: string + +-- description: string + +-- createdAt: number + +-- updatedAt: number + +-- originalTitle: string | null + +-- firstAuthor: string | null + +-- publicationYear: string | null + +-- authors: string | null + +-- journal: string | null + +-- doi: string | null + +-- abstract: string | null + +-- pdfUrl: string | null + +-- pdfSource: string | null + +-- pdfAccessible: boolean + +-- reviewer1: string | null + +-- reviewer2: string | null + | + +-- checklists (Y.Map) + | +-- [checklistId] (Y.Map) + | +-- type: 'AMSTAR2' | 'ROBINS_I' + | +-- title: string | null + | +-- assignedTo: string | null + | +-- status: 'pending' | 'in_progress' | 'finalized' + | +-- createdAt: number + | +-- updatedAt: number + | +-- answers (Y.Map) -- varies by checklist type + | + +-- pdfs (Y.Map) + | +-- [fileName] (Y.Map) + | +-- key: string (R2 bucket key) + | +-- fileName: string + | +-- size: number + | +-- uploadedBy: string + | +-- uploadedAt: number + | + +-- reconciliation (Y.Map) -- optional + +-- checklist1Id: string + +-- checklist2Id: string + +-- reconciledChecklistId: string | null + +-- currentPage: number + +-- viewMode: string + +-- updatedAt: number +``` + +### Checklist Answer Structures + +**AMSTAR2**: Each question (q1-q16, q9a/b, q11a/b) contains: + +- `answers`: `boolean[][]` - Multi-dimensional checkbox arrays +- `critical`: `boolean` - Critical domain flag +- `note`: `Y.Text` - Collaborative notes + +**ROBINS-I**: Complex nested structure with domains: + +- Domain questions (1a-6) with `judgement`, `judgementSource`, `direction` +- `answers`: Y.Map of questions with `answer` and `comment` (Y.Text) +- Section A/B/C/D with various fields +- Overall judgement + +### Current APIs + +| Endpoint | Method | Purpose | +| ----------------------------- | --------------- | ------------------------------------------------------ | +| `/api/project-doc/:projectId` | GET | Returns project info as JSON (uses `getProjectInfo()`) | +| `/api/project-doc/:projectId` | WebSocket | Real-time sync via y-websocket | +| `/sync` | POST (internal) | Sync meta + members from D1 | +| `/sync-member` | POST (internal) | Add/update/remove single member | +| `/sync-pdf` | POST (internal) | Add/remove PDF metadata | + +### Key Utilities + +- `packages/web/src/lib/yjsUtils.js`: + - `yToPlain(value)` - Convert Y.Map/Y.Array to plain JS + - `applyObjectToYMap(target, obj)` - Apply plain object to Y.Map + +--- + +## Proposed Solution + +### Phase 0: Refactor Checklist Logic to Shared Package [COMPLETE] + +**Status**: COMPLETE (2026-01-10) + +**What was done**: + +1. Created `packages/shared/src/checklists/` directory structure +2. Moved AMSTAR2 logic to `amstar2/` subdirectory: + - `schema.ts` - AMSTAR_CHECKLIST map with all 16 questions + - `create.ts` - createAMSTAR2Checklist function + - `score.ts` - scoreAMSTAR2Checklist, isAMSTAR2Complete + - `answers.ts` - getSelectedAnswer, getAnswers, consolidateAnswers + - `compare.ts` - compareChecklists, createReconciledChecklist +3. Moved ROBINS-I logic to `robins-i/` subdirectory: + - `schema.ts` - ROBINS_I_CHECKLIST with all domains, response types + - `create.ts` - createROBINSIChecklist function + - `scoring.ts` - Full smart scoring engine (~800 lines) + - `answers.ts` - shouldStopAssessment, scoreROBINSIChecklist, getDomainSummary +4. Created shared types and status helpers: + - `types.ts` - TypeScript interfaces for all checklist structures + - `status.ts` - CHECKLIST_STATUS constants and helpers + - `domain.ts` - Domain logic for filtering checklists +5. Updated web package to re-export from shared: + - All existing imports continue to work via re-export shims + - UI components stay in web package, business logic in shared +6. Test coverage: + - 122 tests in shared package (all passing) + - 173 checklist tests in web package (all passing) + +**Files created/modified**: + +``` +packages/shared/src/checklists/ + index.ts - Main exports + types.ts - TypeScript interfaces + status.ts - CHECKLIST_STATUS + helpers + domain.ts - Domain logic + amstar2/ + index.ts, schema.ts, create.ts, score.ts, answers.ts, compare.ts + robins-i/ + index.ts, schema.ts, create.ts, scoring.ts, answers.ts + __tests__/ + amstar2.test.ts, robins-i.test.ts, status.test.ts + +packages/web/src/components/checklist/ (updated to re-export from shared) + AMSTAR2Checklist/checklist.js, checklist-map.js, checklist-compare.js + ROBINSIChecklist/checklist.js, checklist-map.js, scoring/robins-scoring.js +``` + +**Usage**: + +```typescript +// New code should import directly from shared +import { amstar2, robinsI, CHECKLIST_STATUS } from '@corates/shared'; + +// Create checklist +const checklist = amstar2.createAMSTAR2Checklist({ name: 'Test', id: 'test-1' }); + +// Score checklist +const score = amstar2.scoreAMSTAR2Checklist(checklist); +``` + +--- + +### Phase 1: Backend API Endpoints (Dev-Only) + +Dynamically imported based on environment similar to other dev routes. +Add new internal endpoints to ProjectDoc durable object for state manipulation: + +```javascript +// New endpoints (require X-Internal-Request header + DEV mode) +POST /dev/export - Export full Y.Doc state as JSON +POST /dev/import - Import/replace Y.Doc state from JSON +POST /dev/patch - Apply partial updates to Y.Doc +POST /dev/reset - Reset Y.Doc to empty state +GET /dev/raw - Get raw Yjs binary state (for debugging) +``` + +**Implementation in `ProjectDoc.js`**: + +```javascript +// Add to fetch() after existing internal request handling +if (isInternalRequest && this.env.DEV_MODE) { + if (url.pathname === '/dev/export') { + return await this.handleDevExport(request); + } + if (url.pathname === '/dev/import') { + return await this.handleDevImport(request); + } + if (url.pathname === '/dev/patch') { + return await this.handleDevPatch(request); + } + if (url.pathname === '/dev/reset') { + return await this.handleDevReset(request); + } + if (url.pathname === '/dev/raw') { + return await this.handleDevRaw(request); + } +} +``` + +### Phase 2: Worker Routes for Dev Tools + +Create new route file `packages/workers/src/routes/dev.js`: + +```javascript +// Only enabled when DEV_MODE=true in wrangler.jsonc +export const devRoutes = new Hono(); + +devRoutes.use('*', async (c, next) => { + if (!c.env.DEV_MODE) { + return c.json({ error: 'Dev routes disabled in production' }, 403); + } + await next(); +}); + +// Export project Yjs state as JSON +devRoutes.get('/projects/:projectId/export', async (c) => { ... }); + +// Import project Yjs state from JSON +devRoutes.post('/projects/:projectId/import', async (c) => { ... }); + +// Patch specific paths in Yjs state +devRoutes.patch('/projects/:projectId/patch', async (c) => { ... }); + +// Reset project Yjs state +devRoutes.delete('/projects/:projectId/reset', async (c) => { ... }); + +// List all projects with their Yjs state summaries +devRoutes.get('/projects', async (c) => { ... }); + +// Seed a project with mock data template +devRoutes.post('/projects/:projectId/seed', async (c) => { ... }); +``` + +### Phase 3: Mock Data Templates + +Create `packages/workers/src/lib/mock-templates.js`: + +```javascript +export const MOCK_TEMPLATES = { + // Empty project with just meta + 'empty': { meta: {...}, members: [], reviews: [] }, + + // Project with studies but no checklists + 'studies-only': { ... }, + + // Project with completed AMSTAR2 checklists + 'amstar2-complete': { ... }, + + // Project with in-progress ROBINS-I + 'robins-i-progress': { ... }, + + // Project ready for reconciliation (2 completed checklists per study) + 'reconciliation-ready': { ... }, + + // Complex project with mixed states + 'full-workflow': { ... }, +}; + +// Helper to generate valid checklist answers +export function generateAMSTAR2Answers(options = {}) { ... } +export function generateROBINSIAnswers(options = {}) { ... } +``` + +### Phase 4: Frontend Dev Panel Component + +Create `packages/web/src/components/dev/DevPanel.jsx`: + +```jsx +// Floating dev panel (only rendered in dev mode) +// Features: +// 1. Tree view of current Y.Doc state +// 2. Inline editing of values +// 3. Export/Import buttons +// 4. Template seeding dropdown +// 5. Quick actions (clear checklists, reset answers, etc.) +``` + +**Component structure**: + +``` +DevPanel/ + DevPanel.jsx - Main floating panel component + DevStateTree.jsx - Recursive tree view of Y.Doc + DevJsonEditor.jsx - JSON editor for import/export + DevTemplateSelector.jsx - Dropdown for mock templates + DevQuickActions.jsx - Buttons for common operations +``` + +### Phase 5: CLI Tool for Terminal-Based Workflow + +Create `packages/workers/scripts/dev-tools.js`: + +```bash +# Export project state to file +pnpm dev:export --project= --output=project-state.json + +# Import project state from file +pnpm dev:import --project= --input=project-state.json + +# Seed project with template +pnpm dev:seed --project= --template=reconciliation-ready + +# List projects with state summary +pnpm dev:list + +# Reset project state +pnpm dev:reset --project= +``` + +--- + +## Implementation Details + +### 1. Export Format (JSON) + +```json +{ + "version": 1, + "exportedAt": "2026-01-09T12:00:00.000Z", + "projectId": "xxx", + "meta": { + "name": "...", + "description": "...", + "createdAt": 1704844800000, + "updatedAt": 1704844800000 + }, + "members": [ + { + "userId": "user_xxx", + "role": "owner", + "joinedAt": 1704844800000, + "name": "John Doe", + "email": "john@example.com" + } + ], + "studies": [ + { + "id": "study_xxx", + "name": "Study Name", + "originalTitle": "...", + "firstAuthor": "...", + "journal": "...", + "checklists": [ + { + "id": "checklist_xxx", + "type": "AMSTAR2", + "status": "in_progress", + "assignedTo": "user_xxx", + "answers": { + "q1": { "answers": [[true, false, false, false], [false], [false, false]], "critical": false }, + "q2": { + "answers": [ + [false, true, false, false], + [false, false, false], + [false, false, false] + ], + "critical": true + } + } + } + ], + "pdfs": [] + } + ] +} +``` + +### 2. Import Logic + +```javascript +async handleDevImport(request) { + const { data, mode = 'replace' } = await request.json(); + // mode: 'replace' (clear & replace) or 'merge' (deep merge) + + await this.initializeDoc(); + + this.doc.transact(() => { + if (mode === 'replace') { + // Clear existing data + this.doc.getMap('meta').clear(); + this.doc.getMap('members').clear(); + this.doc.getMap('reviews').clear(); + } + + // Apply new data using applyObjectToYMap-style logic + this.applyImportData(data); + }); + + return Response.json({ success: true }); +} +``` + +### 3. Patch Logic (For Surgical Updates) + +```javascript +// PATCH body format +{ + "operations": [ + { + "path": "studies.study_xxx.checklists.checklist_xxx.status", + "value": "finalized" + }, + { + "path": "studies.study_xxx.checklists.checklist_xxx.answers.q1.critical", + "value": true + } + ] +} +``` + +### 4. DevPanel UI Wireframe + +``` ++------------------------------------------+ +| Dev Tools [x] | ++------------------------------------------+ +| [Export JSON] [Import JSON] [Reset] | +| [Template: ▼ reconciliation-ready ] | ++------------------------------------------+ +| Project State: | +| | +| v meta | +| name: "My Project" [edit]| +| description: "..." [edit]| +| | +| v members (2) | +| > user_abc123 (owner) | +| > user_def456 (member) | +| | +| v studies (3) | +| v study_001 | +| name: "Study 1" [edit]| +| v checklists (2) | +| v checklist_a (AMSTAR2) | +| status: in_progress [edit]| +| v answers | +| > q1: [partial] | +| > q2: [complete] | ++------------------------------------------+ +``` + +--- + +## File Changes Summary + +### New Files + +| File | Purpose | +| --------------------------------------------------------- | --------------------------- | +| **Shared Package (Phase 0)** | | +| `packages/shared/src/checklists/index.ts` | Main checklist exports | +| `packages/shared/src/checklists/types.ts` | TypeScript types/interfaces | +| `packages/shared/src/checklists/status.ts` | CHECKLIST_STATUS + helpers | +| `packages/shared/src/checklists/domain.ts` | Domain logic (filtering) | +| `packages/shared/src/checklists/amstar2/index.ts` | AMSTAR2 exports | +| `packages/shared/src/checklists/amstar2/schema.ts` | AMSTAR2 checklist map | +| `packages/shared/src/checklists/amstar2/create.ts` | createChecklist function | +| `packages/shared/src/checklists/amstar2/score.ts` | Scoring functions | +| `packages/shared/src/checklists/amstar2/answers.ts` | Answer manipulation | +| `packages/shared/src/checklists/robins-i/index.ts` | ROBINS-I exports | +| `packages/shared/src/checklists/robins-i/schema.ts` | ROBINS-I checklist map | +| `packages/shared/src/checklists/robins-i/create.ts` | createChecklist function | +| `packages/shared/src/checklists/robins-i/score.ts` | Scoring functions | +| `packages/shared/src/checklists/robins-i/answers.ts` | Answer manipulation | +| `packages/shared/src/checklists/generators/index.ts` | Mock data generator exports | +| `packages/shared/src/checklists/generators/amstar2.ts` | AMSTAR2 state generator | +| `packages/shared/src/checklists/generators/robins-i.ts` | ROBINS-I state generator | +| **Workers Package (Phase 1-3)** | | +| `packages/workers/src/routes/dev.js` | Dev-only API routes | +| `packages/workers/scripts/dev-tools.js` | CLI tool | +| **Web Package (Phase 4)** | | +| `packages/web/src/components/dev/DevPanel.jsx` | Main dev panel | +| `packages/web/src/components/dev/DevStateTree.jsx` | Tree view component | +| `packages/web/src/components/dev/DevJsonEditor.jsx` | JSON editor | +| `packages/web/src/components/dev/DevTemplateSelector.jsx` | Template selector | +| `packages/web/src/components/dev/DevQuickActions.jsx` | Quick action buttons | +| `packages/web/src/stores/devStore.js` | Dev panel state | + +### Modified Files + +| File | Changes | +| ---------------------------------------------------------- | -------------------------------------------------- | +| **Phase 0 (Refactor)** | | +| `packages/shared/src/index.ts` | Export checklists module | +| `packages/shared/package.json` | Add any needed dependencies | +| `packages/web/src/components/checklist/AMSTAR2Checklist/*` | Import from `@corates/shared` instead of local | +| `packages/web/src/components/checklist/ROBINSIChecklist/*` | Import from `@corates/shared` instead of local | +| `packages/web/src/constants/checklist-status.js` | Re-export from `@corates/shared` (backward compat) | +| `packages/web/src/lib/checklist-domain.js` | Re-export from `@corates/shared` (backward compat) | +| **Phase 1-5** | | +| `packages/workers/src/durable-objects/ProjectDoc.js` | Add dev endpoints | +| `packages/workers/src/index.js` | Mount dev routes | +| `packages/workers/wrangler.jsonc` | Add DEV_MODE var | +| `packages/web/src/App.jsx` | Conditionally render DevPanel | +| `packages/workers/package.json` | Add CLI scripts | + +--- + +## Security Considerations + +1. **Dev-only access**: All dev endpoints check `env.DEV_MODE` which should only be `true` in local/dev environments +2. **No production exposure**: Routes return 403 if DEV_MODE is not set +3. **Internal request header**: Routes require `X-Internal-Request: true` +4. **No auth bypass**: Regular endpoints still require authentication + +--- + +## Implementation Order + +### Milestone 0: Refactor Checklist Logic to Shared (Est: 6-8 hours) + +**This milestone is foundational - enables all mock data generation to use real logic.** + +1. Create `packages/shared/src/checklists/` directory structure +2. Move `CHECKLIST_STATUS` and helpers to `status.ts` +3. Move AMSTAR2 schema and logic: + - `checklist-map.js` -> `amstar2/schema.ts` + - `createChecklist` -> `amstar2/create.ts` + - `scoreChecklist`, `isAMSTAR2Complete` -> `amstar2/score.ts` + - `getAnswers`, answer helpers -> `amstar2/answers.ts` +4. Move ROBINS-I schema and logic: + - `checklist-map.js` -> `robins-i/schema.ts` + - `createChecklist` -> `robins-i/create.ts` + - `scoreChecklist`, scoring logic -> `robins-i/score.ts` + - `getAnswers`, domain helpers -> `robins-i/answers.ts` +5. Move `checklist-domain.js` -> `domain.ts` +6. Create `generators/` with mock data helpers +7. Update web package imports to use `@corates/shared` +8. Run tests, fix any breaks +9. Remove duplicate code from web package (keep re-exports for gradual migration) + +### Milestone 1: Backend Foundation (Est: 4-6 hours) + +1. Add DEV_MODE to wrangler.jsonc +2. Implement `/dev/export` endpoint in ProjectDoc.js +3. Implement `/dev/import` endpoint +4. Create dev.js route file and mount it +5. Test with curl/httpie + +### Milestone 2: Mock Templates (Est: 2-3 hours) + +1. Use `@corates/shared` generators to create templates +2. Implement `/dev/seed` endpoint +3. Create 4-5 useful templates (empty, studies-only, amstar2-complete, reconciliation-ready, full-workflow) +4. Test seeding workflow + +### Milestone 3: CLI Tool (Est: 2-3 hours) + +1. Create dev-tools.js script +2. Add npm scripts to package.json +3. Document usage + +### Milestone 4: Frontend DevPanel (Est: 6-8 hours) + +1. Create devStore.js +2. Implement DevPanel skeleton with toggle +3. Build DevStateTree with recursive rendering +4. Add inline editing capability +5. Implement export/import UI +6. Add template selector dropdown +7. Style with existing UI patterns + +### Milestone 5: Polish & Documentation (Est: 2 hours) + +1. Add keyboard shortcuts (Cmd+Shift+D to toggle) +2. Document in packages/docs +3. Add quick actions for common workflows + +**Total Estimated: 22-30 hours** (increased from 18-22 due to shared package refactor) + +--- + +## Alternative Approaches Considered + +### 1. Direct IndexedDB Manipulation (Client-side only) + +- **Pros**: No backend changes needed +- **Cons**: Only affects local state, doesn't sync, can cause conflicts + +### 2. Wrangler D1/KV CLI + +- **Pros**: No code changes +- **Cons**: Yjs state is binary, not directly readable/editable + +### 3. Separate Admin App + +- **Pros**: Clean separation +- **Cons**: More infrastructure, harder to use in context + +### 4. Browser DevTools Extension + +- **Pros**: Professional tooling +- **Cons**: High development cost, browser-specific + +**Chosen Approach**: In-app dev panel + API routes provides the best balance of: + +- Direct access to Yjs state +- Works in development context +- Easy to iterate on +- Can evolve into user-facing import/export feature + +--- + +## Future Enhancements + +1. **Project cloning**: Clone a project's Yjs state to a new project +2. **Diff viewer**: Compare two project states side-by-side +3. **History/undo**: Track state changes with ability to rollback +4. **Shared templates**: Team-shared mock data templates +5. **State validation**: Validate Yjs state against expected schema +6. **Performance metrics**: Show Yjs doc size, update count, etc. + +--- + +## Questions/Decisions Needed + +1. Should the DevPanel be lazy-loaded to avoid bundle size impact in production? +2. Should we support partial imports (merge mode) or always require full replacement? +3. Do we want the CLI tool to work with remote dev environments or just local? +4. Should mock templates include realistic citation metadata or placeholder text? +5. **Shared package**: Should we keep JS or convert to TypeScript during migration? +6. **Shared package**: Keep backward-compatible re-exports in web package or update all imports at once? + +--- + +## Appendix: Checklist Logic to Move + +### From `packages/web/src/components/checklist/AMSTAR2Checklist/checklist.js` + +```javascript +// Pure functions - safe to move +export function createChecklist({ name, id, createdAt, reviewerName }) { ... } +export function scoreChecklist(state) { ... } +export function isAMSTAR2Complete(checklist) { ... } +export function getAnswers(checklist) { ... } +export function exportChecklistsToCSV(checklists) { ... } // May need adjustment for Node.js +``` + +### From `packages/web/src/components/checklist/ROBINSIChecklist/checklist.js` + +```javascript +// Pure functions - safe to move +export function createChecklist({ name, id, createdAt, reviewerName }) { ... } +export function shouldStopAssessment(sectionB) { ... } +export function scoreChecklist(state) { ... } +export function getSmartScoring(state) { ... } +export function suggestDomainJudgement(domainKey, answers) { ... } +export function getSelectedAnswer(domainKey, questionKey, state) { ... } +export function getAnswers(checklist) { ... } +export function getDomainSummary(checklist) { ... } +export function exportChecklistsToCSV(checklists) { ... } +``` + +### From `packages/web/src/constants/checklist-status.js` + +```javascript +export const CHECKLIST_STATUS = { ... } +export function isEditable(status) { ... } +export function getStatusLabel(status) { ... } +export function getStatusStyle(status) { ... } // Keep in web (UI-specific) +export function canTransitionTo(currentStatus, newStatus) { ... } +``` + +### From `packages/web/src/lib/checklist-domain.js` + +```javascript +export function isReconciledChecklist(checklist) { ... } +export function getTodoChecklists(study, userId) { ... } +export function getCompletedChecklists(study) { ... } +export function getFinalizedChecklist(study) { ... } +export function getReconciliationChecklists(study) { ... } +// ... more domain logic +``` + +3. Do we want the CLI tool to work with remote dev environments or just local? +4. Should mock templates include realistic citation metadata or placeholder text? diff --git a/packages/shared/src/checklists/__tests__/amstar2.test.ts b/packages/shared/src/checklists/__tests__/amstar2.test.ts new file mode 100644 index 000000000..aaa54fbec --- /dev/null +++ b/packages/shared/src/checklists/__tests__/amstar2.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect } from 'vitest'; +import { + createAMSTAR2Checklist, + scoreAMSTAR2Checklist, + isAMSTAR2Complete, + getSelectedAnswer, + getAnswers, + consolidateAnswers, +} from '../amstar2/index.js'; + +describe('AMSTAR2', () => { + describe('createAMSTAR2Checklist', () => { + it('should create a checklist with all required fields', () => { + const checklist = createAMSTAR2Checklist({ + name: 'Test Checklist', + id: 'test-123', + reviewerName: 'Alice', + }); + + expect(checklist.name).toBe('Test Checklist'); + expect(checklist.id).toBe('test-123'); + expect(checklist.reviewerName).toBe('Alice'); + expect(checklist.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}$/); + + // Check all questions exist + expect(checklist.q1).toBeDefined(); + expect(checklist.q2).toBeDefined(); + expect(checklist.q9a).toBeDefined(); + expect(checklist.q9b).toBeDefined(); + expect(checklist.q16).toBeDefined(); + }); + + it('should throw if id is missing', () => { + expect(() => + createAMSTAR2Checklist({ + name: 'Test', + id: '', + }), + ).toThrow('non-empty string id'); + }); + + it('should throw if name is missing', () => { + expect(() => + createAMSTAR2Checklist({ + name: '', + id: 'test-123', + }), + ).toThrow('non-empty string name'); + }); + + it('should set critical flags correctly', () => { + const checklist = createAMSTAR2Checklist({ + name: 'Test', + id: 'test-123', + }); + + // Critical questions: q2, q4, q7, q9a, q9b, q11a, q11b, q13, q15 + expect(checklist.q2.critical).toBe(true); + expect(checklist.q4.critical).toBe(true); + expect(checklist.q7.critical).toBe(true); + expect(checklist.q9a.critical).toBe(true); + expect(checklist.q9b.critical).toBe(true); + expect(checklist.q11a.critical).toBe(true); + expect(checklist.q11b.critical).toBe(true); + expect(checklist.q13.critical).toBe(true); + expect(checklist.q15.critical).toBe(true); + + // Non-critical questions + expect(checklist.q1.critical).toBe(false); + expect(checklist.q3.critical).toBe(false); + expect(checklist.q5.critical).toBe(false); + expect(checklist.q6.critical).toBe(false); + }); + }); + + describe('getSelectedAnswer', () => { + it('should return Yes for first option selected in 2-option question', () => { + const answers = [[false], [true, false]]; + expect(getSelectedAnswer(answers, 'q1')).toBe('Yes'); + }); + + it('should return No for second option selected in 2-option question', () => { + const answers = [[false], [false, true]]; + expect(getSelectedAnswer(answers, 'q1')).toBe('No'); + }); + + it('should return Partial Yes for second option in 3-option question', () => { + const answers = [ + [false, false, false], + [false, true, false], + ]; + expect(getSelectedAnswer(answers, 'q2')).toBe('Partial Yes'); + }); + + it('should return null if no option selected', () => { + const answers = [[false], [false, false]]; + expect(getSelectedAnswer(answers, 'q1')).toBe(null); + }); + + it('should handle No MA for questions with that option', () => { + const answers = [ + [false, false, false], + [false, false, true], + ]; + expect(getSelectedAnswer(answers, 'q11a')).toBe('No MA'); + }); + }); + + describe('isAMSTAR2Complete', () => { + it('should return false for empty checklist', () => { + const checklist = createAMSTAR2Checklist({ + name: 'Test', + id: 'test-123', + }); + + expect(isAMSTAR2Complete(checklist)).toBe(false); + }); + + it('should return true when all questions answered', () => { + const checklist = createAMSTAR2Checklist({ + name: 'Test', + id: 'test-123', + }); + + // Set all final column answers to true (first option = Yes) + checklist.q1.answers[2][0] = true; + checklist.q2.answers[2][0] = true; + checklist.q3.answers[1][0] = true; + checklist.q4.answers[2][0] = true; + checklist.q5.answers[1][0] = true; + checklist.q6.answers[1][0] = true; + checklist.q7.answers[2][0] = true; + checklist.q8.answers[2][0] = true; + checklist.q9a.answers[2][0] = true; + checklist.q9b.answers[2][0] = true; + checklist.q10.answers[1][0] = true; + checklist.q11a.answers[1][0] = true; + checklist.q11b.answers[1][0] = true; + checklist.q12.answers[1][0] = true; + checklist.q13.answers[1][0] = true; + checklist.q14.answers[1][0] = true; + checklist.q15.answers[1][0] = true; + checklist.q16.answers[1][0] = true; + + expect(isAMSTAR2Complete(checklist)).toBe(true); + }); + }); + + describe('scoreAMSTAR2Checklist', () => { + it('should return Error for invalid input', () => { + expect(scoreAMSTAR2Checklist(null as any)).toBe('Error'); + }); + + it('should return High when all answers are Yes', () => { + const checklist = createAMSTAR2Checklist({ + name: 'Test', + id: 'test-123', + }); + + // Set all final column answers to true (first option = Yes) + checklist.q1.answers[2][0] = true; + checklist.q2.answers[2][0] = true; + checklist.q3.answers[1][0] = true; + checklist.q4.answers[2][0] = true; + checklist.q5.answers[1][0] = true; + checklist.q6.answers[1][0] = true; + checklist.q7.answers[2][0] = true; + checklist.q8.answers[2][0] = true; + checklist.q9a.answers[2][0] = true; + checklist.q9b.answers[2][0] = true; + checklist.q10.answers[1][0] = true; + checklist.q11a.answers[1][0] = true; + checklist.q11b.answers[1][0] = true; + checklist.q12.answers[1][0] = true; + checklist.q13.answers[1][0] = true; + checklist.q14.answers[1][0] = true; + checklist.q15.answers[1][0] = true; + checklist.q16.answers[1][0] = true; + + expect(scoreAMSTAR2Checklist(checklist)).toBe('High'); + }); + + it('should return Critically Low with more than one critical flaw', () => { + const checklist = createAMSTAR2Checklist({ + name: 'Test', + id: 'test-123', + }); + + // Set most answers to Yes + checklist.q1.answers[2][0] = true; + checklist.q3.answers[1][0] = true; + checklist.q5.answers[1][0] = true; + checklist.q6.answers[1][0] = true; + checklist.q8.answers[2][0] = true; + checklist.q10.answers[1][0] = true; + checklist.q12.answers[1][0] = true; + checklist.q14.answers[1][0] = true; + checklist.q16.answers[1][0] = true; + + // Set multiple critical questions to No + checklist.q2.answers[2][2] = true; // No + checklist.q4.answers[2][2] = true; // No (more than 1 critical flaw) + checklist.q7.answers[2][0] = true; + checklist.q9a.answers[2][0] = true; + checklist.q9b.answers[2][0] = true; + checklist.q11a.answers[1][0] = true; + checklist.q11b.answers[1][0] = true; + checklist.q13.answers[1][0] = true; + checklist.q15.answers[1][0] = true; + + expect(scoreAMSTAR2Checklist(checklist)).toBe('Critically Low'); + }); + }); + + describe('consolidateAnswers', () => { + it('should merge q9a and q9b into q9', () => { + const checklist = createAMSTAR2Checklist({ + name: 'Test', + id: 'test-123', + }); + + checklist.q9a.answers[2][0] = true; // Yes + checklist.q9b.answers[2][0] = true; // Yes + + const consolidated = consolidateAnswers(checklist); + + expect(consolidated.q9).toBeDefined(); + expect(consolidated.q9a).toBeUndefined(); + expect(consolidated.q9b).toBeUndefined(); + }); + + it('should merge q11a and q11b into q11', () => { + const checklist = createAMSTAR2Checklist({ + name: 'Test', + id: 'test-123', + }); + + checklist.q11a.answers[1][0] = true; // Yes + checklist.q11b.answers[1][0] = true; // Yes + + const consolidated = consolidateAnswers(checklist); + + expect(consolidated.q11).toBeDefined(); + expect(consolidated.q11a).toBeUndefined(); + expect(consolidated.q11b).toBeUndefined(); + }); + }); + + describe('getAnswers', () => { + it('should return null for invalid input', () => { + expect(getAnswers(null as any)).toBe(null); + }); + + it('should return answers object with selected values', () => { + const checklist = createAMSTAR2Checklist({ + name: 'Test', + id: 'test-123', + }); + + checklist.q1.answers[2][0] = true; // Yes + + const answers = getAnswers(checklist); + expect(answers).not.toBe(null); + expect(answers?.q1).toBe('Yes'); + }); + }); +}); diff --git a/packages/shared/src/checklists/__tests__/robins-i.test.ts b/packages/shared/src/checklists/__tests__/robins-i.test.ts new file mode 100644 index 000000000..c921132cb --- /dev/null +++ b/packages/shared/src/checklists/__tests__/robins-i.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect } from 'vitest'; +import { + createROBINSIChecklist, + scoreROBINSIChecklist, + isROBINSIComplete, + shouldStopAssessment, + getAnswers, +} from '../robins-i/index.js'; +import { scoreRobinsDomain, JUDGEMENTS } from '../robins-i/scoring.js'; + +describe('ROBINS-I', () => { + describe('createROBINSIChecklist', () => { + it('should create a checklist with all required fields', () => { + const checklist = createROBINSIChecklist({ + name: 'Test Checklist', + id: 'test-123', + reviewerName: 'Bob', + }); + + expect(checklist.name).toBe('Test Checklist'); + expect(checklist.id).toBe('test-123'); + expect(checklist.reviewerName).toBe('Bob'); + expect(checklist.checklistType).toBe('ROBINS_I'); + expect(checklist.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}$/); + + // Check all sections exist + expect(checklist.planning).toBeDefined(); + expect(checklist.sectionA).toBeDefined(); + expect(checklist.sectionB).toBeDefined(); + expect(checklist.sectionC).toBeDefined(); + expect(checklist.sectionD).toBeDefined(); + + // Check all domains exist + expect(checklist.domain1a).toBeDefined(); + expect(checklist.domain1b).toBeDefined(); + expect(checklist.domain2).toBeDefined(); + expect(checklist.domain3).toBeDefined(); + expect(checklist.domain4).toBeDefined(); + expect(checklist.domain5).toBeDefined(); + expect(checklist.domain6).toBeDefined(); + }); + + it('should throw if id is missing', () => { + expect(() => + createROBINSIChecklist({ + name: 'Test', + id: '', + }), + ).toThrow('non-empty string id'); + }); + + it('should throw if name is missing', () => { + expect(() => + createROBINSIChecklist({ + name: '', + id: 'test-123', + }), + ).toThrow('non-empty string name'); + }); + + it('should initialize domains with empty answers', () => { + const checklist = createROBINSIChecklist({ + name: 'Test', + id: 'test-123', + }); + + // Domain 1A should have questions d1a_1 through d1a_4 + expect(checklist.domain1a.answers.d1a_1).toEqual({ answer: null, comment: '' }); + expect(checklist.domain1a.answers.d1a_4).toEqual({ answer: null, comment: '' }); + + // Domain 6 should have questions d6_1 through d6_4 + expect(checklist.domain6.answers.d6_1).toEqual({ answer: null, comment: '' }); + expect(checklist.domain6.answers.d6_4).toEqual({ answer: null, comment: '' }); + }); + }); + + describe('shouldStopAssessment', () => { + it('should return false when B2 and B3 are not Yes/PY', () => { + const sectionB = { + b1: { answer: 'Y' as const, comment: '' }, + b2: { answer: 'N' as const, comment: '' }, + b3: { answer: 'N' as const, comment: '' }, + stopAssessment: false, + }; + + expect(shouldStopAssessment(sectionB)).toBe(false); + }); + + it('should return true when B2 is Yes', () => { + const sectionB = { + b1: { answer: 'N' as const, comment: '' }, + b2: { answer: 'Y' as const, comment: '' }, + b3: { answer: 'N' as const, comment: '' }, + stopAssessment: false, + }; + + expect(shouldStopAssessment(sectionB)).toBe(true); + }); + + it('should return true when B3 is Probably Yes', () => { + const sectionB = { + b1: { answer: 'Y' as const, comment: '' }, + b2: { answer: 'N' as const, comment: '' }, + b3: { answer: 'PY' as const, comment: '' }, + stopAssessment: false, + }; + + expect(shouldStopAssessment(sectionB)).toBe(true); + }); + }); + + describe('scoreRobinsDomain', () => { + describe('Domain 1A', () => { + it('should return null for incomplete answers', () => { + const answers = { + d1a_1: { answer: null, comment: '' }, + }; + + const result = scoreRobinsDomain('domain1a', answers); + expect(result.judgement).toBe(null); + expect(result.isComplete).toBe(false); + }); + + it('should return Low (except confounding) for Y/Y/N/N path', () => { + const answers = { + d1a_1: { answer: 'Y', comment: '' }, + d1a_2: { answer: 'Y', comment: '' }, + d1a_3: { answer: 'N', comment: '' }, + d1a_4: { answer: 'N', comment: '' }, + }; + + const result = scoreRobinsDomain('domain1a', answers); + expect(result.judgement).toBe(JUDGEMENTS.LOW_EXCEPT_CONFOUNDING); + expect(result.isComplete).toBe(true); + }); + + it('should return Serious for SN on Q1', () => { + const answers = { + d1a_1: { answer: 'SN', comment: '' }, + d1a_2: { answer: null, comment: '' }, + d1a_3: { answer: null, comment: '' }, + d1a_4: { answer: 'N', comment: '' }, + }; + + const result = scoreRobinsDomain('domain1a', answers); + expect(result.judgement).toBe(JUDGEMENTS.SERIOUS); + expect(result.isComplete).toBe(true); + }); + }); + + describe('Domain 5', () => { + it('should return Serious when Q1 is Y/PY', () => { + const answers = { + d5_1: { answer: 'Y', comment: '' }, + d5_2: { answer: null, comment: '' }, + d5_3: { answer: null, comment: '' }, + }; + + const result = scoreRobinsDomain('domain5', answers); + expect(result.judgement).toBe(JUDGEMENTS.SERIOUS); + expect(result.isComplete).toBe(true); + }); + + it('should return Low when Q1=N, Q2=N', () => { + const answers = { + d5_1: { answer: 'N', comment: '' }, + d5_2: { answer: 'N', comment: '' }, + d5_3: { answer: null, comment: '' }, + }; + + const result = scoreRobinsDomain('domain5', answers); + expect(result.judgement).toBe(JUDGEMENTS.LOW); + expect(result.isComplete).toBe(true); + }); + }); + + describe('Domain 6', () => { + it('should return Low when Q1 is Yes', () => { + const answers = { + d6_1: { answer: 'Y', comment: '' }, + d6_2: { answer: null, comment: '' }, + d6_3: { answer: null, comment: '' }, + d6_4: { answer: null, comment: '' }, + }; + + const result = scoreRobinsDomain('domain6', answers); + expect(result.judgement).toBe(JUDGEMENTS.LOW); + expect(result.isComplete).toBe(true); + }); + + it('should return Critical when 2+ selection questions are Yes', () => { + const answers = { + d6_1: { answer: 'N', comment: '' }, + d6_2: { answer: 'Y', comment: '' }, + d6_3: { answer: 'Y', comment: '' }, + d6_4: { answer: 'N', comment: '' }, + }; + + const result = scoreRobinsDomain('domain6', answers); + expect(result.judgement).toBe(JUDGEMENTS.CRITICAL); + expect(result.isComplete).toBe(true); + }); + }); + }); + + describe('scoreROBINSIChecklist', () => { + it('should return Error for invalid input', () => { + expect(scoreROBINSIChecklist(null as any)).toBe('Error'); + }); + + it('should return Critical when assessment stopped early', () => { + const checklist = createROBINSIChecklist({ + name: 'Test', + id: 'test-123', + }); + + checklist.sectionB.b2.answer = 'Y'; + + expect(scoreROBINSIChecklist(checklist)).toBe('Critical'); + }); + + it('should return Incomplete when domains are not complete', () => { + const checklist = createROBINSIChecklist({ + name: 'Test', + id: 'test-123', + }); + + expect(scoreROBINSIChecklist(checklist)).toBe('Incomplete'); + }); + }); + + describe('isROBINSIComplete', () => { + it('should return false for empty checklist', () => { + const checklist = createROBINSIChecklist({ + name: 'Test', + id: 'test-123', + }); + + expect(isROBINSIComplete(checklist)).toBe(false); + }); + + it('should return true when assessment stopped early (Critical)', () => { + const checklist = createROBINSIChecklist({ + name: 'Test', + id: 'test-123', + }); + + checklist.sectionB.b2.answer = 'Y'; + + expect(isROBINSIComplete(checklist)).toBe(true); + }); + }); + + describe('getAnswers', () => { + it('should return null for invalid input', () => { + expect(getAnswers(null as any)).toBe(null); + }); + + it('should return structured answers object', () => { + const checklist = createROBINSIChecklist({ + name: 'Test', + id: 'test-123', + reviewerName: 'Bob', + }); + + const answers = getAnswers(checklist); + expect(answers).not.toBe(null); + expect(answers?.metadata.name).toBe('Test'); + expect(answers?.metadata.reviewerName).toBe('Bob'); + expect(answers?.sectionB).toBeDefined(); + expect(answers?.domains).toBeDefined(); + }); + }); +}); diff --git a/packages/shared/src/checklists/__tests__/status.test.ts b/packages/shared/src/checklists/__tests__/status.test.ts new file mode 100644 index 000000000..8949ae641 --- /dev/null +++ b/packages/shared/src/checklists/__tests__/status.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; +import { + CHECKLIST_STATUS, + isEditable, + getStatusLabel, + canTransitionTo, + getStatusStyle, +} from '../status.js'; + +describe('Checklist Status', () => { + describe('CHECKLIST_STATUS', () => { + it('should have all expected statuses', () => { + expect(CHECKLIST_STATUS.PENDING).toBe('pending'); + expect(CHECKLIST_STATUS.IN_PROGRESS).toBe('in-progress'); + expect(CHECKLIST_STATUS.REVIEWER_COMPLETED).toBe('reviewer-completed'); + expect(CHECKLIST_STATUS.RECONCILING).toBe('reconciling'); + expect(CHECKLIST_STATUS.FINALIZED).toBe('finalized'); + }); + }); + + describe('isEditable', () => { + it('should return true for pending status', () => { + expect(isEditable('pending')).toBe(true); + }); + + it('should return true for in-progress status', () => { + expect(isEditable('in-progress')).toBe(true); + }); + + it('should return false for reviewer-completed status', () => { + expect(isEditable('reviewer-completed')).toBe(false); + }); + + it('should return false for reconciling status', () => { + expect(isEditable('reconciling')).toBe(false); + }); + + it('should return false for finalized status', () => { + expect(isEditable('finalized')).toBe(false); + }); + }); + + describe('getStatusLabel', () => { + it('should return correct labels', () => { + expect(getStatusLabel('pending')).toBe('Pending'); + expect(getStatusLabel('in-progress')).toBe('In Progress'); + expect(getStatusLabel('reviewer-completed')).toBe('Reviewer Completed'); + expect(getStatusLabel('reconciling')).toBe('Reconciling'); + expect(getStatusLabel('finalized')).toBe('Finalized'); + }); + + it('should return the status itself for unknown statuses', () => { + expect(getStatusLabel('invalid')).toBe('invalid'); + }); + + it('should return Pending for undefined status', () => { + expect(getStatusLabel(undefined)).toBe('Pending'); + }); + }); + + describe('canTransitionTo', () => { + it('should allow pending to transition to in-progress', () => { + expect(canTransitionTo('pending', 'in-progress')).toBe(true); + }); + + it('should allow in-progress to transition to reviewer-completed', () => { + expect(canTransitionTo('in-progress', 'reviewer-completed')).toBe(true); + }); + + it('should allow in-progress to transition to finalized', () => { + expect(canTransitionTo('in-progress', 'finalized')).toBe(true); + }); + + it('should allow reconciling to transition to finalized', () => { + expect(canTransitionTo('reconciling', 'finalized')).toBe(true); + }); + + it('should not allow finalized to transition anywhere', () => { + expect(canTransitionTo('finalized', 'pending')).toBe(false); + expect(canTransitionTo('finalized', 'in-progress')).toBe(false); + }); + + it('should not allow reviewer-completed to transition', () => { + expect(canTransitionTo('reviewer-completed', 'in-progress')).toBe(false); + }); + + it('should allow staying in the same state', () => { + expect(canTransitionTo('pending', 'pending')).toBe(true); + expect(canTransitionTo('in-progress', 'in-progress')).toBe(true); + }); + + it('should not allow skipping states', () => { + expect(canTransitionTo('pending', 'finalized')).toBe(false); + }); + }); + + describe('getStatusStyle', () => { + it('should return Tailwind class strings for all statuses', () => { + expect(typeof getStatusStyle('pending')).toBe('string'); + expect(typeof getStatusStyle('in-progress')).toBe('string'); + expect(typeof getStatusStyle('reviewer-completed')).toBe('string'); + expect(typeof getStatusStyle('reconciling')).toBe('string'); + expect(typeof getStatusStyle('finalized')).toBe('string'); + }); + + it('should include background classes', () => { + expect(getStatusStyle('pending')).toContain('bg-'); + expect(getStatusStyle('in-progress')).toContain('bg-'); + }); + }); +}); diff --git a/packages/shared/src/checklists/amstar2/answers.ts b/packages/shared/src/checklists/amstar2/answers.ts new file mode 100644 index 000000000..cd7f7b768 --- /dev/null +++ b/packages/shared/src/checklists/amstar2/answers.ts @@ -0,0 +1,145 @@ +/** + * AMSTAR2 Answer Utilities + * + * Functions for getting and manipulating checklist answers. + */ + +import type { AMSTAR2Checklist, AMSTAR2Question } from '../types.js'; + +type AnswerLabel = 'Yes' | 'Partial Yes' | 'No' | 'No MA' | null; + +/** + * Get the selected answer from the last column of a question's answers. + * + * @param answers - 2D array of boolean answers + * @param questionKey - The question key (e.g., 'q1', 'q9a') + * @returns The selected answer label or null if none selected + */ +export function getSelectedAnswer(answers: boolean[][], questionKey: string): AnswerLabel { + // Questions with custom pattern (Yes/No/No MA instead of Yes/Partial Yes/No/No MA) + const customPatternQuestions = ['q11a', 'q11b', 'q12', 'q15']; + const customLabels: AnswerLabel[] = ['Yes', 'No', 'No MA']; + const defaultLabels: AnswerLabel[] = ['Yes', 'Partial Yes', 'No', 'No MA']; + + if (!Array.isArray(answers) || answers.length === 0) return null; + const lastCol = answers[answers.length - 1]; + if (!Array.isArray(lastCol)) return null; + + const idx = lastCol.findIndex(v => v === true); + if (idx === -1) return null; + + if (customPatternQuestions.includes(questionKey)) return customLabels[idx] || null; + if (lastCol.length === 2) return idx === 0 ? 'Yes' : 'No'; + if (lastCol.length >= 3) return defaultLabels[idx] || null; + + return null; +} + +/** + * Get all answers from a checklist as a simple key-value object. + * + * @param checklist - The AMSTAR2 checklist + * @returns Object mapping question keys to their selected answers + */ +export function getAnswers(checklist: AMSTAR2Checklist): Record | null { + if (!checklist || typeof checklist !== 'object') return null; + + const result: Record = {}; + const consolidated = consolidateAnswers(checklist); + + for (const [key, value] of Object.entries(consolidated)) { + if (!/^q\d+[a-z]*$/i.test(key)) continue; + const question = value as AMSTAR2Question; + if (!question || !Array.isArray(question.answers)) continue; + + const selected = getSelectedAnswer(question.answers, key); + result[key] = selected; + } + + return result; +} + +/** + * Consolidate multi-part questions (q9a/b, q11a/b) into single questions. + * + * For scoring purposes, q9a and q9b are combined into q9, taking the lower score. + * Similarly for q11a and q11b into q11. + * + * @param checklist - The checklist with separate a/b questions + * @returns Checklist with consolidated questions + */ +export function consolidateAnswers(checklist: AMSTAR2Checklist): Record { + const result: Record = { ...checklist }; + + // Consolidate q9a and q9b into q9 by taking the lower score + if (result.q9a && result.q9b) { + const q9a = getSelectedAnswer((result.q9a as AMSTAR2Question).answers, 'q9a'); + const q9b = getSelectedAnswer((result.q9b as AMSTAR2Question).answers, 'q9b'); + + if (q9a === null || q9b === null) { + result.q9 = result.q9a; + } else if (q9a === 'No' || q9b === 'No') { + result.q9 = q9a === 'No' ? result.q9a : result.q9b; + } else if (q9a === 'No MA' && q9b === 'No MA') { + result.q9 = result.q9a; + } else { + result.q9 = result.q9a; + } + delete result.q9a; + delete result.q9b; + } + + // Consolidate q11a and q11b into q11 by taking the lower score + if (result.q11a && result.q11b) { + const q11a = getSelectedAnswer((result.q11a as AMSTAR2Question).answers, 'q11a'); + const q11b = getSelectedAnswer((result.q11b as AMSTAR2Question).answers, 'q11b'); + + if (q11a === null || q11b === null) { + result.q11 = result.q11a; + } else if (q11a === 'No' || q11b === 'No') { + result.q11 = q11a === 'No' ? result.q11a : result.q11b; + } else if (q11a === 'No MA' && q11b === 'No MA') { + result.q11 = result.q11a; + } else { + result.q11 = result.q11a; + } + delete result.q11a; + delete result.q11b; + } + + return result; +} + +/** + * Get the final answer (last column selection) from a question's answers. + * + * @param answers - 2D array of boolean answers + * @param questionKey - The question key + * @returns The selected final answer or null + */ +export function getFinalAnswer(answers: boolean[][], questionKey: string): AnswerLabel { + return getSelectedAnswer(answers, questionKey); +} + +/** + * Check if two answer arrays are identical. + * + * @param answers1 - First 2D array of answers + * @param answers2 - Second 2D array of answers + * @returns True if all answers match + */ +export function answersMatch(answers1: boolean[][], answers2: boolean[][]): boolean { + if (!Array.isArray(answers1) || !Array.isArray(answers2)) return false; + if (answers1.length !== answers2.length) return false; + + for (let i = 0; i < answers1.length; i++) { + if (!Array.isArray(answers1[i]) || !Array.isArray(answers2[i])) return false; + if (answers1[i].length !== answers2[i].length) return false; + + for (let j = 0; j < answers1[i].length; j++) { + if (answers1[i][j] !== answers2[i][j]) return false; + } + } + + return true; +} diff --git a/packages/shared/src/checklists/amstar2/compare.ts b/packages/shared/src/checklists/amstar2/compare.ts new file mode 100644 index 000000000..777738b41 --- /dev/null +++ b/packages/shared/src/checklists/amstar2/compare.ts @@ -0,0 +1,352 @@ +/** + * AMSTAR2 Checklist Comparison + * + * Utilities for comparing two reviewer checklists and creating reconciled versions. + */ + +import type { AMSTAR2Checklist, AMSTAR2Question } from '../types.js'; +import { AMSTAR_CHECKLIST, AMSTAR2_QUESTION_KEYS } from './schema.js'; +import { getFinalAnswer, answersMatch } from './answers.js'; + +interface QuestionComparison { + isAgreement: boolean; + finalMatch: boolean; + criticalMatch: boolean; + detailedMatch: boolean; + reviewer1: { + answers: boolean[][]; + finalAnswer: string | null; + critical: boolean; + }; + reviewer2: { + answers: boolean[][]; + finalAnswer: string | null; + critical: boolean; + }; +} + +interface MultiPartComparison { + isAgreement: boolean; + isMultiPart: true; + parts: Array<{ + key: string; + isAgreement: boolean; + finalMatch: boolean; + criticalMatch: boolean; + detailedMatch: boolean; + reviewer1Answer: AMSTAR2Question; + reviewer2Answer: AMSTAR2Question; + }>; + reviewer1Answer: AMSTAR2Question[]; + reviewer2Answer: AMSTAR2Question[]; +} + +interface ComparisonResult { + agreements: Array<{ key: string } & (QuestionComparison | MultiPartComparison)>; + disagreements: Array<{ key: string } & (QuestionComparison | MultiPartComparison)>; + stats: { + total: number; + agreed: number; + disagreed: number; + agreementRate: number; + }; +} + +/** + * Get all question keys for display in reconciliation. + * + * Returns keys as they appear in AMSTAR_CHECKLIST (q1-q16), but q9 and q11 + * are displayed as combined questions while their data is stored as q9a/q9b and q11a/q11b. + */ +export function getQuestionKeys(): string[] { + return AMSTAR2_QUESTION_KEYS; +} + +/** + * Get the actual data keys for a question. + * + * For q9 and q11, returns the a/b parts. For others, returns the key as-is. + */ +export function getDataKeysForQuestion(questionKey: string): string[] { + if (questionKey === 'q9') { + return ['q9a', 'q9b']; + } + if (questionKey === 'q11') { + return ['q11a', 'q11b']; + } + return [questionKey]; +} + +/** + * Check if a question has multiple parts (a/b) + */ +export function isMultiPartQuestion(questionKey: string): boolean { + return questionKey === 'q9' || questionKey === 'q11'; +} + +/** + * Compare the answers of two checklists and identify differences. + */ +export function compareChecklists( + checklist1: AMSTAR2Checklist, + checklist2: AMSTAR2Checklist, +): ComparisonResult { + if (!checklist1 || !checklist2) { + return { + agreements: [], + disagreements: [], + stats: { total: 0, agreed: 0, disagreed: 0, agreementRate: 0 }, + }; + } + + const questionKeys = getQuestionKeys(); + const agreements: ComparisonResult['agreements'] = []; + const disagreements: ComparisonResult['disagreements'] = []; + + for (const key of questionKeys) { + if (isMultiPartQuestion(key)) { + const dataKeys = getDataKeysForQuestion(key); + const q1Parts = dataKeys.map( + dk => checklist1[dk as keyof AMSTAR2Checklist] as AMSTAR2Question, + ); + const q2Parts = dataKeys.map( + dk => checklist2[dk as keyof AMSTAR2Checklist] as AMSTAR2Question, + ); + + if (q1Parts.some(p => !p) || q2Parts.some(p => !p)) continue; + + const comparison = compareMultiPartQuestion(key, q1Parts, q2Parts, dataKeys); + + if (comparison.isAgreement) { + agreements.push({ key, ...comparison }); + } else { + disagreements.push({ key, ...comparison }); + } + } else { + const q1 = checklist1[key as keyof AMSTAR2Checklist] as AMSTAR2Question; + const q2 = checklist2[key as keyof AMSTAR2Checklist] as AMSTAR2Question; + + if (!q1 || !q2) continue; + + const comparison = compareQuestion(key, q1, q2); + + if (comparison.isAgreement) { + agreements.push({ key, ...comparison }); + } else { + disagreements.push({ key, ...comparison }); + } + } + } + + const total = agreements.length + disagreements.length; + return { + agreements, + disagreements, + stats: { + total, + agreed: agreements.length, + disagreed: disagreements.length, + agreementRate: total > 0 ? agreements.length / total : 0, + }, + }; +} + +/** + * Compare a multi-part question (q9 or q11) between two checklists. + */ +export function compareMultiPartQuestion( + _questionKey: string, + q1Parts: AMSTAR2Question[], + q2Parts: AMSTAR2Question[], + dataKeys: string[], +): MultiPartComparison { + let allPartsAgree = true; + const partComparisons: QuestionComparison[] = []; + + for (let i = 0; i < q1Parts.length; i++) { + const partComparison = compareQuestion(dataKeys[i], q1Parts[i], q2Parts[i]); + partComparisons.push(partComparison); + if (!partComparison.isAgreement) { + allPartsAgree = false; + } + } + + return { + isAgreement: allPartsAgree, + isMultiPart: true, + parts: dataKeys.map((dk, i) => ({ + key: dk, + ...partComparisons[i], + reviewer1Answer: q1Parts[i], + reviewer2Answer: q2Parts[i], + })), + reviewer1Answer: q1Parts, + reviewer2Answer: q2Parts, + }; +} + +/** + * Compare a single question's answers between two checklists. + */ +export function compareQuestion( + questionKey: string, + q1: AMSTAR2Question, + q2: AMSTAR2Question, +): QuestionComparison { + const answers1 = q1.answers; + const answers2 = q2.answers; + + const finalAnswer1 = getFinalAnswer(answers1, questionKey); + const finalAnswer2 = getFinalAnswer(answers2, questionKey); + + const detailedMatch = answersMatch(answers1, answers2); + const finalMatch = finalAnswer1 === finalAnswer2; + const criticalMatch = q1.critical === q2.critical; + + return { + isAgreement: finalMatch && criticalMatch, + finalMatch, + criticalMatch, + detailedMatch, + reviewer1: { + answers: answers1, + finalAnswer: finalAnswer1, + critical: q1.critical, + }, + reviewer2: { + answers: answers2, + finalAnswer: finalAnswer2, + critical: q2.critical, + }, + }; +} + +type SelectionValue = 'reviewer1' | 'reviewer2' | AMSTAR2Question | Record; + +interface ReconciledMetadata { + name?: string; + reviewerName?: string; + createdAt?: string; + id?: string; +} + +/** + * Create a merged/reconciled checklist from two source checklists. + */ +export function createReconciledChecklist( + checklist1: AMSTAR2Checklist, + checklist2: AMSTAR2Checklist, + selections: Record, + metadata: ReconciledMetadata = {}, +): AMSTAR2Checklist & { sourceChecklists: string[] } { + const questionKeys = getQuestionKeys(); + + const reconciled: Record = { + name: metadata.name || 'Reconciled Checklist', + reviewerName: metadata.reviewerName || 'Consensus', + createdAt: metadata.createdAt || new Date().toISOString().split('T')[0], + id: metadata.id || `reconciled-${Date.now()}`, + sourceChecklists: [checklist1.id, checklist2.id], + }; + + for (const key of questionKeys) { + const selection = selections[key]; + const dataKeys = getDataKeysForQuestion(key); + + if (isMultiPartQuestion(key)) { + for (const dataKey of dataKeys) { + if (!selection || selection === 'reviewer1') { + reconciled[dataKey] = JSON.parse( + JSON.stringify(checklist1[dataKey as keyof AMSTAR2Checklist]), + ); + } else if (selection === 'reviewer2') { + reconciled[dataKey] = JSON.parse( + JSON.stringify(checklist2[dataKey as keyof AMSTAR2Checklist]), + ); + } else if ( + typeof selection === 'object' && + (selection as Record)[dataKey] + ) { + reconciled[dataKey] = JSON.parse( + JSON.stringify((selection as Record)[dataKey]), + ); + } + } + } else { + if (!selection || selection === 'reviewer1') { + reconciled[key] = JSON.parse(JSON.stringify(checklist1[key as keyof AMSTAR2Checklist])); + } else if (selection === 'reviewer2') { + reconciled[key] = JSON.parse(JSON.stringify(checklist2[key as keyof AMSTAR2Checklist])); + } else if (typeof selection === 'object') { + reconciled[key] = JSON.parse(JSON.stringify(selection)); + } + } + } + + return reconciled as unknown as AMSTAR2Checklist & { sourceChecklists: string[] }; +} + +/** + * Get a summary of what needs reconciliation. + */ +export function getReconciliationSummary(comparison: ComparisonResult): { + totalQuestions: number; + agreementCount: number; + disagreementCount: number; + agreementPercentage: number; + criticalDisagreements: number; + nonCriticalDisagreements: number; + needsReconciliation: boolean; + disagreementsByQuestion: string[]; +} { + const { disagreements, stats } = comparison; + + const criticalDisagreements = disagreements.filter(d => { + if ('isMultiPart' in d && d.isMultiPart && d.parts) { + return d.parts.some(part => part.reviewer1Answer?.critical || part.reviewer2Answer?.critical); + } + if ('reviewer1' in d) { + return d.reviewer1?.critical || d.reviewer2?.critical; + } + return false; + }); + + const nonCriticalDisagreements = disagreements.filter(d => { + if ('isMultiPart' in d && d.isMultiPart && d.parts) { + return !d.parts.some( + part => part.reviewer1Answer?.critical || part.reviewer2Answer?.critical, + ); + } + if ('reviewer1' in d) { + return !d.reviewer1?.critical && !d.reviewer2?.critical; + } + return true; + }); + + return { + totalQuestions: stats.total, + agreementCount: stats.agreed, + disagreementCount: stats.disagreed, + agreementPercentage: Math.round(stats.agreementRate * 100), + criticalDisagreements: criticalDisagreements.length, + nonCriticalDisagreements: nonCriticalDisagreements.length, + needsReconciliation: disagreements.length > 0, + disagreementsByQuestion: disagreements.map(d => d.key), + }; +} + +/** + * Get readable question text from question key. + */ +export function getQuestionText(questionKey: string): string { + return AMSTAR_CHECKLIST[questionKey]?.text || questionKey; +} + +/** + * Get the question definition from checklist map. + */ +export function getQuestionDef( + questionKey: string, +): (typeof AMSTAR_CHECKLIST)[keyof typeof AMSTAR_CHECKLIST] | undefined { + return AMSTAR_CHECKLIST[questionKey]; +} diff --git a/packages/shared/src/checklists/amstar2/create.ts b/packages/shared/src/checklists/amstar2/create.ts new file mode 100644 index 000000000..745bf09dd --- /dev/null +++ b/packages/shared/src/checklists/amstar2/create.ts @@ -0,0 +1,175 @@ +/** + * AMSTAR2 Checklist Creation + * + * Creates new checklist objects with proper structure and defaults. + */ + +import type { AMSTAR2Checklist, AMSTAR2Question } from '../types.js'; + +interface CreateChecklistOptions { + name: string; + id: string; + createdAt?: number | Date; + reviewerName?: string; +} + +/** + * Creates a new AMSTAR2 checklist object with default empty answers for all questions. + * + * @param options - Checklist properties. + * @param options.name - The checklist name (required). + * @param options.id - Unique checklist ID (required). + * @param options.createdAt - Timestamp of checklist creation. + * @param options.reviewerName - Name of the reviewer. + * + * @returns A checklist object with all AMSTAR2 questions initialized to default answers. + * + * @throws Error if `id` or `name` is missing or not a non-empty string. + * + * @example + * createChecklist({ name: 'My Checklist', id: 'chk-123', reviewerName: 'Alice' }); + */ +export function createAMSTAR2Checklist({ + name, + id, + createdAt = Date.now(), + reviewerName = '', +}: CreateChecklistOptions): AMSTAR2Checklist { + if (!id || typeof id !== 'string' || !id.trim()) { + throw new Error('AMSTAR2Checklist requires a non-empty string id.'); + } + if (!name || typeof name !== 'string' || !name.trim()) { + throw new Error('AMSTAR2Checklist requires a non-empty string name.'); + } + + let d = new Date(createdAt); + if (isNaN(d.getTime())) d = new Date(); + + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const formattedDate = `${d.getFullYear()}-${mm}-${dd}`; + + return { + name: name, + reviewerName: reviewerName || '', + createdAt: formattedDate, + id: id, + q1: { answers: [[false, false, false, false], [false], [false, false]], critical: false }, + q2: { + answers: [ + [false, false, false, false], + [false, false, false], + [false, false, false], + ], + critical: true, + }, + q3: { + answers: [ + [false, false, false], + [false, false], + ], + critical: false, + }, + q4: { + answers: [ + [false, false, false], + [false, false, false, false, false], + [false, false, false], + ], + critical: true, + }, + q5: { + answers: [ + [false, false], + [false, false], + ], + critical: false, + }, + q6: { + answers: [ + [false, false], + [false, false], + ], + critical: false, + }, + q7: { answers: [[false], [false], [false, false, false]], critical: true }, + q8: { + answers: [ + [false, false, false, false, false], + [false, false, false, false, false], + [false, false, false], + ], + critical: false, + }, + q9a: { + answers: [ + [false, false], + [false, false], + [false, false, false, false], + ], + critical: true, + }, + q9b: { + answers: [ + [false, false], + [false, false], + [false, false, false, false], + ], + critical: true, + }, + q10: { answers: [[false], [false, false]], critical: false }, + q11a: { + answers: [ + [false, false, false], + [false, false, false], + ], + critical: true, + }, + q11b: { + answers: [ + [false, false, false, false], + [false, false, false], + ], + critical: true, + }, + q12: { + answers: [ + [false, false], + [false, false, false], + ], + critical: false, + }, + q13: { + answers: [ + [false, false], + [false, false], + ], + critical: true, + }, + q14: { + answers: [ + [false, false], + [false, false], + ], + critical: false, + }, + q15: { answers: [[false], [false, false, false]], critical: true }, + q16: { + answers: [ + [false, false], + [false, false], + ], + critical: false, + }, + }; +} + +/** + * Create an empty question object with the correct answer structure + */ +export function createEmptyQuestion(critical: boolean, answerStructure: number[]): AMSTAR2Question { + return { + answers: answerStructure.map(len => Array(len).fill(false)), + critical, + }; +} diff --git a/packages/shared/src/checklists/amstar2/index.ts b/packages/shared/src/checklists/amstar2/index.ts new file mode 100644 index 000000000..057aff4ac --- /dev/null +++ b/packages/shared/src/checklists/amstar2/index.ts @@ -0,0 +1,21 @@ +/** + * AMSTAR2 Module + * + * AMSTAR2 (A MeaSurement Tool to Assess systematic Reviews, version 2) + * is a critical appraisal tool for systematic reviews. + */ + +// Schema (checklist map) +export * from './schema.js'; + +// Checklist creation +export * from './create.js'; + +// Scoring functions +export * from './score.js'; + +// Answer manipulation +export * from './answers.js'; + +// Comparison/reconciliation +export * from './compare.js'; diff --git a/packages/shared/src/checklists/amstar2/schema.ts b/packages/shared/src/checklists/amstar2/schema.ts new file mode 100644 index 000000000..aa9922f65 --- /dev/null +++ b/packages/shared/src/checklists/amstar2/schema.ts @@ -0,0 +1,449 @@ +/** + * AMSTAR2 Checklist Schema + * + * Map the checklist state to actual checklist data for plotting and import/export + */ + +// Available checklist types +export const AMSTAR2_CHECKLIST_TYPES = { + AMSTAR2: { + name: 'AMSTAR 2', + description: 'A MeaSurement Tool to Assess systematic Reviews (version 2)', + }, +}; + +export interface AMSTAR2Column { + label: string; + description?: string; + options: string[]; +} + +export interface AMSTAR2Question { + info: string; + text: string; + columns: AMSTAR2Column[]; + subtitle?: string; + subtitle2?: string; + columns2?: AMSTAR2Column[]; + options?: string[]; +} + +export const AMSTAR_CHECKLIST: Record = { + q1: { + info: 'To score Yes, appraisers should be confident that the 4 elements of PICO (population, intervention, control group and outcome) are described somewhere in the report.', + text: '1. Did the research questions and inclusion criteria for the review include the components of PICO?', + columns: [ + { + label: 'For Yes:', + options: ['Population', 'Intervention', 'Comparator group', 'Outcome'], + }, + { + label: 'Optional (recommended):', + options: ['Timeframe for follow-up'], + }, + { + label: '', + options: ['Yes', 'No'], + }, + ], + }, + q2: { + info: 'The research questions and the review study methods should have been planned ahead of conducting the review. At a minimum this should be stated in the report (scores Partial Yes). To score Yes authors should demonstrate that they worked with a written protocol with independent verification (by a registry, publication of the protocol, or another independent body, e.g. research ethics board or research office) before the review was undertaken.', + text: '2. Did the report of the review contain an explicit statement that the review methods were established prior to the conduct of the review and did the report justify any significant deviations from the protocol?', + columns: [ + { + label: 'For Partial Yes:', + description: + 'The authors state that they had a written protocol or guide that included ALL the following:', + options: [ + 'review question(s)', + 'a search strategy', + 'inclusion/exclusion criteria', + 'risk of bias assessment', + ], + }, + { + label: 'For Yes:', + description: + 'As for Partial Yes, plus the protocol should be registered and should also have specified:', + options: [ + 'a meta-analysis/synthesis plan, if appropriate, and', + 'a plan for investigating causes of heterogeneity', + 'justification for any derivations from the protocol', + ], + }, + { + label: '', + options: ['Yes', 'Partial Yes', 'No'], + }, + ], + }, + q3: { + info: 'Review authors should justify their choice of study designs. A Yes rating requires evidence that the selection was intentional (e.g., why RCTs alone were sufficient or why nonrandomized studies were needed to capture outcomes or harms), rather than arbitrary.', + text: '3. Did the review authors explain their selection of the study designs for inclusion in the review?', + columns: [ + { + label: 'For Yes, the review should satisfy ONE of the following:', + options: [ + 'Explanation for including only RCTs ', + 'OR Explanation for including only NRSI', + 'OR Explanation for including both RCTs and NRSI', + ], + }, + { + label: '', + options: ['Yes', 'No'], + }, + ], + }, + q4: { + info: 'To score Yes, appraisers should be satisfied that all relevant aspects of the search have been addressed by review authors.', + text: '4. Did the review authors use a comprehensive literature search strategy? ', + columns: [ + { + label: 'For Partial Yes (all the following):', + options: [ + 'searched at least 2 databases (relevant to research question)', + 'provided key word and/or search strategy', + 'justified publication restrictions (e.g. language)', + ], + }, + { + label: 'For Yes, should also have (all the following):', + options: [ + 'searched the reference lists / bibliographies of included studies', + 'searched trial/study registries', + 'included/consulted content experts in the field', + 'where relevant, searched for grey literature', + 'conducted search within 24 months of completion of the review', + ], + }, + { + label: '', + options: ['Yes', 'Partial Yes', 'No'], + }, + ], + }, + q5: { + info: 'A Yes rating requires that study selection was conducted by at least two independent reviewers, with a clear consensus process for resolving disagreements. If one reviewer screened all studies, a second reviewer must have checked a representative sample and demonstrated strong agreement (e.g., kappa >= 0.80).', + text: '5. Did the review authors perform study selection in duplicate?', + columns: [ + { + label: 'For Yes, either ONE of the following:', + options: [ + 'at least two reviewers independently agreed on selection of eligible studies and achieved consensus on which studies to include', + 'OR two reviewers extracted data from a sample of eligible studies and achieved good agreement (at least 80 percent), with the remainder extracted by one reviewer.', + ], + }, + { + label: '', + options: ['Yes', 'No'], + }, + ], + }, + q6: { + info: 'A Yes rating requires that data extraction was performed by at least two independent reviewers, with a consensus process for resolving disagreements. If one reviewer extracted all data, a second reviewer must have checked a sample and demonstrated strong agreement (e.g., kappa >= 0.80).', + text: '6. Did the review authors perform data extraction in duplicate?', + columns: [ + { + label: 'For Yes, either ONE of the following:', + options: [ + 'at least two reviewers achieved consensus on which data to extract from included studies', + 'OR two reviewers extracted data from a sample of eligible studies and achieved good agreement (at least 80 percent), with the remainder extracted by one reviewer.', + ], + }, + { + label: '', + options: ['Yes', 'No'], + }, + ], + }, + q7: { + info: 'A Yes rating requires a list of excluded studies with a clear justification for each exclusion. Exclusions should be based on eligibility criteria (e.g., population, intervention, comparator, outcomes), not risk of bias, which is assessed separately.', + text: '7. Did the review authors provide a list of excluded studies and justify the exclusions?', + columns: [ + { + label: 'For Partial Yes:', + options: [ + 'provided a list of all potentially relevant studies that were read in full-text form but excluded from the review', + ], + }, + { + label: 'For Yes, must also have:', + options: ['Justified the exclusion from the review of each potentially relevant study'], + }, + { + label: '', + options: ['Yes', 'Partial Yes', 'No'], + }, + ], + }, + q8: { + info: 'A Yes rating requires sufficiently detailed descriptions of the included studies (e.g., population, intervention, comparator, outcomes, design, and setting) to allow readers to judge PICO relevance, applicability to practice or policy, and sources of heterogeneity.', + text: '8. Did the review authors describe the included studies in adequate detail?', + columns: [ + { + label: 'For Partial Yes (ALL the following):', + options: [ + 'described populations', + 'described interventions', + 'described comparators', + 'described outcomes', + 'described research designs', + ], + }, + { + label: 'For Yes, should also have ALL the following:', + options: [ + 'described population in detail', + 'described intervention in detail (including doses where relevant)', + 'described comparator in detail (including doses where relevant)', + "described study's setting", + 'timeframe for follow-up', + ], + }, + { + label: '', + options: ['Yes', 'Partial Yes', 'No'], + }, + ], + }, + q9: { + info: 'A Yes rating requires that review authors conducted a systematic, design-appropriate assessment of risk of bias for included studies, using a recognized or clearly justified tool that addresses key sources of bias relevant to the study designs.', + text: '9. Did the review authors use a satisfactory technique for assessing the risk of bias (RoB) in individual studies that were included in the review?', + subtitle: 'RCTs', + columns: [ + { + label: 'For Partial Yes, must have assessed RoB from', + options: [ + 'unconcealed allocation, and', + 'lack of blinding of patients and assessors when assessing outcomes (unnecessary for objective outcomes such as all-cause mortality)', + ], + }, + { + label: 'For Yes, must also have assessed RoB from:', + options: [ + 'allocation sequence that was not truly random, and', + 'selection of the reported result from among multiple measurements or analyses of a specified outcome', + ], + }, + { + label: '', + options: ['Yes', 'Partial Yes', 'No', ' Includes only NRSI'], + }, + ], + subtitle2: 'NRSI', + columns2: [ + { + label: 'For Partial Yes, must have assessed RoB:', + options: ['from confounding, and', 'from selection bias'], + }, + { + label: 'For Yes, must also have assessed RoB:', + options: [ + 'methods used to ascertain exposures and outcomes, and', + 'selection of the reported result from among multiple measurements or analyses of a specified outcome', + ], + }, + { + label: '', + options: ['Yes', 'Partial Yes', 'No', 'Includes only RCTs'], + }, + ], + }, + q10: { + info: 'A Yes rating requires that review authors reported the funding sources for the included studies, or clearly stated when funding information was not available.', + text: '10. Did the review authors report on the sources of funding for the studies included in the review?', + columns: [ + { + label: 'For Yes:', + options: [ + 'Must have reported on the sources of funding for individual studies included in the review. Note: Reporting that the reviewers looked for this information but it was not reported by study authors also qualifies', + ], + }, + { + label: '', + options: ['Yes', 'No'], + }, + ], + }, + q11: { + info: 'A Yes rating requires that meta-analysis was clearly justified and conducted using appropriate statistical methods, including suitable effect models, assessment of heterogeneity, and, when both RCTs and nonrandomized studies are included, separate pooling by study design or clear justification for combined analyses.', + text: '11. If meta-analysis was performed did the review authors use appropriate methods for statistical combination of results?', + subtitle: 'RCTs', + columns: [ + { + label: 'For Yes:', + options: [ + 'The authors justified combining the data in a meta-analysis', + 'AND they used an appropriate weighted technique to combine study results and adjusted for heterogeneity if present.', + 'AND investigated the causes of any heterogeneity', + ], + }, + { + label: '', + options: ['Yes', 'No', 'No meta-analysis conducted'], + }, + ], + subtitle2: 'NRSI', + columns2: [ + { + label: 'For Yes:', + options: [ + 'The authors justified combining the data in a meta-analysis', + 'AND they used an appropriate weighted technique to combine study results, adjusting for heterogeneity if present', + 'AND they statistically combined effect estimates from NRSI that were adjusted for confounding, rather than combining raw data, or justified combining raw data when adjusted effect estimates were not available', + 'AND they reported separate summary estimates for RCTs and NRSI separately when both were included in the review', + ], + }, + { + label: '', + options: ['Yes', 'No', 'No meta-analysis conducted'], + }, + ], + }, + q12: { + info: 'A Yes rating requires that review authors examined how risk of bias in included studies may affect the synthesis results, such as through sensitivity analyses, subgroup analyses, or narrative discussion when meta-analysis was not performed.', + text: '12. If meta-analysis was performed, did the review authors assess the potential impact of RoB in individual studies on the results of the meta-analysis or other evidence synthesis?', + columns: [ + { + label: 'For Yes:', + options: [ + 'included only low risk of bias RCTs', + 'OR, if the pooled estimate was based on RCTs and/or NRSI at variable RoB, the authors performed analyses to investigate possible impact of RoB on summary estimates of effect.', + ], + }, + { + label: '', + options: ['Yes', 'No', 'No meta-analysis conducted'], + }, + ], + }, + q13: { + info: "A Yes rating requires explicit discussion of how risk of bias may influence the review's results/conclusions or the authors included only low risk of bias RCTs.", + text: '13. Did the review authors account for RoB in individual studies when interpreting/ discussing the results of the review?', + columns: [ + { + label: 'For Yes:', + options: [ + 'included only low risk of bias RCTs', + 'OR, if RCTs with moderate or high RoB, or NRSI were included the review provided a discussion of the likely impact of RoB on the results', + ], + }, + { + label: '', + options: ['Yes', 'No'], + }, + ], + }, + q14: { + info: 'A Yes rating requires that review authors examined and discussed sources of heterogeneity, such as differences in populations, interventions, outcomes, study design, or risk of bias, and considered how heterogeneity affects the interpretation of results and conclusions.', + text: '14. Did the review authors provide a satisfactory explanation for, and discussion of, any heterogeneity observed in the results of the review?', + columns: [ + { + label: 'For Yes:', + options: [ + 'There was no significant heterogeneity in the results', + 'OR if heterogeneity was present the authors performed an investigation of sources of any heterogeneity in the results and discussed the impact of this on the results of the review', + ], + }, + { + label: '', + options: ['Yes', 'No'], + }, + ], + }, + q15: { + info: 'A Yes rating requires that review authors investigated potential publication (small-study) bias using appropriate methods (e.g., funnel plots, statistical tests, sensitivity analyses) and discussed how publication bias may affect the results, recognizing the limitations of these approaches.', + text: '15. If they performed quantitative synthesis did the review authors carry out an adequate investigation of publication bias (small study bias) and discuss its likely impact on the results of the review?', + columns: [ + { + label: 'For Yes:', + options: [ + 'performed graphical or statistical tests for publication bias and discussed the likelihood and magnitude of impact of publication bias', + ], + }, + { + label: '', + options: ['Yes', 'No', 'No meta-analysis conducted'], + }, + ], + }, + q16: { + info: 'A Yes rating requires that the review authors reported potential conflicts of interest related to the conduct of the review, including funding sources for the review itself and any relevant financial or professional ties, or clearly stated that no conflicts were identified.', + text: '16. Did the review authors report any potential sources of conflict of interest, including any funding they received for conducting the review?', + options: ['Yes', 'Partial Yes', 'No'], + columns: [ + { + label: 'For Yes:', + options: [ + 'The authors reported no competing interests OR', + 'The authors described their funding sources and how they managed potential conflicts of interest', + ], + }, + { + label: '', + options: ['Yes', 'No'], + }, + ], + }, +}; + +/** + * All AMSTAR2 question keys in order + */ +export const AMSTAR2_QUESTION_KEYS = Object.keys(AMSTAR_CHECKLIST); + +/** + * Question keys as stored in checklist data (with a/b variants) + */ +export const AMSTAR2_DATA_KEYS = [ + 'q1', + 'q2', + 'q3', + 'q4', + 'q5', + 'q6', + 'q7', + 'q8', + 'q9a', + 'q9b', + 'q10', + 'q11a', + 'q11b', + 'q12', + 'q13', + 'q14', + 'q15', + 'q16', +]; + +/** + * Critical questions per AMSTAR2 methodology + */ +export const AMSTAR2_CRITICAL_QUESTIONS = [ + 'q2', + 'q4', + 'q7', + 'q9a', + 'q9b', + 'q11a', + 'q11b', + 'q13', + 'q15', +]; + +/** + * Non-critical questions + */ +export const AMSTAR2_NON_CRITICAL_QUESTIONS = [ + 'q1', + 'q3', + 'q5', + 'q6', + 'q8', + 'q10', + 'q12', + 'q14', + 'q16', +]; diff --git a/packages/shared/src/checklists/amstar2/score.ts b/packages/shared/src/checklists/amstar2/score.ts new file mode 100644 index 000000000..8ced60f40 --- /dev/null +++ b/packages/shared/src/checklists/amstar2/score.ts @@ -0,0 +1,139 @@ +/** + * AMSTAR2 Scoring + * + * Functions for scoring checklists and determining confidence levels. + */ + +import type { AMSTAR2Checklist, AMSTAR2Question, AMSTAR2Score } from '../types.js'; +import { AMSTAR2_DATA_KEYS } from './schema.js'; +import { getSelectedAnswer, consolidateAnswers } from './answers.js'; + +/** + * Score an AMSTAR2 checklist using the last column of each question. + * + * Scoring rules: + * - Partial Yes is scored the same as Yes + * - No MA (No Meta-Analysis) is not counted as a flaw + * - Critical flaws: More than one critical question answered "No" = Critically Low + * - One critical flaw = Low confidence + * - Multiple non-critical flaws = Moderate confidence + * - Otherwise = High confidence + * + * @param checklist - The AMSTAR2 checklist to score + * @returns The confidence rating + */ +export function scoreAMSTAR2Checklist(checklist: AMSTAR2Checklist): AMSTAR2Score { + if (!checklist || typeof checklist !== 'object') return 'Error'; + + let criticalFlaws = 0; + let nonCriticalFlaws = 0; + + const consolidated = consolidateAnswers(checklist); + + for (const [question, obj] of Object.entries(consolidated)) { + if (!/^q\d+[a-z]*$/i.test(question)) continue; + const questionData = obj as AMSTAR2Question; + if (!questionData || !Array.isArray(questionData.answers)) continue; + + const selected = getSelectedAnswer(questionData.answers, question); + + // Only count as flaw if answer is missing or "No" + // "Yes", "Partial Yes", and "No MA" are not flaws + if (!selected || selected === 'No') { + if (questionData.critical) { + criticalFlaws++; + } else { + nonCriticalFlaws++; + } + } + } + + if (criticalFlaws > 1) return 'Critically Low'; + if (criticalFlaws === 1) return 'Low'; + if (nonCriticalFlaws > 1) return 'Moderate'; + return 'High'; +} + +/** + * Check if an AMSTAR2 checklist is complete (all questions have final answers). + * + * A question has a final answer if the last column has at least one option selected. + * + * @param checklist - The checklist object to validate + * @returns True if all questions have final answers, false otherwise + */ +export function isAMSTAR2Complete(checklist: AMSTAR2Checklist): boolean { + if (!checklist || typeof checklist !== 'object') return false; + + // Check each required question has a final answer + for (const questionKey of AMSTAR2_DATA_KEYS) { + const question = checklist[questionKey as keyof AMSTAR2Checklist] as + | AMSTAR2Question + | undefined; + if (!question || !Array.isArray(question.answers)) return false; + + // Check if the last column has at least one option selected + const lastCol = question.answers[question.answers.length - 1]; + if (!Array.isArray(lastCol)) return false; + const hasAnswer = lastCol.some(v => v === true); + if (!hasAnswer) return false; + } + + return true; +} + +/** + * Get a breakdown of critical and non-critical flaw counts. + * + * @param checklist - The AMSTAR2 checklist + * @returns Object with flaw counts and details + */ +export function getScoreBreakdown(checklist: AMSTAR2Checklist): { + criticalFlaws: number; + nonCriticalFlaws: number; + criticalQuestions: string[]; + nonCriticalQuestions: string[]; + score: AMSTAR2Score; +} { + if (!checklist || typeof checklist !== 'object') { + return { + criticalFlaws: 0, + nonCriticalFlaws: 0, + criticalQuestions: [], + nonCriticalQuestions: [], + score: 'Error', + }; + } + + const criticalQuestions: string[] = []; + const nonCriticalQuestions: string[] = []; + + const consolidated = consolidateAnswers(checklist); + + for (const [question, obj] of Object.entries(consolidated)) { + if (!/^q\d+[a-z]*$/i.test(question)) continue; + const questionData = obj as AMSTAR2Question; + if (!questionData || !Array.isArray(questionData.answers)) continue; + + const selected = getSelectedAnswer(questionData.answers, question); + + if (!selected || selected === 'No') { + if (questionData.critical) { + criticalQuestions.push(question); + } else { + nonCriticalQuestions.push(question); + } + } + } + + return { + criticalFlaws: criticalQuestions.length, + nonCriticalFlaws: nonCriticalQuestions.length, + criticalQuestions, + nonCriticalQuestions, + score: scoreAMSTAR2Checklist(checklist), + }; +} + +// Legacy export for backwards compatibility +export const scoreChecklist = scoreAMSTAR2Checklist; diff --git a/packages/shared/src/checklists/domain.ts b/packages/shared/src/checklists/domain.ts new file mode 100644 index 000000000..6c3036708 --- /dev/null +++ b/packages/shared/src/checklists/domain.ts @@ -0,0 +1,283 @@ +/** + * Checklist Domain Logic + * + * Centralized business logic for filtering and querying checklists. + * UI components should use these functions instead of implementing filtering logic inline. + */ + +import { CHECKLIST_STATUS } from './status.js'; +import type { Study, ChecklistMetadata } from './types.js'; + +/** + * Checks if a checklist is a reconciled checklist + * Reconciled checklists are identified by having no assignedTo (null) since they represent consensus + * @param checklist - The checklist object + * @returns True if the checklist is a reconciled checklist + */ +export function isReconciledChecklist(checklist: ChecklistMetadata | null | undefined): boolean { + if (!checklist) return false; + return checklist.assignedTo === null; +} + +/** + * Gets checklists for the todo tab (assigned to user, not finalized or reviewer-completed) + * @param study - The study object + * @param userId - The current user ID + * @returns Array of checklists for todo tab + */ +export function getTodoChecklists( + study: Study | null | undefined, + userId: string | null | undefined, +): ChecklistMetadata[] { + if (!study || !userId) return []; + const checklists = study.checklists || []; + return checklists.filter( + c => + c.assignedTo === userId && + c.status !== CHECKLIST_STATUS.FINALIZED && + c.status !== CHECKLIST_STATUS.REVIEWER_COMPLETED, + ); +} + +/** + * Gets checklists for the completed tab (finalized checklists) + * @param study - The study object + * @returns Array of checklists for completed tab + */ +export function getCompletedChecklists(study: Study | null | undefined): ChecklistMetadata[] { + if (!study) return []; + const checklists = study.checklists || []; + return checklists.filter(c => c.status === CHECKLIST_STATUS.FINALIZED); +} + +/** + * Gets the finalized checklist for a study (the authoritative version for tables/charts) + * @param study - The study object + * @returns The finalized checklist or null if not found + */ +export function getFinalizedChecklist(study: Study | null | undefined): ChecklistMetadata | null { + if (!study || !study.checklists) return null; + const checklists = study.checklists || []; + // Prefer reconciled checklist if it's finalized + const reconciled = checklists.find( + c => isReconciledChecklist(c) && c.status === CHECKLIST_STATUS.FINALIZED, + ); + if (reconciled) return reconciled; + // Otherwise, find any finalized checklist + return checklists.find(c => c.status === CHECKLIST_STATUS.FINALIZED) || null; +} + +/** + * Gets checklists in the reconciliation workflow (individual reviewer checklists that are completed) + * @param study - The study object + * @returns Array of checklists in reconciliation workflow + */ +export function getReconciliationChecklists(study: Study | null | undefined): ChecklistMetadata[] { + if (!study) return []; + const checklists = study.checklists || []; + return checklists.filter( + c => !isReconciledChecklist(c) && c.status === CHECKLIST_STATUS.REVIEWER_COMPLETED, + ); +} + +/** + * Determines if a study should appear in a specific tab + * @param study - The study object + * @param tab - The tab name ('todo', 'reconcile', 'completed') + * @param userId - The current user ID (required for 'todo' tab) + * @returns True if study should appear in the tab + */ +export function shouldShowInTab( + study: Study | null | undefined, + tab: 'todo' | 'reconcile' | 'completed', + userId?: string | null, +): boolean { + if (!study) return false; + + switch (tab) { + case 'todo': { + if (!userId) return false; + // Must be assigned to user + if (study.reviewer1 !== userId && study.reviewer2 !== userId) return false; + const checklists = study.checklists || []; + const userChecklists = checklists.filter(c => c.assignedTo === userId); + // Show if user has no checklist yet OR has a non-finalized/reviewer-completed checklist + return ( + userChecklists.length === 0 || + userChecklists.some( + c => + c.status !== CHECKLIST_STATUS.FINALIZED && + c.status !== CHECKLIST_STATUS.REVIEWER_COMPLETED, + ) + ); + } + + case 'reconcile': { + // Must have both reviewers assigned (single reviewer studies never go to reconcile tab) + if (!study.reviewer1 || !study.reviewer2) return false; + + const checklists = study.checklists || []; + + // If there's a finalized reconciled checklist, reconciliation is complete - don't show in reconcile tab + const hasFinalizedReconciled = checklists.some( + c => isReconciledChecklist(c) && c.status === CHECKLIST_STATUS.FINALIZED, + ); + if (hasFinalizedReconciled) return false; + + // Check for individual reviewer checklists awaiting reconciliation + const awaitingReconcile = checklists.filter( + c => !isReconciledChecklist(c) && c.status === CHECKLIST_STATUS.REVIEWER_COMPLETED, + ); + + // Show if there are 1 or 2 individual checklists awaiting reconciliation + return awaitingReconcile.length >= 1 && awaitingReconcile.length <= 2; + } + + case 'completed': { + const checklists = study.checklists || []; + return checklists.some(c => c.status === CHECKLIST_STATUS.FINALIZED); + } + + default: + return false; + } +} + +/** + * Gets filtered studies for a specific tab + * @param studies - Array of study objects + * @param tab - The tab name ('todo', 'reconcile', 'completed') + * @param userId - The current user ID (required for 'todo' tab) + * @returns Filtered array of studies + */ +export function getStudiesForTab( + studies: Study[] | null | undefined, + tab: 'todo' | 'reconcile' | 'completed', + userId?: string | null, +): (Study & { _needsChecklist?: boolean })[] { + if (!studies || !Array.isArray(studies)) return []; + + if (tab === 'todo') { + // For todo tab, also transform studies to include filtered checklists + return studies + .filter(study => shouldShowInTab(study, tab, userId)) + .map(study => { + const originalChecklists = study.checklists || []; + const todoChecklists = getTodoChecklists(study, userId); + const userHasChecklist = originalChecklists.some(c => c.assignedTo === userId); + return { + ...study, + checklists: todoChecklists, + _needsChecklist: !userHasChecklist, + }; + }) + .filter(study => (study.checklists?.length ?? 0) > 0 || study._needsChecklist); + } + + // For other tabs, just filter studies + return studies.filter(study => shouldShowInTab(study, tab, userId)); +} + +/** + * Gets the count of studies/checklists for a tab badge + * @param studies - Array of study objects + * @param tab - The tab name ('todo', 'reconcile', 'completed') + * @param userId - The current user ID (required for 'todo' tab) + * @returns Count for the tab badge + */ +export function getChecklistCount( + studies: Study[] | null | undefined, + tab: 'todo' | 'reconcile' | 'completed', + userId?: string | null, +): number { + if (!studies || !Array.isArray(studies)) return 0; + return getStudiesForTab(studies, tab, userId).length; +} + +/** + * Determines the next status when a reviewer marks their checklist as complete + * @param study - The study object + * @returns The status to set (FINALIZED for single reviewer, REVIEWER_COMPLETED for dual reviewer) + */ +export function getNextStatusForCompletion(study: Study | null | undefined): string { + if (!study) return CHECKLIST_STATUS.FINALIZED; + + const isSingleReviewer = study.reviewer1 && !study.reviewer2; + if (isSingleReviewer) { + // Single reviewer: goes directly to finalized + return CHECKLIST_STATUS.FINALIZED; + } + + // Dual reviewer: goes to reviewer-completed (awaiting reconciliation) + return CHECKLIST_STATUS.REVIEWER_COMPLETED; +} + +/** + * Finds the reconciled checklist for a study, if one exists + * @param study - The study object + * @param excludeId - Optional checklist ID to exclude from search + * @returns The reconciled checklist or null if not found + */ +export function findReconciledChecklist( + study: Study | null | undefined, + excludeId: string | null = null, +): ChecklistMetadata | null { + if (!study || !study.checklists) return null; + + const reconciled = study.checklists.find( + c => isReconciledChecklist(c) && (!excludeId || c.id !== excludeId), + ); + + return reconciled || null; +} + +/** + * Gets all reconciled checklists for a study that are not yet finalized + * @param study - The study object + * @returns Array of in-progress or reconciling checklists + */ +export function getInProgressReconciledChecklists( + study: Study | null | undefined, +): ChecklistMetadata[] { + if (!study || !study.checklists) return []; + + return study.checklists.filter( + c => isReconciledChecklist(c) && c.status !== CHECKLIST_STATUS.FINALIZED, + ); +} + +/** + * Determines if a study has dual reviewers + * @param study - The study object + * @returns True if study has both reviewer1 and reviewer2 + */ +export function isDualReviewerStudy(study: Study | null | undefined): boolean { + if (!study) return false; + return !!(study.reviewer1 && study.reviewer2); +} + +/** + * Gets the original reviewer checklists that were reconciled + * @param study - The study object + * @param reconciliationProgress - Reconciliation progress data with checklist1Id and checklist2Id + * @returns Array of original reviewer checklists (metadata only) + */ +export function getOriginalReviewerChecklists( + study: Study | null | undefined, + reconciliationProgress: { checklist1Id?: string; checklist2Id?: string } | null | undefined, +): ChecklistMetadata[] { + if (!study || !study.checklists || !reconciliationProgress) return []; + + const { checklist1Id, checklist2Id } = reconciliationProgress; + if (!checklist1Id || !checklist2Id) return []; + + const checklists = study.checklists || []; + const checklist1 = checklists.find(c => c.id === checklist1Id); + const checklist2 = checklists.find(c => c.id === checklist2Id); + + const result: ChecklistMetadata[] = []; + if (checklist1) result.push(checklist1); + if (checklist2) result.push(checklist2); + + return result; +} diff --git a/packages/shared/src/checklists/index.ts b/packages/shared/src/checklists/index.ts new file mode 100644 index 000000000..9d896c8c3 --- /dev/null +++ b/packages/shared/src/checklists/index.ts @@ -0,0 +1,45 @@ +/** + * Checklists Module + * + * This module contains all checklist-related logic that is shared between + * frontend and backend, including: + * - Checklist status constants and helpers + * - AMSTAR2 schema, creation, scoring, and comparison + * - ROBINS-I schema, creation, scoring + * - Domain logic for filtering and querying checklists + * - Type definitions for checklist structures + */ + +// Type definitions +export * from './types.js'; + +// Status constants and helpers +export * from './status.js'; + +// Domain logic (filtering, queries) +export * from './domain.js'; + +// AMSTAR2 +export * as amstar2 from './amstar2/index.js'; + +// ROBINS-I +export * as robinsI from './robins-i/index.js'; + +// Re-export key functions at top level for convenience +export { + createAMSTAR2Checklist, + scoreAMSTAR2Checklist, + isAMSTAR2Complete, + getAnswers as getAMSTAR2Answers, + compareChecklists as compareAMSTAR2Checklists, + createReconciledChecklist as createReconciledAMSTAR2Checklist, +} from './amstar2/index.js'; + +export { + createROBINSIChecklist, + scoreROBINSIChecklist, + isROBINSIComplete, + getAnswers as getROBINSIAnswers, + scoreRobinsDomain, + scoreAllDomains, +} from './robins-i/index.js'; diff --git a/packages/shared/src/checklists/robins-i/answers.ts b/packages/shared/src/checklists/robins-i/answers.ts new file mode 100644 index 000000000..c6a4304ce --- /dev/null +++ b/packages/shared/src/checklists/robins-i/answers.ts @@ -0,0 +1,203 @@ +/** + * ROBINS-I Answer Utilities + * + * Functions for getting and manipulating ROBINS-I checklist answers. + */ + +import type { ROBINSIChecklist } from '../types.js'; +import { ROBINS_I_CHECKLIST, getActiveDomainKeys, getDomainQuestions } from './schema.js'; +import { scoreAllDomains } from './scoring.js'; + +/** + * Determines if assessment should stop based on Section B answers + */ +export function shouldStopAssessment(sectionB: ROBINSIChecklist['sectionB'] | undefined): boolean { + if (!sectionB) return false; + + const b2Answer = sectionB.b2?.answer; + const b3Answer = sectionB.b3?.answer; + + // Stop if B2 or B3 is Yes or Probably Yes + return b2Answer === 'Y' || b2Answer === 'PY' || b3Answer === 'Y' || b3Answer === 'PY'; +} + +/** + * Score the overall checklist based on domain judgements + */ +export function scoreROBINSIChecklist(state: ROBINSIChecklist): string { + if (!state || typeof state !== 'object') return 'Error'; + + if (shouldStopAssessment(state.sectionB)) { + return 'Critical'; + } + + const { overall, isComplete } = scoreAllDomains( + state as unknown as Parameters[0], + ); + + if (!isComplete) { + return 'Incomplete'; + } + + return overall || 'Incomplete'; +} + +/** + * Get the selected answer for a specific question + */ +export function getSelectedAnswer( + domainKey: string, + questionKey: string, + state: ROBINSIChecklist, +): string | null { + const domain = state?.[domainKey as keyof ROBINSIChecklist]; + if (!domain || typeof domain !== 'object') return null; + const typedDomain = domain as { answers?: Record }; + return typedDomain.answers?.[questionKey]?.answer || null; +} + +/** + * Get all answers in a flat format for export/display + */ +export function getAnswers(checklist: ROBINSIChecklist): { + metadata: { + name: string; + reviewerName: string; + createdAt: string; + id: string; + }; + sectionB: Record; + domains: Record< + string, + { + judgement: string | null; + direction: string | null; + questions: Record; + } + >; + overall: ROBINSIChecklist['overall']; +} | null { + if (!checklist || typeof checklist !== 'object') return null; + + const result = { + metadata: { + name: checklist.name, + reviewerName: checklist.reviewerName, + createdAt: checklist.createdAt, + id: checklist.id, + }, + sectionB: {} as Record, + domains: {} as Record< + string, + { + judgement: string | null; + direction: string | null; + questions: Record; + } + >, + overall: checklist.overall, + }; + + // Section B + Object.keys(ROBINS_I_CHECKLIST.sectionB).forEach(key => { + const sectionBItem = checklist.sectionB?.[key as keyof typeof checklist.sectionB]; + if (typeof sectionBItem === 'object' && sectionBItem !== null && 'answer' in sectionBItem) { + result.sectionB[key] = sectionBItem.answer || null; + } else { + result.sectionB[key] = null; + } + }); + + // Domains + const isPerProtocol = checklist.sectionC?.isPerProtocol || false; + const activeDomains = getActiveDomainKeys(isPerProtocol); + + activeDomains.forEach(domainKey => { + const domain = checklist[domainKey]; + if (!domain) return; + + result.domains[domainKey] = { + judgement: domain.judgement || null, + direction: domain.direction || null, + questions: {}, + }; + + Object.keys(domain.answers || {}).forEach(qKey => { + result.domains[domainKey].questions[qKey] = domain.answers[qKey]?.answer || null; + }); + }); + + return result; +} + +/** + * Get a summary of domain judgements + */ +export function getDomainSummary(checklist: ROBINSIChecklist): Record< + string, + { + judgement: string | null; + direction: string | null; + complete: boolean; + } +> | null { + if (!checklist) return null; + + const isPerProtocol = checklist.sectionC?.isPerProtocol || false; + const activeDomains = getActiveDomainKeys(isPerProtocol); + + const summary: Record< + string, + { + judgement: string | null; + direction: string | null; + complete: boolean; + } + > = {}; + + activeDomains.forEach(domainKey => { + const domain = checklist[domainKey]; + summary[domainKey] = { + judgement: domain?.judgement || null, + direction: domain?.direction || null, + complete: isQuestionnaireComplete(domainKey, domain?.answers), + }; + }); + + return summary; +} + +/** + * Check if all questions in a domain are answered + */ +function isQuestionnaireComplete( + domainKey: string, + answers: Record | undefined, +): boolean { + if (!answers) return false; + + const questions = getDomainQuestions(domainKey); + const requiredKeys = Object.keys(questions); + + return requiredKeys.every(key => answers[key]?.answer !== null); +} + +/** + * Check if a ROBINS-I checklist is complete (all active domains have judgements) + */ +export function isROBINSIComplete(checklist: ROBINSIChecklist): boolean { + if (!checklist || typeof checklist !== 'object') return false; + + // If assessment stopped early, it's complete with Critical rating + if (shouldStopAssessment(checklist.sectionB)) { + return true; + } + + const isPerProtocol = checklist.sectionC?.isPerProtocol || false; + const activeDomains = getActiveDomainKeys(isPerProtocol); + + return activeDomains.every(domainKey => { + const domain = checklist[domainKey]; + return domain?.judgement !== null && domain?.judgement !== undefined; + }); +} diff --git a/packages/shared/src/checklists/robins-i/create.ts b/packages/shared/src/checklists/robins-i/create.ts new file mode 100644 index 000000000..75d228b7b --- /dev/null +++ b/packages/shared/src/checklists/robins-i/create.ts @@ -0,0 +1,144 @@ +/** + * ROBINS-I Checklist Creation + * + * Creates new ROBINS-I V2 checklist objects with proper structure and defaults. + */ + +import type { ROBINSIChecklist } from '../types.js'; +import { + INFORMATION_SOURCES, + getDomainQuestions, + ROBINS_I_CHECKLIST, + type DomainKey, +} from './schema.js'; + +interface CreateChecklistOptions { + name: string; + id: string; + createdAt?: number | Date; + reviewerName?: string; +} + +interface DomainState { + answers: Record; + judgement: null; + judgementSource: 'auto'; + direction?: null; +} + +/** + * Creates a new ROBINS-I V2 checklist object with default empty answers. + * + * @param options - Checklist properties. + * @param options.name - The checklist name (required). + * @param options.id - Unique checklist ID (required). + * @param options.createdAt - Timestamp of checklist creation. + * @param options.reviewerName - Name of the reviewer. + * + * @returns A checklist object with all ROBINS-I questions initialized to default answers. + * + * @throws Error if `id` or `name` is missing or not a non-empty string. + */ +export function createROBINSIChecklist({ + name, + id, + createdAt = Date.now(), + reviewerName = '', +}: CreateChecklistOptions): ROBINSIChecklist { + if (!id || typeof id !== 'string' || !id.trim()) { + throw new Error('ROBINS-I Checklist requires a non-empty string id.'); + } + if (!name || typeof name !== 'string' || !name.trim()) { + throw new Error('ROBINS-I Checklist requires a non-empty string name.'); + } + + let d = new Date(createdAt); + if (isNaN(d.getTime())) d = new Date(); + + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const formattedDate = `${d.getFullYear()}-${mm}-${dd}`; + + return { + name: name, + reviewerName: reviewerName || '', + createdAt: formattedDate, + id: id, + checklistType: 'ROBINS_I', + + planning: { + confoundingFactors: '', + }, + + sectionA: { + numericalResult: '', + furtherDetails: '', + outcome: '', + }, + + sectionB: { + b1: { answer: null, comment: '' }, + b2: { answer: null, comment: '' }, + b3: { answer: null, comment: '' }, + stopAssessment: false, + }, + + sectionC: { + participants: '', + interventionStrategy: '', + comparatorStrategy: '', + isPerProtocol: false, + }, + + sectionD: { + sources: INFORMATION_SOURCES.reduce( + (acc, source) => { + acc[source] = false; + return acc; + }, + {} as Record, + ), + otherSpecify: '', + }, + + confoundingEvaluation: { + predefined: [], + additional: [], + }, + + domain1a: createDomainState('domain1a'), + domain1b: createDomainState('domain1b'), + domain2: createDomainState('domain2'), + domain3: createDomainState('domain3'), + domain4: createDomainState('domain4'), + domain5: createDomainState('domain5'), + domain6: createDomainState('domain6'), + + overall: { + judgement: null, + judgementSource: 'auto', + direction: null, + }, + }; +} + +/** + * Creates the initial state for a domain + */ +function createDomainState(domainKey: DomainKey): DomainState { + const questions = getDomainQuestions(domainKey); + const answers: Record = {}; + + Object.keys(questions).forEach(qKey => { + answers[qKey] = { answer: null, comment: '' }; + }); + + const domain = ROBINS_I_CHECKLIST[domainKey]; + + return { + answers, + judgement: null, + judgementSource: 'auto', + direction: domain?.hasDirection ? null : undefined, + }; +} diff --git a/packages/shared/src/checklists/robins-i/index.ts b/packages/shared/src/checklists/robins-i/index.ts new file mode 100644 index 000000000..93b81015b --- /dev/null +++ b/packages/shared/src/checklists/robins-i/index.ts @@ -0,0 +1,18 @@ +/** + * ROBINS-I Module + * + * ROBINS-I (Risk Of Bias In Non-randomized Studies - of Interventions) + * is a tool for assessing risk of bias in non-randomized studies. + */ + +// Schema (checklist map) +export * from './schema.js'; + +// Scoring engine +export * from './scoring.js'; + +// Checklist creation +export * from './create.js'; + +// Answer manipulation +export * from './answers.js'; diff --git a/packages/shared/src/checklists/robins-i/schema.ts b/packages/shared/src/checklists/robins-i/schema.ts new file mode 100644 index 000000000..5f4c56a28 --- /dev/null +++ b/packages/shared/src/checklists/robins-i/schema.ts @@ -0,0 +1,611 @@ +/** + * ROBINS-I V2 Checklist Schema + * + * Risk Of Bias In Non-randomized Studies - of Interventions, Version 2 + */ + +import { JUDGEMENTS, OVERALL_DISPLAY } from './scoring.js'; + +// Response option types used across different questions +export const RESPONSE_TYPES = { + YN: ['Y', 'N'] as const, + STANDARD: ['Y', 'PY', 'PN', 'N'] as const, + WITH_NI: ['Y', 'PY', 'PN', 'N', 'NI'] as const, + WITH_NA: ['NA', 'Y', 'PY', 'PN', 'NI'] as const, + WITH_NA_FULL: ['NA', 'Y', 'PY', 'PN', 'N', 'NI'] as const, + WITH_NA_NO_NI: ['NA', 'Y', 'PY', 'PN', 'N'] as const, + WEAK_STRONG_NO: ['Y', 'PY', 'WN', 'SN', 'NI'] as const, + WITH_NA_WEAK_STRONG_NO: ['NA', 'Y', 'PY', 'WN', 'SN', 'NI'] as const, + WEAK_STRONG_YES: ['SY', 'WY', 'PN', 'N', 'NI'] as const, + WITH_NA_WEAK_STRONG_YES: ['NA', 'SY', 'WY', 'PN', 'N', 'NI'] as const, +} as const; + +export type ResponseType = keyof typeof RESPONSE_TYPES; +export type ResponseValue = (typeof RESPONSE_TYPES)[ResponseType][number]; + +// Human-readable labels for response options +export const RESPONSE_LABELS: Record = { + NA: 'Not Applicable', + Y: 'Yes', + PY: 'Probably Yes', + PN: 'Probably No', + N: 'No', + NI: 'No Information', + WN: 'No, but not substantial', + SN: 'No, and probably substantial', + SY: 'Yes, substantially', + WY: 'Yes, but not substantially', +}; + +// Risk of bias judgement options +export const ROB_JUDGEMENTS = [ + JUDGEMENTS.LOW, + JUDGEMENTS.LOW_EXCEPT_CONFOUNDING, + JUDGEMENTS.MODERATE, + JUDGEMENTS.SERIOUS, + JUDGEMENTS.CRITICAL, +] as const; + +// Overall ROB display strings +export const OVERALL_ROB_JUDGEMENTS = [ + OVERALL_DISPLAY.LOW_EXCEPT_CONFOUNDING, + OVERALL_DISPLAY.MODERATE, + OVERALL_DISPLAY.SERIOUS, + OVERALL_DISPLAY.CRITICAL, +] as const; + +// Bias direction options +export const BIAS_DIRECTIONS = [ + 'Upward bias (overestimate the effect)', + 'Downward bias (underestimate the effect)', + 'Favours intervention', + 'Favours comparator', + 'Towards null', + 'Away from null', + 'Unpredictable', +] as const; + +// Domain 1 specific directions (subset) +export const DOMAIN1_DIRECTIONS = [ + 'Upward bias (overestimate the effect)', + 'Downward bias (underestimate the effect)', + 'Unpredictable', +] as const; + +// Information sources for Section D +export const INFORMATION_SOURCES = [ + 'Journal article(s)', + 'Study protocol', + 'Statistical analysis plan (SAP)', + 'Non-commercial registry record (e.g. ClinicalTrials.gov record)', + 'Company-owned registry record (e.g. GSK Clinical Study Register record)', + 'Grey literature (e.g. unpublished thesis)', + 'Conference abstract(s)', + 'Regulatory document (e.g. Clinical Study Report, Drug Approval Package)', + 'Individual participant data', + 'Research ethics application', + 'Grant database summary (e.g. NIH RePORTER, Research Councils UK Gateway to Research)', + 'Personal communication with investigator', + 'Personal communication with sponsor', +] as const; + +// Checklist type definition +export const CHECKLIST_TYPES = { + ROBINS_I: { + name: 'ROBINS-I V2', + description: 'Risk Of Bias In Non-randomized Studies - of Interventions (Version 2)', + }, +} as const; + +export interface ROBINSQuestion { + id: string; + number?: string; + text: string; + responseType: ResponseType; + info?: string; + label?: string; + placeholder?: string; + type?: string; + stateKey?: string; + optional?: boolean; + options?: Array<{ value: boolean; label: string }>; +} + +export interface ROBINSDomain { + id: string; + name: string; + subtitle?: string; + questions?: Record; + subsections?: Record< + string, + { + name: string; + questions: Record; + } + >; + hasDirection?: boolean; + directionOptions?: readonly string[]; +} + +// Planning Section: List confounding factors at planning stage +export const PLANNING_SECTION = { + title: 'The ROBINS-I V2 Tool', + subtitle: 'At planning stage: list confounding factors', + p1: { + id: 'p1', + label: 'P1', + text: 'List the important confounding factors relevant to all or most studies on this topic. Specify whether these are particular to specific intervention-outcome combinations.', + placeholder: + 'e.g., Age, baseline disease severity, comorbidities, concomitant medications, socioeconomic status...', + type: 'textarea', + stateKey: 'confoundingFactors', + }, +} as const; + +// Section A: Specify the result being assessed for risk of bias +export const SECTION_A = { + a1: { + id: 'a1', + label: 'A1', + text: 'Specify the numerical result being assessed', + placeholder: 'e.g., OR = 1.5 (95% CI: 1.2-1.9)', + type: 'textarea', + stateKey: 'numericalResult', + }, + a2: { + id: 'a2', + label: 'A2', + text: 'Provide further details about this result (for example, location in the study report, reason it was chosen)', + optional: true, + placeholder: 'e.g., Table 3, primary outcome analysis', + type: 'textarea', + stateKey: 'furtherDetails', + }, + a3: { + id: 'a3', + label: 'A3', + text: 'Specify the outcome to which this result relates', + placeholder: 'e.g., All-cause mortality at 12 months', + type: 'textarea', + stateKey: 'outcome', + }, +} as const; + +// Section B: Decide whether to proceed with a risk-of-bias assessment +export const SECTION_B: Record = { + b1: { + id: 'b1', + text: 'Did the authors make any attempt to control for confounding in the result being assessed?', + responseType: 'STANDARD', + }, + b2: { + id: 'b2', + text: 'If N/PN to B1: Is there sufficient potential for confounding that this result should not be considered further?', + responseType: 'STANDARD', + info: "If the answer to B2 is 'Yes' or 'Probably yes', the result should be considered to be at 'Critical risk of bias' and no further assessment is required.", + }, + b3: { + id: 'b3', + text: 'Was the method of measuring the outcome inappropriate?', + responseType: 'STANDARD', + info: "If the answer to B3 is 'Yes' or 'Probably yes', the result should be considered to be at 'Critical risk of bias' and no further assessment is required.", + }, +}; + +// Section C: Specify the (hypothetical) target randomized trial specific to the study +export const SECTION_C = { + description: + "The target randomized trial is either explicitly described by the primary study investigators or implied by the study's design and analysis.", + c1: { + id: 'c1', + label: 'C1', + text: 'Specify the participants and eligibility criteria', + placeholder: 'e.g., Adults aged 18+ with type 2 diabetes, no prior cardiovascular disease', + type: 'textarea', + stateKey: 'participants', + }, + c2: { + id: 'c2', + label: 'C2', + text: 'Specify the intervention strategy', + placeholder: 'e.g., Initiation of metformin 500mg twice daily', + type: 'textarea', + stateKey: 'interventionStrategy', + }, + c3: { + id: 'c3', + label: 'C3', + text: 'Specify the comparator strategy', + placeholder: 'e.g., Initiation of sulfonylurea therapy', + type: 'textarea', + stateKey: 'comparatorStrategy', + }, + c4: { + id: 'c4', + label: 'C4', + text: 'Did the analysis account for switches during follow-up between the intervention strategies being compared, or for other protocol deviations during follow-up?', + type: 'radio', + stateKey: 'isPerProtocol', + options: [ + { value: false, label: 'No (the analysis is estimating the intention-to-treat effect)' }, + { value: true, label: 'Yes (the analysis is estimating the per-protocol effect)' }, + ], + }, +} as const; + +// Domain definitions +export const DOMAIN_1A: ROBINSDomain = { + id: 'domain1a', + name: 'Domain 1: Bias due to confounding', + subtitle: + 'Variant A (the analysis is estimating the intention-to-treat effect so only baseline confounding needs to be addressed)', + questions: { + d1a_1: { + id: 'd1a_1', + number: '1.1', + text: 'Did the authors control for all the important confounding factors for which this was necessary?', + responseType: 'WEAK_STRONG_NO', + }, + d1a_2: { + id: 'd1a_2', + number: '1.2', + text: 'If Y/PY/WN to 1.1: Were confounding factors that were controlled for (and for which control was necessary) measured validly and reliably by the variables available in this study?', + responseType: 'WITH_NA_WEAK_STRONG_NO', + }, + d1a_3: { + id: 'd1a_3', + number: '1.3', + text: 'If Y/PY/WN to 1.1: Did the authors control for any post-intervention variables that could have been affected by the intervention?', + responseType: 'WITH_NA_FULL', + }, + d1a_4: { + id: 'd1a_4', + number: '1.4', + text: 'Did the use of negative controls, quantitative bias analysis, or other considerations, suggest serious uncontrolled confounding?', + responseType: 'WITH_NA', + }, + }, + hasDirection: true, + directionOptions: DOMAIN1_DIRECTIONS, +}; + +export const DOMAIN_1B: ROBINSDomain = { + id: 'domain1b', + name: 'Domain 1: Bias due to confounding', + subtitle: + 'Variant B (the analysis is estimating the per-protocol effect so both baseline and time-varying confounding need to be addressed)', + questions: { + d1b_1: { + id: 'd1b_1', + number: '1.1', + text: 'Did the authors use an analysis method that was appropriate to control for time-varying as well as baseline confounding?', + responseType: 'WITH_NI', + }, + d1b_2: { + id: 'd1b_2', + number: '1.2', + text: 'If Y/PY to 1.1: Did the authors control for all the important baseline and time-varying confounding factors for which this was necessary?', + responseType: 'WITH_NA_WEAK_STRONG_NO', + }, + d1b_3: { + id: 'd1b_3', + number: '1.3', + text: 'If Y/PY/WN to 1.2: Were confounding factors that were controlled for (and for which control was necessary) measured validly and reliably by the variables available in this study?', + responseType: 'WITH_NA_WEAK_STRONG_NO', + }, + d1b_4: { + id: 'd1b_4', + number: '1.4', + text: 'If N/PN/NI to 1.1: Did the authors control for time-varying factors or other variables measured after the start of intervention?', + responseType: 'WITH_NA_FULL', + }, + d1b_5: { + id: 'd1b_5', + number: '1.5', + text: 'Did the use of negative controls, or other considerations, suggest serious uncontrolled confounding?', + responseType: 'STANDARD', + }, + }, + hasDirection: true, + directionOptions: DOMAIN1_DIRECTIONS, +}; + +export const DOMAIN_2: ROBINSDomain = { + id: 'domain2', + name: 'Domain 2: Bias in classification of interventions', + questions: { + d2_1: { + id: 'd2_1', + number: '2.1', + text: 'Were the intervention strategies distinguishable at the time when follow-up would have started in the target trial?', + responseType: 'WITH_NI', + }, + d2_2: { + id: 'd2_2', + number: '2.2', + text: 'If N/PN/NI to 2.1: Did all or nearly all outcome events occur after the intervention and comparator strategies could be distinguished?', + responseType: 'WITH_NA_FULL', + }, + d2_3: { + id: 'd2_3', + number: '2.3', + text: 'If N/PN/NI to 2.2: Did the analysis avoid problems arising from intervention strategies that are not distinguishable at the start of follow-up?', + responseType: 'WITH_NA_WEAK_STRONG_YES', + }, + d2_4: { + id: 'd2_4', + number: '2.4', + text: 'Was classification of intervention status influenced by knowledge of the outcome or risk of the outcome?', + responseType: 'WEAK_STRONG_YES', + }, + d2_5: { + id: 'd2_5', + number: '2.5', + text: 'Were further classification errors (not influenced by knowledge of the outcome or risk of the outcome) likely?', + responseType: 'WITH_NI', + }, + }, + hasDirection: true, + directionOptions: BIAS_DIRECTIONS, +}; + +export const DOMAIN_3: ROBINSDomain = { + id: 'domain3', + name: 'Domain 3: Bias in selection of participants into the study (or into the analysis)', + subsections: { + a: { + name: 'A. Questions about prevalent user bias and immortal time', + questions: { + d3_1: { + id: 'd3_1', + number: '3.1', + text: 'Did follow up in the analysis begin at the start of the intervention strategies being compared?', + responseType: 'WEAK_STRONG_NO', + }, + d3_2: { + id: 'd3_2', + number: '3.2', + text: 'If Y/PY to 3.1: Were outcome events during a period of follow-up after the start of the interventions excluded from the analysis?', + responseType: 'WITH_NI', + }, + }, + }, + b: { + name: 'B. Questions about other types of selection bias', + questions: { + d3_3: { + id: 'd3_3', + number: '3.3', + text: 'Was selection of participants into the study (or into the analysis) based on participant characteristics observed after the start of intervention (additional to the situations addressed in 3.1 and 3.2)?', + responseType: 'WITH_NI', + }, + d3_4: { + id: 'd3_4', + number: '3.4', + text: 'If Y/PY to 3.3: Were the post-intervention variables that influenced selection likely to be associated with intervention?', + responseType: 'WITH_NA_FULL', + }, + d3_5: { + id: 'd3_5', + number: '3.5', + text: 'If Y/PY to 3.4: Were the post-intervention variables that influenced selection likely to be influenced by the outcome or a cause of the outcome?', + responseType: 'WITH_NA_FULL', + }, + }, + }, + c: { + name: 'C. Questions about analysis, sensitivity analyses and severity of the problem', + questions: { + d3_6: { + id: 'd3_6', + number: '3.6', + text: 'If SN to 3.1 or Y/PY to 3.5: Is it likely that the analysis corrected for all of the potential selection biases identified above?', + responseType: 'WITH_NA_FULL', + }, + d3_7: { + id: 'd3_7', + number: '3.7', + text: 'If N/PN/NI to 3.6: Did sensitivity analyses demonstrate that the likely impact of the potential selection biases identified above was minimal?', + responseType: 'WITH_NA_FULL', + }, + d3_8: { + id: 'd3_8', + number: '3.8', + text: 'If N/PN/NI to 3.7: Were potential selection biases identified above sufficiently severe that the result should not be included in a quantitative synthesis?', + responseType: 'WITH_NA_FULL', + }, + }, + }, + }, + hasDirection: true, + directionOptions: BIAS_DIRECTIONS, +}; + +export const DOMAIN_4: ROBINSDomain = { + id: 'domain4', + name: 'Domain 4: Bias due to missing data', + questions: { + d4_1: { + id: 'd4_1', + number: '4.1', + text: 'Were complete data on intervention status available for all, or nearly all, participants?', + responseType: 'WITH_NI', + }, + d4_2: { + id: 'd4_2', + number: '4.2', + text: 'Were complete data on the outcome available for all, or nearly all, participants?', + responseType: 'WITH_NI', + }, + d4_3: { + id: 'd4_3', + number: '4.3', + text: 'Were complete data on important confounding variables available for all, or nearly all, participants?', + responseType: 'WITH_NI', + }, + d4_4: { + id: 'd4_4', + number: '4.4', + text: 'If N/PN/NI to 4.1, 4.2 or 4.3: Is the result based on a complete case analysis?', + responseType: 'WITH_NA_FULL', + }, + d4_5: { + id: 'd4_5', + number: '4.5', + text: 'If Y/PY/NI to 4.4: Was exclusion from the analysis because of missing data (in intervention, confounders or the outcome) likely to be related to the true value of the outcome?', + responseType: 'WITH_NA_FULL', + }, + d4_6: { + id: 'd4_6', + number: '4.6', + text: 'If Y/PY/NI to 4.5: Is the relationship between the outcome and missingness likely to be explained by the variables in the analysis model?', + responseType: 'WITH_NA_WEAK_STRONG_NO', + }, + d4_7: { + id: 'd4_7', + number: '4.7', + text: 'If N/PN to 4.4: Was the analysis based on imputing missing values?', + responseType: 'WITH_NA', + }, + d4_8: { + id: 'd4_8', + number: '4.8', + text: "If Y/PY to 4.7: Is it reasonable to assume that data were 'missing at random' (MAR) or 'missing completely at random' (MCAR)?", + responseType: 'WITH_NA_FULL', + }, + d4_9: { + id: 'd4_9', + number: '4.9', + text: 'If Y/PY to 4.8: Was imputation performed appropriately?', + responseType: 'WITH_NA_WEAK_STRONG_NO', + }, + d4_10: { + id: 'd4_10', + number: '4.10', + text: 'If N/PN/NI to 4.7: Was an appropriate alternative method used to correct for bias due to missing data?', + responseType: 'WITH_NA_WEAK_STRONG_NO', + }, + d4_11: { + id: 'd4_11', + number: '4.11', + text: 'If PN/N/NI to 4.1, 4.2 or 4.3 AND (Y/PY/NI to 4.5 OR WN/SN/NI to 4.9 OR WN/SN/NI to 4.10): Is there evidence that the result was not biased by missing data?', + responseType: 'WITH_NA_NO_NI', + }, + }, + hasDirection: true, + directionOptions: BIAS_DIRECTIONS, +}; + +export const DOMAIN_5: ROBINSDomain = { + id: 'domain5', + name: 'Domain 5: Bias in measurement of the outcome', + questions: { + d5_1: { + id: 'd5_1', + number: '5.1', + text: 'Could measurement or ascertainment of the outcome have differed between intervention groups?', + responseType: 'WITH_NI', + }, + d5_2: { + id: 'd5_2', + number: '5.2', + text: 'Were outcome assessors aware of the intervention received by study participants?', + responseType: 'WITH_NI', + }, + d5_3: { + id: 'd5_3', + number: '5.3', + text: 'If Y/PY/NI to 5.2: Could assessment of the outcome have been influenced by knowledge of the intervention received?', + responseType: 'WITH_NA_WEAK_STRONG_YES', + }, + }, + hasDirection: true, + directionOptions: BIAS_DIRECTIONS, +}; + +export const DOMAIN_6: ROBINSDomain = { + id: 'domain6', + name: 'Domain 6: Bias in selection of the reported result', + questions: { + d6_1: { + id: 'd6_1', + number: '6.1', + text: 'Was the result reported in accordance with an available, pre-determined analysis plan?', + responseType: 'WITH_NI', + }, + d6_2: { + id: 'd6_2', + number: '6.2', + text: 'Is the numerical result being assessed likely to have been selected, on the basis of the results, from multiple outcome measurements (e.g. scales, definitions, time points) within the outcome domain?', + responseType: 'WITH_NI', + }, + d6_3: { + id: 'd6_3', + number: '6.3', + text: 'Is the numerical result being assessed likely to have been selected, on the basis of the results, from multiple analyses of the data?', + responseType: 'WITH_NI', + }, + d6_4: { + id: 'd6_4', + number: '6.4', + text: 'Is the numerical result being assessed likely to have been selected, on the basis of the results, from multiple subgroups?', + responseType: 'WITH_NI', + }, + }, + hasDirection: true, + directionOptions: BIAS_DIRECTIONS, +}; + +// Complete ROBINS-I checklist structure +export const ROBINS_I_CHECKLIST = { + sectionB: SECTION_B, + domain1a: DOMAIN_1A, + domain1b: DOMAIN_1B, + domain2: DOMAIN_2, + domain3: DOMAIN_3, + domain4: DOMAIN_4, + domain5: DOMAIN_5, + domain6: DOMAIN_6, +} as const; + +export type DomainKey = + | 'domain1a' + | 'domain1b' + | 'domain2' + | 'domain3' + | 'domain4' + | 'domain5' + | 'domain6'; + +// Get all domain keys (for iteration) +export function getDomainKeys(): DomainKey[] { + return ['domain1a', 'domain1b', 'domain2', 'domain3', 'domain4', 'domain5', 'domain6']; +} + +// Get domains that should be displayed based on C4 answer +export function getActiveDomainKeys(isPerProtocol: boolean): DomainKey[] { + const base: DomainKey[] = ['domain2', 'domain3', 'domain4', 'domain5', 'domain6']; + return isPerProtocol ? ['domain1b', ...base] : ['domain1a', ...base]; +} + +// Get all questions for a domain (flattened) +export function getDomainQuestions(domainKey: string): Record { + const domain = ROBINS_I_CHECKLIST[domainKey as keyof typeof ROBINS_I_CHECKLIST]; + if (!domain || domainKey === 'sectionB') return {}; + + const typedDomain = domain as ROBINSDomain; + if (typedDomain.subsections) { + let allQuestions: Record = {}; + Object.values(typedDomain.subsections).forEach(subsection => { + allQuestions = { ...allQuestions, ...subsection.questions }; + }); + return allQuestions; + } + + return typedDomain.questions || {}; +} + +// Get response options array for a response type +export function getResponseOptions(responseType: ResponseType): readonly string[] { + return RESPONSE_TYPES[responseType] || RESPONSE_TYPES.WITH_NI; +} diff --git a/packages/shared/src/checklists/robins-i/scoring.ts b/packages/shared/src/checklists/robins-i/scoring.ts new file mode 100644 index 000000000..3c8c04e8c --- /dev/null +++ b/packages/shared/src/checklists/robins-i/scoring.ts @@ -0,0 +1,1030 @@ +/** + * ROBINS-I V2 Smart Scoring Engine + * + * Implements deterministic, table-driven scoring for all ROBINS-I domains + * based on the official decision tables. + */ + +// Helper: check if answer matches any value in a set +const inSet = (answer: string | null | undefined, ...values: string[]): boolean => + values.includes(answer as string); + +// Normalization: treat NA as NI for scoring to avoid "stuck" branches +const normalizeAnswer = (answer: string | null | undefined): string | null => + answer === 'NA' ? 'NI' : (answer ?? null); + +// Helper: check if answer is Yes or Probably Yes +const isYesPY = (answer: string | null): boolean => inSet(answer, 'Y', 'PY'); + +// Helper: check if answer is No or Probably No +const isNoPPN = (answer: string | null): boolean => inSet(answer, 'N', 'PN'); + +// Helper: check if answer is No, Probably No, or No Information +const isNoPPNNI = (answer: string | null): boolean => inSet(answer, 'N', 'PN', 'NI'); + +// Canonical judgement values - single source of truth for all ROBINS-I scoring +export const JUDGEMENTS = { + LOW: 'Low', + LOW_EXCEPT_CONFOUNDING: 'Low (except for concerns about uncontrolled confounding)', + MODERATE: 'Moderate', + SERIOUS: 'Serious', + CRITICAL: 'Critical', +} as const; + +export type Judgement = (typeof JUDGEMENTS)[keyof typeof JUDGEMENTS]; + +export interface ScoringResult { + judgement: Judgement | null; + isComplete: boolean; + ruleId: string | null; +} + +export interface DomainAnswers { + [questionKey: string]: { + answer: string | null; + comment?: string; + }; +} + +/** + * Score Domain 1A (Bias due to confounding - ITT effect) + */ +function scoreDomain1A(answers: DomainAnswers): ScoringResult { + const q1 = normalizeAnswer(answers.d1a_1?.answer); + const q2 = normalizeAnswer(answers.d1a_2?.answer); + const q3 = normalizeAnswer(answers.d1a_3?.answer); + const q4 = normalizeAnswer(answers.d1a_4?.answer); + + if (q1 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // Path: Q1 -> if SN/NI -> NC1 -> outcomes + if (inSet(q1, 'SN', 'NI')) { + if (q4 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q4)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1A.R8' }; + } + if (isYesPY(q4)) { + return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D1A.R9' }; + } + } + + // Path: Q1 -> if Y/PY -> Q3a + if (isYesPY(q1)) { + if (q3 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (isYesPY(q3)) { + if (q4 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q4)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1A.R5' }; + } + if (isYesPY(q4)) { + return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D1A.R4' }; + } + } + + if (isNoPPNNI(q3)) { + if (q2 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (inSet(q2, 'SN', 'NI')) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1A.R3' }; + } + + if (isYesPY(q2) || q2 === 'WN') { + if (q4 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q4)) { + return { + judgement: JUDGEMENTS.LOW_EXCEPT_CONFOUNDING, + isComplete: true, + ruleId: 'D1A.R1', + }; + } + if (isYesPY(q4)) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D1A.R2' }; + } + } + } + } + + // Path: Q1 -> if WN -> Q3b + if (q1 === 'WN') { + if (q3 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (isYesPY(q3)) { + if (q4 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q4)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1A.R6' }; + } + if (isYesPY(q4)) { + return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D1A.R7' }; + } + } + + if (isNoPPNNI(q3)) { + if (q2 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (inSet(q2, 'SN', 'NI')) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1A.R10' }; + } + + if (isYesPY(q2) || q2 === 'WN') { + if (q4 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q4)) { + return { + judgement: JUDGEMENTS.LOW_EXCEPT_CONFOUNDING, + isComplete: true, + ruleId: 'D1A.R1', + }; + } + if (isYesPY(q4)) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D1A.R2' }; + } + } + } + } + + return { judgement: null, isComplete: false, ruleId: null }; +} + +/** + * Score Domain 1B (Bias due to confounding - Per-Protocol effect) + */ +function scoreDomain1B(answers: DomainAnswers): ScoringResult { + const q1 = normalizeAnswer(answers.d1b_1?.answer); + const q2 = normalizeAnswer(answers.d1b_2?.answer); + const q3 = normalizeAnswer(answers.d1b_3?.answer); + const q4 = normalizeAnswer(answers.d1b_4?.answer); + const q5 = normalizeAnswer(answers.d1b_5?.answer); + + if (q1 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // Path: Q1 -> if N/PN/NI -> Q4 + if (isNoPPNNI(q1)) { + if (q4 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (isYesPY(q4)) { + return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D1B.R6' }; + } + + if (isNoPPNNI(q4)) { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q5)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R8' }; + } + if (isYesPY(q5)) { + return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D1B.R7' }; + } + } + } + + // Path: Q1 -> if Y/PY -> Q2 + if (isYesPY(q1)) { + if (q2 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (inSet(q2, 'SN', 'NI')) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R5' }; + } + + if (isYesPY(q2)) { + if (q3 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (inSet(q3, 'SN', 'NI')) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R5' }; + } + + if (isYesPY(q3)) { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q5)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D1B.R1' }; + } + if (isYesPY(q5)) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D1B.R2' }; + } + } + + if (q3 === 'WN') { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q5)) { + return { + judgement: JUDGEMENTS.LOW_EXCEPT_CONFOUNDING, + isComplete: true, + ruleId: 'D1B.R3', + }; + } + if (isYesPY(q5)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R4' }; + } + } + } + + if (q2 === 'WN') { + if (q3 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (inSet(q3, 'SN', 'NI')) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R5' }; + } + + if (isYesPY(q3) || q3 === 'WN') { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q5)) { + return { + judgement: JUDGEMENTS.LOW_EXCEPT_CONFOUNDING, + isComplete: true, + ruleId: 'D1B.R3', + }; + } + if (isYesPY(q5)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R4' }; + } + } + } + } + + return { judgement: null, isComplete: false, ruleId: null }; +} + +/** + * Score Domain 2 (Bias in classification of interventions) + */ +function scoreDomain2(answers: DomainAnswers): ScoringResult { + const q1 = normalizeAnswer(answers.d2_1?.answer); + const q2 = normalizeAnswer(answers.d2_2?.answer); + const q3 = normalizeAnswer(answers.d2_3?.answer); + const q4 = normalizeAnswer(answers.d2_4?.answer); + const q5 = normalizeAnswer(answers.d2_5?.answer); + + if (q1 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // Path: A1 -> if Y/PY -> C1 + if (isYesPY(q1)) { + if (q4 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (q4 === 'SY') { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q5)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R4' }; + } + if (inSet(q5, 'Y', 'PY', 'NI')) { + return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D2.R4' }; + } + } + + if (inSet(q4, 'WY', 'NI')) { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q5)) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D2.R3' }; + } + if (inSet(q5, 'Y', 'PY', 'NI')) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R3' }; + } + } + + if (isNoPPN(q4)) { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q5)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D2.R1' }; + } + if (inSet(q5, 'Y', 'PY', 'NI')) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D2.R2' }; + } + } + } + + // Path: A1 -> if N/PN/NI -> A2 + if (isNoPPNNI(q1)) { + if (q2 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (isYesPY(q2)) { + if (q4 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (q4 === 'SY') { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q5)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R4' }; + } + if (inSet(q5, 'Y', 'PY', 'NI')) { + return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D2.R4' }; + } + } + + if (inSet(q4, 'WY', 'NI')) { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q5)) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D2.R3' }; + } + if (inSet(q5, 'Y', 'PY', 'NI')) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R3' }; + } + } + + if (isNoPPN(q4)) { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q5)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D2.R5' }; + } + if (inSet(q5, 'Y', 'PY', 'NI')) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D2.R2' }; + } + } + } + + if (isNoPPNNI(q2)) { + if (q3 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (isNoPPN(q3)) { + if (q4 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (inSet(q4, 'SY', 'WY', 'NI')) { + return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D2.R7' }; + } + if (isNoPPN(q4)) { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q5)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R7' }; + } + if (inSet(q5, 'Y', 'PY', 'NI')) { + return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D2.R7' }; + } + } + } + + if (inSet(q3, 'SY', 'WY', 'NI')) { + if (q4 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (q4 === 'SY') { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q5)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R6' }; + } + if (inSet(q5, 'Y', 'PY', 'NI')) { + return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D2.R6' }; + } + } + if (isNoPPN(q4)) { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q5)) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D2.R6' }; + } + if (inSet(q5, 'Y', 'PY', 'NI')) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R6' }; + } + } + } + } + } + + return { judgement: null, isComplete: false, ruleId: null }; +} + +interface PartResult { + result: string | null; + isComplete: boolean; +} + +/** + * Score Domain 3 Part A (Selection bias - prevalent user bias and immortal time) + */ +function scoreDomain3PartA(answers: DomainAnswers): PartResult { + const q1 = normalizeAnswer(answers.d3_1?.answer); + const q2 = normalizeAnswer(answers.d3_2?.answer); + + if (q1 === null) { + return { result: null, isComplete: false }; + } + + if (q1 === 'SN') { + return { result: 'Serious', isComplete: true }; + } + + if (inSet(q1, 'WN', 'NI')) { + return { result: 'Moderate', isComplete: true }; + } + + if (isYesPY(q1)) { + if (q2 === null) { + return { result: null, isComplete: false }; + } + if (isNoPPNNI(q2)) { + return { result: 'Low', isComplete: true }; + } + if (isYesPY(q2)) { + return { result: 'Moderate', isComplete: true }; + } + } + + return { result: null, isComplete: q2 !== null }; +} + +/** + * Score Domain 3 Part B (Selection bias - other types) + */ +function scoreDomain3PartB(answers: DomainAnswers): PartResult { + const q3 = normalizeAnswer(answers.d3_3?.answer); + const q4 = normalizeAnswer(answers.d3_4?.answer); + const q5 = normalizeAnswer(answers.d3_5?.answer); + + if (q3 === null) { + return { result: null, isComplete: false }; + } + + if (isNoPPN(q3)) { + return { result: 'Low', isComplete: true }; + } + + if (q3 === 'NI') { + return { result: 'Moderate', isComplete: true }; + } + + if (isYesPY(q3)) { + if (q4 === null) { + return { result: null, isComplete: false }; + } + + if (isNoPPN(q4)) { + return { result: 'Low', isComplete: true }; + } + + if (q4 === 'NI') { + return { result: 'Moderate', isComplete: true }; + } + + if (isYesPY(q4)) { + if (q5 === null) { + return { result: null, isComplete: false }; + } + if (isNoPPNNI(q5)) { + return { result: 'Moderate', isComplete: true }; + } + if (isYesPY(q5)) { + return { result: 'Serious', isComplete: true }; + } + } + } + + const allAnswered = [q3, q4, q5].every(a => a !== null); + return { result: null, isComplete: allAnswered }; +} + +/** + * Score Domain 3 Final (combines Part A + Part B with correction questions) + */ +function scoreDomain3(answers: DomainAnswers): ScoringResult { + const partA = scoreDomain3PartA(answers); + const partB = scoreDomain3PartB(answers); + + if (!partA.isComplete || !partB.isComplete) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + const q6 = normalizeAnswer(answers.d3_6?.answer); + const q7 = normalizeAnswer(answers.d3_7?.answer); + const q8 = normalizeAnswer(answers.d3_8?.answer); + + const rankMap: Record = { Low: 0, Moderate: 1, Serious: 2 }; + const aRank = rankMap[partA.result || ''] ?? 0; + const bRank = rankMap[partB.result || ''] ?? 0; + const worstRank = Math.max(aRank, bRank); + + if (partA.result === 'Low' && partB.result === 'Low') { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D3.R1' }; + } + + if (worstRank <= 1) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D3.R2' }; + } + + if (worstRank >= 2) { + if (isYesPY(q6)) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D3.R3' }; + } + + if (isNoPPNNI(q6)) { + if (isYesPY(q7)) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D3.R3' }; + } + + if (isNoPPNNI(q7)) { + if (q8 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPNNI(q8)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D3.R4' }; + } + if (isYesPY(q8)) { + return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D3.R5' }; + } + } + } + } + + return { judgement: null, isComplete: false, ruleId: null }; +} + +/** + * Score Domain 4 (Bias due to missing data) + */ +function scoreDomain4(answers: DomainAnswers): ScoringResult { + const q1 = normalizeAnswer(answers.d4_1?.answer); + const q2 = normalizeAnswer(answers.d4_2?.answer); + const q3 = normalizeAnswer(answers.d4_3?.answer); + const q4 = normalizeAnswer(answers.d4_4?.answer); + const q5 = normalizeAnswer(answers.d4_5?.answer); + const q6 = normalizeAnswer(answers.d4_6?.answer); + const q7 = normalizeAnswer(answers.d4_7?.answer); + const q8 = normalizeAnswer(answers.d4_8?.answer); + const q9 = normalizeAnswer(answers.d4_9?.answer); + const q10 = normalizeAnswer(answers.d4_10?.answer); + const q11 = normalizeAnswer(answers.d4_11?.answer); + + const completeDataAnswered = [q1, q2, q3].every(a => a !== null); + if (!completeDataAnswered) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + const allCompleteData = [q1, q2, q3].every(a => isYesPY(a)); + + if (allCompleteData) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D4.R1' }; + } + + if ([q1, q2, q3].some(a => isNoPPNNI(a))) { + if (q4 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // Complete-case path + if (isYesPY(q4) || q4 === 'NI') { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (isNoPPN(q5)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D4.R2' }; + } + + if (isYesPY(q5) || q5 === 'NI') { + if (q6 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (isYesPY(q6)) { + if (q11 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isYesPY(q11)) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D4.R3' }; + } + if (isNoPPN(q11)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R4' }; + } + } + + if (inSet(q6, 'WN', 'NI')) { + if (q11 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isYesPY(q11)) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D4.R6' }; + } + if (isNoPPN(q11)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' }; + } + } + + if (q6 === 'SN') { + if (q11 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isYesPY(q11)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' }; + } + if (isNoPPN(q11)) { + return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D4.R8' }; + } + } + } + } + + // Imputation/alternative method path + if (isNoPPN(q4)) { + if (q7 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // Imputation path + if (isYesPY(q7)) { + if (q8 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (isYesPY(q8)) { + if (q9 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isYesPY(q9)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D4.R5' }; + } + if (inSet(q9, 'WN', 'NI')) { + if (q11 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isYesPY(q11)) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D4.R6' }; + } + if (isNoPPN(q11)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' }; + } + } + if (q9 === 'SN') { + if (q11 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isYesPY(q11)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' }; + } + if (isNoPPN(q11)) { + return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D4.R8' }; + } + } + } + + if (isNoPPNNI(q8)) { + if (q11 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isYesPY(q11)) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D4.R6' }; + } + if (isNoPPN(q11)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' }; + } + } + } + + // Alternative method path + if (isNoPPNNI(q7)) { + if (q10 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isYesPY(q10)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D4.R2' }; + } + if (inSet(q10, 'WN', 'NI')) { + if (q11 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isYesPY(q11)) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D4.R6' }; + } + if (isNoPPN(q11)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' }; + } + } + if (q10 === 'SN') { + if (q11 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isYesPY(q11)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' }; + } + if (isNoPPN(q11)) { + return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D4.R8' }; + } + } + } + } + } + + return { judgement: null, isComplete: false, ruleId: null }; +} + +/** + * Score Domain 5 (Bias in measurement of the outcome) + */ +function scoreDomain5(answers: DomainAnswers): ScoringResult { + const q1 = normalizeAnswer(answers.d5_1?.answer); + const q2 = normalizeAnswer(answers.d5_2?.answer); + const q3 = normalizeAnswer(answers.d5_3?.answer); + + if (q1 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (isYesPY(q1)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D5.R1' }; + } + + if (isNoPPN(q1)) { + if (q2 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (isNoPPN(q2)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D5.R2' }; + } + + if (inSet(q2, 'Y', 'PY', 'NI')) { + if (q3 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (isNoPPN(q3)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D5.R3' }; + } + if (inSet(q3, 'WY', 'NI')) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D5.R4' }; + } + if (q3 === 'SY') { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D5.R5' }; + } + } + } + + if (q1 === 'NI') { + if (q2 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (isNoPPN(q2)) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D5.R6' }; + } + + if (inSet(q2, 'Y', 'PY', 'NI')) { + if (q3 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + if (inSet(q3, 'WY', 'N', 'PN', 'NI')) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D5.R7' }; + } + if (q3 === 'SY') { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D5.R7' }; + } + } + } + + return { judgement: null, isComplete: false, ruleId: null }; +} + +/** + * Score Domain 6 (Bias in selection of the reported result) + */ +function scoreDomain6(answers: DomainAnswers): ScoringResult { + const q1 = normalizeAnswer(answers.d6_1?.answer); + const q2 = normalizeAnswer(answers.d6_2?.answer); + const q3 = normalizeAnswer(answers.d6_3?.answer); + const q4 = normalizeAnswer(answers.d6_4?.answer); + + if (q1 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + if (isYesPY(q1)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D6.R1' }; + } + + if (isNoPPNNI(q1)) { + const selectionQuestions = [q2, q3, q4]; + const allSelectionAnswered = selectionQuestions.every(a => a !== null); + + if (!allSelectionAnswered) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + const yesCount = selectionQuestions.filter(a => isYesPY(a)).length; + const hasNI = selectionQuestions.some(a => a === 'NI'); + const allNI = selectionQuestions.every(a => a === 'NI'); + const allNPN = selectionQuestions.every(a => isNoPPN(a)); + + if (allNPN) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D6.R2' }; + } + + if (yesCount === 0 && hasNI && !allNI) { + return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D6.R3' }; + } + + if (yesCount === 1 || (yesCount === 0 && allNI)) { + return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D6.R4' }; + } + + if (yesCount >= 2) { + return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D6.R5' }; + } + } + + return { judgement: null, isComplete: false, ruleId: null }; +} + +/** + * Main entry point: score a ROBINS-I domain + */ +export function scoreRobinsDomain( + domainKey: string, + answers: DomainAnswers | undefined, + _options: { isPerProtocol?: boolean } = {}, +): ScoringResult { + if (!answers) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + switch (domainKey) { + case 'domain1a': + return scoreDomain1A(answers); + case 'domain1b': + return scoreDomain1B(answers); + case 'domain2': + return scoreDomain2(answers); + case 'domain3': + return scoreDomain3(answers); + case 'domain4': + return scoreDomain4(answers); + case 'domain5': + return scoreDomain5(answers); + case 'domain6': + return scoreDomain6(answers); + default: + return { judgement: null, isComplete: false, ruleId: null }; + } +} + +interface DomainState { + answers?: DomainAnswers; + judgement?: Judgement | null; + judgementSource?: 'auto' | 'manual'; + direction?: string | null; +} + +/** + * Get effective domain judgement (respects manual override) + */ +export function getEffectiveDomainJudgement( + domainState: DomainState | undefined, + autoScore: ScoringResult, +): Judgement | null { + if (domainState?.judgementSource === 'manual' && domainState?.judgement) { + return domainState.judgement; + } + return autoScore?.judgement || null; +} + +interface ChecklistState { + sectionC?: { isPerProtocol?: boolean }; + [domainKey: string]: DomainState | unknown; +} + +interface DomainScoringInfo { + auto: ScoringResult; + effective: Judgement | null; + source: 'auto' | 'manual'; + isOverridden: boolean; +} + +interface AllDomainsResult { + domains: Record; + overall: Judgement | null; + isComplete: boolean; +} + +/** + * Score all active domains and return a summary + */ +export function scoreAllDomains(checklistState: ChecklistState | null): AllDomainsResult { + if (!checklistState) { + return { domains: {}, overall: null, isComplete: false }; + } + + const isPerProtocol = checklistState.sectionC?.isPerProtocol || false; + const activeDomainKeys = + isPerProtocol ? + ['domain1b', 'domain2', 'domain3', 'domain4', 'domain5', 'domain6'] + : ['domain1a', 'domain2', 'domain3', 'domain4', 'domain5', 'domain6']; + + const domains: Record = {}; + const effectiveJudgements: Judgement[] = []; + + for (const domainKey of activeDomainKeys) { + const domainState = checklistState[domainKey] as DomainState | undefined; + const auto = scoreRobinsDomain(domainKey, domainState?.answers, { isPerProtocol }); + const effective = getEffectiveDomainJudgement(domainState, auto); + const source = domainState?.judgementSource || 'auto'; + + domains[domainKey] = { + auto, + effective, + source, + isOverridden: source === 'manual' && effective !== auto.judgement, + }; + + if (effective) { + effectiveJudgements.push(effective); + } + } + + let overall: Judgement | null = null; + if (effectiveJudgements.length === activeDomainKeys.length) { + if (effectiveJudgements.includes(JUDGEMENTS.CRITICAL)) { + overall = JUDGEMENTS.CRITICAL; + } else if (effectiveJudgements.includes(JUDGEMENTS.SERIOUS)) { + overall = JUDGEMENTS.SERIOUS; + } else if (effectiveJudgements.includes(JUDGEMENTS.MODERATE)) { + overall = JUDGEMENTS.MODERATE; + } else { + overall = JUDGEMENTS.LOW; + } + } + + return { domains, overall, isComplete: effectiveJudgements.length === activeDomainKeys.length }; +} + +// Overall risk of bias display strings for UI +export const OVERALL_DISPLAY = { + LOW_EXCEPT_CONFOUNDING: 'Low risk of bias except for concerns about uncontrolled confounding', + MODERATE: 'Moderate risk', + SERIOUS: 'Serious risk', + CRITICAL: 'Critical risk', +} as const; + +/** + * Map internal overall judgement to the OVERALL_ROB_JUDGEMENTS display strings + */ +export function mapOverallJudgementToDisplay(judgement: Judgement | null): string | null { + switch (judgement) { + case JUDGEMENTS.LOW: + case JUDGEMENTS.LOW_EXCEPT_CONFOUNDING: + return OVERALL_DISPLAY.LOW_EXCEPT_CONFOUNDING; + case JUDGEMENTS.MODERATE: + return OVERALL_DISPLAY.MODERATE; + case JUDGEMENTS.SERIOUS: + return OVERALL_DISPLAY.SERIOUS; + case JUDGEMENTS.CRITICAL: + return OVERALL_DISPLAY.CRITICAL; + default: + return null; + } +} diff --git a/packages/shared/src/checklists/status.ts b/packages/shared/src/checklists/status.ts new file mode 100644 index 000000000..148789e43 --- /dev/null +++ b/packages/shared/src/checklists/status.ts @@ -0,0 +1,115 @@ +/** + * Checklist Status Constants and Helpers + * + * Centralized status management for checklists. All status values and related + * logic should use these constants and helper functions. + */ + +export const CHECKLIST_STATUS = { + PENDING: 'pending', + IN_PROGRESS: 'in-progress', + REVIEWER_COMPLETED: 'reviewer-completed', + RECONCILING: 'reconciling', + FINALIZED: 'finalized', +} as const; + +export type ChecklistStatus = (typeof CHECKLIST_STATUS)[keyof typeof CHECKLIST_STATUS]; + +/** + * Determines if a checklist can be edited based on its status + * @param status - The checklist status + * @returns True if the checklist can be edited + */ +export function isEditable(status: ChecklistStatus | string): boolean { + return ( + status !== CHECKLIST_STATUS.FINALIZED && + status !== CHECKLIST_STATUS.REVIEWER_COMPLETED && + status !== CHECKLIST_STATUS.RECONCILING + ); +} + +/** + * Gets a human-readable label for a status + * @param status - The checklist status + * @returns Human-readable label + */ +export function getStatusLabel(status: ChecklistStatus | string | undefined): string { + switch (status) { + case CHECKLIST_STATUS.PENDING: + return 'Pending'; + case CHECKLIST_STATUS.IN_PROGRESS: + return 'In Progress'; + case CHECKLIST_STATUS.REVIEWER_COMPLETED: + return 'Reviewer Completed'; + case CHECKLIST_STATUS.RECONCILING: + return 'Reconciling'; + case CHECKLIST_STATUS.FINALIZED: + return 'Finalized'; + default: + return status || 'Pending'; + } +} + +/** + * Validates if a status transition is allowed + * @param currentStatus - The current status + * @param newStatus - The desired new status + * @returns True if the transition is valid + */ +export function canTransitionTo( + currentStatus: ChecklistStatus | string, + newStatus: ChecklistStatus | string, +): boolean { + // Can always stay in the same state + if (currentStatus === newStatus) return true; + + // Can transition from pending to in-progress (automatic on first edit) + if (currentStatus === CHECKLIST_STATUS.PENDING && newStatus === CHECKLIST_STATUS.IN_PROGRESS) { + return true; + } + + // Can transition from in-progress to reviewer-completed or finalized + if (currentStatus === CHECKLIST_STATUS.IN_PROGRESS) { + return ( + newStatus === CHECKLIST_STATUS.REVIEWER_COMPLETED || newStatus === CHECKLIST_STATUS.FINALIZED + ); + } + + // Can transition from reconciling to finalized (after reconciliation is complete) + if (currentStatus === CHECKLIST_STATUS.RECONCILING && newStatus === CHECKLIST_STATUS.FINALIZED) { + return true; + } + + // Cannot transition from finalized or reviewer-completed to anything else (locked) + if ( + currentStatus === CHECKLIST_STATUS.FINALIZED || + currentStatus === CHECKLIST_STATUS.REVIEWER_COMPLETED + ) { + return false; + } + + return false; +} + +/** + * Gets Tailwind CSS classes for status badge styling. + * Note: This is UI-specific but kept here for convenience. + * Consider moving to web package if shared package should be pure logic. + * @param status - The checklist status + * @returns Tailwind classes for badge + */ +export function getStatusStyle(status: ChecklistStatus | string | undefined): string { + switch (status) { + case CHECKLIST_STATUS.FINALIZED: + return 'bg-green-100 text-green-800'; + case CHECKLIST_STATUS.IN_PROGRESS: + return 'bg-yellow-100 text-yellow-800'; + case CHECKLIST_STATUS.REVIEWER_COMPLETED: + return 'bg-blue-100 text-blue-800'; + case CHECKLIST_STATUS.RECONCILING: + return 'bg-purple-100 text-purple-800'; + case CHECKLIST_STATUS.PENDING: + default: + return 'bg-gray-100 text-gray-800'; + } +} diff --git a/packages/shared/src/checklists/types.ts b/packages/shared/src/checklists/types.ts new file mode 100644 index 000000000..b7816ebb8 --- /dev/null +++ b/packages/shared/src/checklists/types.ts @@ -0,0 +1,173 @@ +/** + * Shared TypeScript types for checklists + */ + +import type { ChecklistStatus } from './status.js'; + +/** + * Base checklist metadata shared by all checklist types + */ +export interface ChecklistMetadata { + id: string; + name: string; + reviewerName: string; + createdAt: string; + assignedTo?: string | null; + status?: ChecklistStatus; + type?: 'AMSTAR2' | 'ROBINS_I'; +} + +/** + * AMSTAR2 question answer structure + */ +export interface AMSTAR2QuestionAnswer { + answers: boolean[][]; + critical: boolean; +} + +/** + * Alias for AMSTAR2QuestionAnswer for internal use + */ +export type AMSTAR2Question = AMSTAR2QuestionAnswer; + +/** + * AMSTAR2 checklist structure + */ +export interface AMSTAR2Checklist extends ChecklistMetadata { + q1: AMSTAR2QuestionAnswer; + q2: AMSTAR2QuestionAnswer; + q3: AMSTAR2QuestionAnswer; + q4: AMSTAR2QuestionAnswer; + q5: AMSTAR2QuestionAnswer; + q6: AMSTAR2QuestionAnswer; + q7: AMSTAR2QuestionAnswer; + q8: AMSTAR2QuestionAnswer; + q9a: AMSTAR2QuestionAnswer; + q9b: AMSTAR2QuestionAnswer; + q10: AMSTAR2QuestionAnswer; + q11a: AMSTAR2QuestionAnswer; + q11b: AMSTAR2QuestionAnswer; + q12: AMSTAR2QuestionAnswer; + q13: AMSTAR2QuestionAnswer; + q14: AMSTAR2QuestionAnswer; + q15: AMSTAR2QuestionAnswer; + q16: AMSTAR2QuestionAnswer; + sourceChecklists?: string[]; +} + +/** + * AMSTAR2 scoring result + */ +export type AMSTAR2Score = 'High' | 'Moderate' | 'Low' | 'Critically Low' | 'Error'; + +/** + * ROBINS-I response types + */ +export type ROBINSIResponse = + | 'Y' + | 'PY' + | 'PN' + | 'N' + | 'NI' + | 'NA' + | 'WN' + | 'SN' + | 'SY' + | 'WY' + | null; + +/** + * ROBINS-I question answer structure + */ +export interface ROBINSIQuestionAnswer { + answer: ROBINSIResponse; + comment: string; +} + +/** + * ROBINS-I domain state + */ +export interface ROBINSIDomainState { + answers: Record; + judgement: string | null; + judgementSource: 'auto' | 'manual'; + direction?: string | null; +} + +/** + * ROBINS-I Section B state + */ +export interface ROBINSISectionB { + b1: ROBINSIQuestionAnswer; + b2: ROBINSIQuestionAnswer; + b3: ROBINSIQuestionAnswer; + stopAssessment: boolean; +} + +/** + * ROBINS-I checklist structure + */ +export interface ROBINSIChecklist extends ChecklistMetadata { + checklistType: 'ROBINS_I'; + planning: { + confoundingFactors: string; + }; + sectionA: { + numericalResult: string; + furtherDetails: string; + outcome: string; + }; + sectionB: ROBINSISectionB; + sectionC: { + participants: string; + interventionStrategy: string; + comparatorStrategy: string; + isPerProtocol: boolean; + }; + sectionD: { + sources: Record; + otherSpecify: string; + }; + confoundingEvaluation: { + predefined: unknown[]; + additional: unknown[]; + }; + domain1a: ROBINSIDomainState; + domain1b: ROBINSIDomainState; + domain2: ROBINSIDomainState; + domain3: ROBINSIDomainState; + domain4: ROBINSIDomainState; + domain5: ROBINSIDomainState; + domain6: ROBINSIDomainState; + overall: { + judgement: string | null; + judgementSource: 'auto' | 'manual'; + direction: string | null; + }; +} + +/** + * Study object structure (for domain logic) + */ +export interface Study { + id: string; + projectId?: string; + name?: string; + reviewer1?: string | null; + reviewer2?: string | null; + checklists?: Array; + reconciliation?: { + checklist1Id?: string; + checklist2Id?: string; + reconciledChecklistId?: string | null; + }; +} + +/** + * ROBINS-I scoring result + */ +export interface ROBINSIDomainScore { + judgement: string | null; + isComplete: boolean; + ruleId: string | null; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 484dbd217..e80e93a70 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,8 +1,9 @@ /** * Main entry point for @corates/shared package - * Re-exports everything from errors, plans, and pdf modules + * Re-exports everything from errors, plans, pdf, and checklists modules */ export * from './errors/index.js'; export * from './plans/index.js'; export * from './pdf/index.js'; +export * from './checklists/index.js'; diff --git a/packages/web/src/Routes.jsx b/packages/web/src/Routes.jsx index 99b65980b..29fc13484 100644 --- a/packages/web/src/Routes.jsx +++ b/packages/web/src/Routes.jsx @@ -27,7 +27,16 @@ import { BASEPATH } from '@config/api.js'; import ProtectedGuard from '@/components/auth/ProtectedGuard.jsx'; import ProjectView from '@/components/project/ProjectView.jsx'; import { CreateOrgPage } from '@/components/org/index.js'; -import MockIndex from '@/components/mock/MockIndex.jsx'; +import MockIndex from '@/components/mocks/MockIndex.jsx'; + +// Code-split mock routes - loaded only when navigating to /mock/* +const ProjectViewEditorial = lazy(() => import('@/components/mocks/ProjectViewEditorial.jsx')); +const ProjectViewDashboard = lazy(() => import('@/components/mocks/ProjectViewDashboard.jsx')); +const ProjectViewKanban = lazy(() => import('@/components/mocks/ProjectViewKanban.jsx')); +const ProjectViewComplete = lazy(() => import('@/components/mocks/ProjectViewComplete.jsx')); +const AddStudiesWizard = lazy(() => import('@/components/mocks/AddStudiesWizard.jsx')); +const AddStudiesPanel = lazy(() => import('@/components/mocks/AddStudiesPanel.jsx')); +const AddStudiesInline = lazy(() => import('@/components/mocks/AddStudiesInline.jsx')); // Code-split admin routes - loaded only when navigating to /admin/* const AdminDashboard = lazy(() => @@ -116,7 +125,14 @@ export default function AppRoutes() { {/* Mock routes - public, visual-only wireframes */} - + + + + + + + + diff --git a/packages/web/src/components/checklist/AMSTAR2Checklist/checklist-compare.js b/packages/web/src/components/checklist/AMSTAR2Checklist/checklist-compare.js index 8b6346b83..fee7130cd 100644 --- a/packages/web/src/components/checklist/AMSTAR2Checklist/checklist-compare.js +++ b/packages/web/src/components/checklist/AMSTAR2Checklist/checklist-compare.js @@ -1,338 +1,28 @@ /** - * Checklist comparison utilities for reconciliation workflow - * Compares two reviewer checklists and helps create a finalized consensus version + * AMSTAR2 Checklist Comparison Utilities + * + * Re-exports comparison functions from @corates/shared for backward compatibility. + * All new code should import directly from @corates/shared. */ -import { AMSTAR_CHECKLIST } from './checklist-map.js'; +import { amstar2 } from '@corates/shared'; -/** - * Get all question keys for display in reconciliation. - * Returns keys as they appear in AMSTAR_CHECKLIST (q1-q16), but q9 and q11 - * are displayed as combined questions while their data is stored as q9a/q9b and q11a/q11b. - * @returns {string[]} Array of question keys for UI display - */ -export function getQuestionKeys() { - return Object.keys(AMSTAR_CHECKLIST); -} - -/** - * Get the actual data keys for a question. - * For q9 and q11, returns the a/b parts. For others, returns the key as-is. - * @param {string} questionKey - The question key (e.g., 'q9') - * @returns {string[]} Array of data keys - */ -export function getDataKeysForQuestion(questionKey) { - if (questionKey === 'q9') { - return ['q9a', 'q9b']; - } - if (questionKey === 'q11') { - return ['q11a', 'q11b']; - } - return [questionKey]; -} - -/** - * Check if a question has multiple parts (a/b) - * @param {string} questionKey - The question key - * @returns {boolean} - */ -export function isMultiPartQuestion(questionKey) { - return questionKey === 'q9' || questionKey === 'q11'; -} - -/** - * Compare the answers of two checklists and identify differences - * @param {Object} checklist1 - First reviewer's checklist - * @param {Object} checklist2 - Second reviewer's checklist - * @returns {Object} Comparison result with agreements, disagreements, and stats - */ -export function compareChecklists(checklist1, checklist2) { - if (!checklist1 || !checklist2) { - return { agreements: [], disagreements: [], stats: { total: 0, agreed: 0, disagreed: 0 } }; - } - - const questionKeys = getQuestionKeys(); - const agreements = []; - const disagreements = []; - - for (const key of questionKeys) { - // Handle multi-part questions (q9 and q11) - if (isMultiPartQuestion(key)) { - const dataKeys = getDataKeysForQuestion(key); - const q1Parts = dataKeys.map(dk => checklist1[dk]); - const q2Parts = dataKeys.map(dk => checklist2[dk]); - - // Skip if any part is missing - if (q1Parts.some(p => !p) || q2Parts.some(p => !p)) continue; - - const comparison = compareMultiPartQuestion(key, q1Parts, q2Parts, dataKeys); - - if (comparison.isAgreement) { - agreements.push({ key, ...comparison }); - } else { - disagreements.push({ key, ...comparison }); - } - } else { - const q1 = checklist1[key]; - const q2 = checklist2[key]; - - if (!q1 || !q2) continue; - - const comparison = compareQuestion(key, q1, q2); - - if (comparison.isAgreement) { - agreements.push({ key, ...comparison }); - } else { - disagreements.push({ key, ...comparison }); - } - } - } - - return { - agreements, - disagreements, - stats: { - total: agreements.length + disagreements.length, - agreed: agreements.length, - disagreed: disagreements.length, - agreementRate: agreements.length / (agreements.length + disagreements.length) || 0, - }, - }; -} - -/** - * Compare a multi-part question (q9 or q11) between two checklists - * @param {string} questionKey - The question key (e.g., 'q9') - * @param {Object[]} q1Parts - First reviewer's answer parts [q9a, q9b] or [q11a, q11b] - * @param {Object[]} q2Parts - Second reviewer's answer parts - * @param {string[]} dataKeys - The data keys ['q9a', 'q9b'] or ['q11a', 'q11b'] - * @returns {Object} Comparison result for this question - */ -export function compareMultiPartQuestion(questionKey, q1Parts, q2Parts, dataKeys) { - // Compare each part - let allPartsAgree = true; - const partComparisons = []; - - for (let i = 0; i < q1Parts.length; i++) { - const partComparison = compareQuestion(dataKeys[i], q1Parts[i], q2Parts[i]); - partComparisons.push(partComparison); - if (!partComparison.isAgreement) { - allPartsAgree = false; - } - } - - return { - isAgreement: allPartsAgree, - isMultiPart: true, - parts: dataKeys.map((dk, i) => ({ - key: dk, - ...partComparisons[i], - reviewer1Answer: q1Parts[i], - reviewer2Answer: q2Parts[i], - })), - // For compatibility, also include combined info - reviewer1Answer: q1Parts, - reviewer2Answer: q2Parts, - }; -} - -/** - * Compare a single question's answers between two checklists - * @param {string} questionKey - The question key (e.g., 'q1') - * @param {Object} q1 - First reviewer's answer object - * @param {Object} q2 - Second reviewer's answer object - * @returns {Object} Comparison result for this question - */ -export function compareQuestion(questionKey, q1, q2) { - const answers1 = q1.answers; - const answers2 = q2.answers; - - // Get the final answer (last column) for each - const finalAnswer1 = getFinalAnswer(answers1, questionKey); - const finalAnswer2 = getFinalAnswer(answers2, questionKey); - - // Check if all individual checkboxes match - const detailedMatch = answersMatch(answers1, answers2); - - // Check if final answers match (main agreement criterion) - const finalMatch = finalAnswer1 === finalAnswer2; +// Re-export comparison functions from shared package +export const compareChecklists = amstar2.compareChecklists; +export const compareMultiPartQuestion = amstar2.compareMultiPartQuestion; +export const compareQuestion = amstar2.compareQuestion; +export const createReconciledChecklist = amstar2.createReconciledChecklist; +export const getReconciliationSummary = amstar2.getReconciliationSummary; - // Check if critical assessment matches - const criticalMatch = q1.critical === q2.critical; +// Re-export answer helpers +export const getFinalAnswer = amstar2.getFinalAnswer; +export const answersMatch = amstar2.answersMatch; - return { - isAgreement: finalMatch && criticalMatch, - finalMatch, - criticalMatch, - detailedMatch, - reviewer1: { - answers: answers1, - finalAnswer: finalAnswer1, - critical: q1.critical, - }, - reviewer2: { - answers: answers2, - finalAnswer: finalAnswer2, - critical: q2.critical, - }, - }; -} +// Re-export question key helpers +export const getQuestionKeys = amstar2.getQuestionKeys; +export const getDataKeysForQuestion = amstar2.getDataKeysForQuestion; +export const isMultiPartQuestion = amstar2.isMultiPartQuestion; -/** - * Get the final answer (Yes/No/Partial Yes/No MA) from the last column - * @param {Array} answers - 2D array of answers - * @param {string} questionKey - The question key - * @returns {string|null} The selected final answer or null - */ -export function getFinalAnswer(answers, questionKey) { - if (!Array.isArray(answers) || answers.length === 0) return null; - - const lastCol = answers[answers.length - 1]; - if (!Array.isArray(lastCol)) return null; - - const idx = lastCol.findIndex(v => v === true); - if (idx === -1) return null; - - // Determine the label based on question type and column length - const customPatternQuestions = ['q11a', 'q11b', 'q12', 'q15']; - const customLabels = ['Yes', 'No', 'No MA']; - const defaultLabels = ['Yes', 'Partial Yes', 'No', 'No MA']; - - if (customPatternQuestions.includes(questionKey)) { - return customLabels[idx] || null; - } - if (lastCol.length === 2) { - return idx === 0 ? 'Yes' : 'No'; - } - if (lastCol.length >= 3) { - return defaultLabels[idx] || null; - } - return null; -} - -/** - * Check if two answer arrays are identical - * @param {Array} answers1 - First 2D array of answers - * @param {Array} answers2 - Second 2D array of answers - * @returns {boolean} True if all answers match - */ -export function answersMatch(answers1, answers2) { - if (!Array.isArray(answers1) || !Array.isArray(answers2)) return false; - if (answers1.length !== answers2.length) return false; - - for (let i = 0; i < answers1.length; i++) { - if (!Array.isArray(answers1[i]) || !Array.isArray(answers2[i])) return false; - if (answers1[i].length !== answers2[i].length) return false; - - for (let j = 0; j < answers1[i].length; j++) { - if (answers1[i][j] !== answers2[i][j]) return false; - } - } - - return true; -} - -/** - * Create a merged/reconciled checklist from two source checklists - * @param {Object} checklist1 - First reviewer's checklist - * @param {Object} checklist2 - Second reviewer's checklist - * @param {Object} selections - Object mapping question keys to 'reviewer1' | 'reviewer2' | custom - * @param {Object} metadata - Metadata for the reconciled checklist - * @returns {Object} The reconciled checklist - */ -export function createReconciledChecklist(checklist1, checklist2, selections, metadata = {}) { - const questionKeys = getQuestionKeys(); - - const reconciled = { - name: metadata.name || 'Reconciled Checklist', - reviewerName: metadata.reviewerName || 'Consensus', - createdAt: metadata.createdAt || new Date().toISOString().split('T')[0], - id: metadata.id || `reconciled-${Date.now()}`, - sourceChecklists: [checklist1.id, checklist2.id], - }; - - for (const key of questionKeys) { - const selection = selections[key]; - const dataKeys = getDataKeysForQuestion(key); - - // Handle multi-part questions (q9 and q11) - if (isMultiPartQuestion(key)) { - for (const dataKey of dataKeys) { - if (!selection || selection === 'reviewer1') { - reconciled[dataKey] = JSON.parse(JSON.stringify(checklist1[dataKey])); - } else if (selection === 'reviewer2') { - reconciled[dataKey] = JSON.parse(JSON.stringify(checklist2[dataKey])); - } else if (typeof selection === 'object' && selection[dataKey]) { - reconciled[dataKey] = JSON.parse(JSON.stringify(selection[dataKey])); - } - } - } else { - if (!selection || selection === 'reviewer1') { - // Default to reviewer 1 if no selection - reconciled[key] = JSON.parse(JSON.stringify(checklist1[key])); - } else if (selection === 'reviewer2') { - reconciled[key] = JSON.parse(JSON.stringify(checklist2[key])); - } else if (typeof selection === 'object') { - // Custom merged answer - reconciled[key] = JSON.parse(JSON.stringify(selection)); - } - } - } - - return reconciled; -} - -/** - * Get a summary of what needs reconciliation - * @param {Object} comparison - Result from compareChecklists - * @returns {Object} Summary with counts and lists - */ -export function getReconciliationSummary(comparison) { - const { disagreements, stats } = comparison; - - const criticalDisagreements = disagreements.filter(d => { - // Handle multi-part questions - if (d.isMultiPart && d.parts) { - return d.parts.some(part => part.reviewer1?.critical || part.reviewer2?.critical); - } - // Check if either reviewer marked as critical or it's a critical question - return d.reviewer1?.critical || d.reviewer2?.critical; - }); - - const nonCriticalDisagreements = disagreements.filter(d => { - // Handle multi-part questions - if (d.isMultiPart && d.parts) { - return !d.parts.some(part => part.reviewer1?.critical || part.reviewer2?.critical); - } - return !d.reviewer1?.critical && !d.reviewer2?.critical; - }); - - return { - totalQuestions: stats.total, - agreementCount: stats.agreed, - disagreementCount: stats.disagreed, - agreementPercentage: Math.round(stats.agreementRate * 100), - criticalDisagreements: criticalDisagreements.length, - nonCriticalDisagreements: nonCriticalDisagreements.length, - needsReconciliation: disagreements.length > 0, - disagreementsByQuestion: disagreements.map(d => d.key), - }; -} - -/** - * Get readable question text from question key - * @param {string} questionKey - The question key (e.g., 'q1') - * @returns {string} The question text - */ -export function getQuestionText(questionKey) { - return AMSTAR_CHECKLIST[questionKey]?.text || questionKey; -} - -/** - * Get the question definition from checklist map - * @param {string} questionKey - The question key - * @returns {Object} The question definition - */ -export function getQuestionDef(questionKey) { - return AMSTAR_CHECKLIST[questionKey]; -} +// Question text helpers (also available in shared) +export const getQuestionText = amstar2.getQuestionText; +export const getQuestionDef = amstar2.getQuestionDef; diff --git a/packages/web/src/components/checklist/AMSTAR2Checklist/checklist-map.js b/packages/web/src/components/checklist/AMSTAR2Checklist/checklist-map.js index cb6bcd9ce..8f2a288fb 100644 --- a/packages/web/src/components/checklist/AMSTAR2Checklist/checklist-map.js +++ b/packages/web/src/components/checklist/AMSTAR2Checklist/checklist-map.js @@ -1,370 +1,25 @@ -// Map the checklist state to actual checklist data for plotting and import/export +/** + * AMSTAR2 Checklist Schema/Map + * + * Re-exports the checklist schema from @corates/shared for backward compatibility. + * All new code should import directly from @corates/shared. + */ -// Available checklist types -export const CHECKLIST_TYPES = { - AMSTAR2: { - name: 'AMSTAR 2', - description: 'A MeaSurement Tool to Assess systematic Reviews (version 2)', - }, -}; +import { amstar2 } from '@corates/shared'; + +// Re-export from shared package +export const AMSTAR_CHECKLIST = amstar2.AMSTAR_CHECKLIST; +export const CHECKLIST_TYPES = amstar2.AMSTAR2_CHECKLIST_TYPES; + +// Question keys for iteration +export const AMSTAR2_QUESTION_KEYS = amstar2.AMSTAR2_QUESTION_KEYS; +export const AMSTAR2_DATA_KEYS = amstar2.AMSTAR2_DATA_KEYS; +export const AMSTAR2_CRITICAL_QUESTIONS = amstar2.AMSTAR2_CRITICAL_QUESTIONS; -export const AMSTAR_CHECKLIST = { - q1: { - info: 'To score Yes, appraisers should be confident that the 4 elements of PICO (population, intervention, control group and outcome) are described somewhere in the report.', - text: '1. Did the research questions and inclusion criteria for the review include the components of PICO?', - columns: [ - { - label: 'For Yes:', - options: ['Population', 'Intervention', 'Comparator group', 'Outcome'], - }, - { - label: 'Optional (recommended):', - options: ['Timeframe for follow-up'], - }, - { - label: '', - options: ['Yes', 'No'], - }, - ], - }, - q2: { - info: 'The research questions and the review study methods should have been planned ahead of conducting the review. At a minimum this should be stated in the report (scores Partial Yes). To score Yes authors should demonstrate that they worked with a written protocol with independent verification (by a registry, publication of the protocol, or another independent body, e.g. research ethics board or research office) before the review was undertaken.', - text: '2. Did the report of the review contain an explicit statement that the review methods were established prior to the conduct of the review and did the report justify any significant deviations from the protocol?', - columns: [ - { - label: 'For Partial Yes:', - description: - 'The authors state that they had a written protocol or guide that included ALL the following:', - options: [ - 'review question(s)', - 'a search strategy', - 'inclusion/exclusion criteria', - 'risk of bias assessment', - ], - }, - { - label: 'For Yes:', - description: - 'As for Partial Yes, plus the protocol should be registered and should also have specified:', - options: [ - 'a meta-analysis/synthesis plan, if appropriate, and', - 'a plan for investigating causes of heterogeneity', - 'justification for any derivations from the protocol', - ], - }, - { - label: '', - options: ['Yes', 'Partial Yes', 'No'], - }, - ], - }, - q3: { - info: 'Review authors should justify their choice of study designs. A Yes rating requires evidence that the selection was intentional (e.g., why RCTs alone were sufficient or why nonrandomized studies were needed to capture outcomes or harms), rather than arbitrary.', - text: '3. Did the review authors explain their selection of the study designs for inclusion in the review?', - columns: [ - { - label: 'For Yes, the review should satisfy ONE of the following:', - options: [ - 'Explanation for including only RCTs ', - 'OR Explanation for including only NRSI', - 'OR Explanation for including both RCTs and NRSI', - ], - }, - { - label: '', - options: ['Yes', 'No'], - }, - ], - }, - q4: { - info: 'To score Yes, appraisers should be satisfied that all relevant aspects of the search have been addressed by review authors.', - text: '4. Did the review authors use a comprehensive literature search strategy? ', - columns: [ - { - label: 'For Partial Yes (all the following):', - options: [ - 'searched at least 2 databases (relevant to research question)', - 'provided key word and/or search strategy', - 'justified publication restrictions (e.g. language)', - ], - }, - { - label: 'For Yes, should also have (all the following):', - options: [ - 'searched the reference lists / bibliographies of included studies', - 'searched trial/study registries', - 'included/consulted content experts in the field', - 'where relevant, searched for grey literature', - 'conducted search within 24 months of completion of the review', - ], - }, - { - label: '', - options: ['Yes', 'Partial Yes', 'No'], - }, - ], - }, - q5: { - info: 'A Yes rating requires that study selection was conducted by at least two independent reviewers, with a clear consensus process for resolving disagreements. If one reviewer screened all studies, a second reviewer must have checked a representative sample and demonstrated strong agreement (e.g., κ ≥ 0.80).', - text: '5. Did the review authors perform study selection in duplicate?', - columns: [ - { - label: 'For Yes, either ONE of the following:', - options: [ - 'at least two reviewers independently agreed on selection of eligible studies and achieved consensus on which studies to include', - 'OR two reviewers extracted data from a sample of eligible studies and achieved good agreement (at least 80 percent), with the remainder extracted by one reviewer.', - ], - }, - { - label: '', - options: ['Yes', 'No'], - }, - ], - }, - q6: { - info: 'A Yes rating requires that data extraction was performed by at least two independent reviewers, with a consensus process for resolving disagreements. If one reviewer extracted all data, a second reviewer must have checked a sample and demonstrated strong agreement (e.g., κ ≥ 0.80).', - text: '6. Did the review authors perform data extraction in duplicate?', - columns: [ - { - label: 'For Yes, either ONE of the following:', - options: [ - 'at least two reviewers achieved consensus on which data to extract from included studies', - 'OR two reviewers extracted data from a sample of eligible studies and achieved good agreement (at least 80 percent), with the remainder extracted by one reviewer.', - ], - }, - { - label: '', - options: ['Yes', 'No'], - }, - ], - }, - q7: { - info: 'A Yes rating requires a list of excluded studies with a clear justification for each exclusion. Exclusions should be based on eligibility criteria (e.g., population, intervention, comparator, outcomes), not risk of bias, which is assessed separately.', - text: '7. Did the review authors provide a list of excluded studies and justify the exclusions?', - columns: [ - { - label: 'For Partial Yes:', - options: [ - 'provided a list of all potentially relevant studies that were read in full-text form but excluded from the review', - ], - }, - { - label: 'For Yes, must also have:', - options: ['Justified the exclusion from the review of each potentially relevant study'], - }, - { - label: '', - options: ['Yes', 'Partial Yes', 'No'], - }, - ], - }, - q8: { - info: 'A Yes rating requires sufficiently detailed descriptions of the included studies (e.g., population, intervention, comparator, outcomes, design, and setting) to allow readers to judge PICO relevance, applicability to practice or policy, and sources of heterogeneity.', - text: '8. Did the review authors describe the included studies in adequate detail?', - columns: [ - { - label: 'For Partial Yes (ALL the following):', - options: [ - 'described populations', - 'described interventions', - 'described comparators', - 'described outcomes', - 'described research designs', - ], - }, - { - label: 'For Yes, should also have ALL the following:', - options: [ - 'described population in detail', - 'described intervention in detail (including doses where relevant)', - 'described comparator in detail (including doses where relevant)', - 'described study’s setting', - 'timeframe for follow-up', - ], - }, - { - label: '', - options: ['Yes', 'Partial Yes', 'No'], - }, - ], - }, - q9: { - info: 'A Yes rating requires that review authors conducted a systematic, design-appropriate assessment of risk of bias for included studies, using a recognized or clearly justified tool that addresses key sources of bias relevant to the study designs.', - text: '9. Did the review authors use a satisfactory technique for assessing the risk of bias (RoB) in individual studies that were included in the review?', - subtitle: 'RCTs', - columns: [ - { - label: 'For Partial Yes, must have assessed RoB from', - options: [ - 'unconcealed allocation, and', - 'lack of blinding of patients and assessors when assessing outcomes (unnecessary for objective outcomes such as all-cause mortality)', - ], - }, - { - label: 'For Yes, must also have assessed RoB from:', - options: [ - 'allocation sequence that was not truly random, and', - 'selection of the reported result from among multiple measurements or analyses of a specified outcome', - ], - }, - { - label: '', - options: ['Yes', 'Partial Yes', 'No', ' Includes only NRSI'], - }, - ], - subtitle2: 'NRSI', - columns2: [ - { - label: 'For Partial Yes, must have assessed RoB:', - options: ['from confounding, and', 'from selection bias'], - }, - { - label: 'For Yes, must also have assessed RoB:', - options: [ - 'methods used to ascertain exposures and outcomes, and', - 'selection of the reported result from among multiple measurements or analyses of a specified outcome', - ], - }, - { - label: '', - options: ['Yes', 'Partial Yes', 'No', 'Includes only RCTs'], - }, - ], - }, - q10: { - info: 'A Yes rating requires that review authors reported the funding sources for the included studies, or clearly stated when funding information was not available.', - text: '10. Did the review authors report on the sources of funding for the studies included in the review?', - columns: [ - { - label: 'For Yes:', - options: [ - 'Must have reported on the sources of funding for individual studies included in the review. Note: Reporting that the reviewers looked for this information but it was not reported by study authors also qualifies', - ], - }, - { - label: '', - options: ['Yes', 'No'], - }, - ], - }, - q11: { - info: 'A Yes rating requires that meta-analysis was clearly justified and conducted using appropriate statistical methods, including suitable effect models, assessment of heterogeneity, and, when both RCTs and nonrandomized studies are included, separate pooling by study design or clear justification for combined analyses.', - text: '11. If meta-analysis was performed did the review authors use appropriate methods for statistical combination of results?', - subtitle: 'RCTs', - columns: [ - { - label: 'For Yes:', - options: [ - 'The authors justified combining the data in a meta-analysis', - 'AND they used an appropriate weighted technique to combine study results and adjusted for heterogeneity if present.', - 'AND investigated the causes of any heterogeneity', - ], - }, - { - label: '', - options: ['Yes', 'No', 'No meta-analysis conducted'], - }, - ], - subtitle2: 'NRSI', - columns2: [ - { - label: 'For Yes:', - options: [ - 'The authors justified combining the data in a meta-analysis', - 'AND they used an appropriate weighted technique to combine study results, adjusting for heterogeneity if present', - 'AND they statistically combined effect estimates from NRSI that were adjusted for confounding, rather than combining raw data, or justified combining raw data when adjusted effect estimates were not available', - 'AND they reported separate summary estimates for RCTs and NRSI separately when both were included in the review', - ], - }, - { - label: '', - options: ['Yes', 'No', 'No meta-analysis conducted'], - }, - ], - }, - q12: { - info: 'A Yes rating requires that review authors examined how risk of bias in included studies may affect the synthesis results, such as through sensitivity analyses, subgroup analyses, or narrative discussion when meta-analysis was not performed.', - text: '12. If meta-analysis was performed, did the review authors assess the potential impact of RoB in individual studies on the results of the meta-analysis or other evidence synthesis?', - columns: [ - { - label: 'For Yes:', - options: [ - 'included only low risk of bias RCTs', - 'OR, if the pooled estimate was based on RCTs and/or NRSI at variable RoB, the authors performed analyses to investigate possible impact of RoB on summary estimates of effect.', - ], - }, - { - label: '', - options: ['Yes', 'No', 'No meta-analysis conducted'], - }, - ], - }, - q13: { - info: 'A Yes rating requires explicit discussion of how risk of bias may influence the review’s results/conclusions or the authors included only low risk of bias RCTs.', - text: '13. Did the review authors account for RoB in individual studies when interpreting/ discussing the results of the review?', - columns: [ - { - label: 'For Yes:', - options: [ - 'included only low risk of bias RCTs', - 'OR, if RCTs with moderate or high RoB, or NRSI were included the review provided a discussion of the likely impact of RoB on the results', - ], - }, - { - label: '', - options: ['Yes', 'No'], - }, - ], - }, - q14: { - info: 'A Yes rating requires that review authors examined and discussed sources of heterogeneity, such as differences in populations, interventions, outcomes, study design, or risk of bias, and considered how heterogeneity affects the interpretation of results and conclusions.', - text: '14. Did the review authors provide a satisfactory explanation for, and discussion of, any heterogeneity observed in the results of the review?', - columns: [ - { - label: 'For Yes:', - options: [ - 'There was no significant heterogeneity in the results', - 'OR if heterogeneity was present the authors performed an investigation of sources of any heterogeneity in the results and discussed the impact of this on the results of the review', - ], - }, - { - label: '', - options: ['Yes', 'No'], - }, - ], - }, - q15: { - info: 'A Yes rating requires that review authors investigated potential publication (small-study) bias using appropriate methods (e.g., funnel plots, statistical tests, sensitivity analyses) and discussed how publication bias may affect the results, recognizing the limitations of these approaches.', - text: '15. If they performed quantitative synthesis did the review authors carry out an adequate investigation of publication bias (small study bias) and discuss its likely impact on the results of the review?', - columns: [ - { - label: 'For Yes:', - options: [ - 'performed graphical or statistical tests for publication bias and discussed the likelihood and magnitude of impact of publication bias', - ], - }, - { - label: '', - options: ['Yes', 'No', 'No meta-analysis conducted'], - }, - ], - }, - q16: { - info: 'A Yes rating requires that the review authors reported potential conflicts of interest related to the conduct of the review, including funding sources for the review itself and any relevant financial or professional ties, or clearly stated that no conflicts were identified.', - text: '16. Did the review authors report any potential sources of conflict of interest, including any funding they received for conducting the review?', - options: ['Yes', 'Partial Yes', 'No'], - columns: [ - { - label: 'For Yes:', - options: [ - 'The authors reported no competing interests OR', - 'The authors described their funding sources and how they managed potential conflicts of interest', - ], - }, - { - label: '', - options: ['Yes', 'No'], - }, - ], - }, +// Response type mappings +export const RESPONSE_TYPES = { + TWO_OPTION: amstar2.TWO_OPTION_QUESTIONS, + THREE_OPTION: amstar2.THREE_OPTION_QUESTIONS, + THREE_OPTION_NO_MA: amstar2.THREE_OPTION_NO_MA_QUESTIONS, + FOUR_OPTION: amstar2.FOUR_OPTION_QUESTIONS, }; diff --git a/packages/web/src/components/checklist/AMSTAR2Checklist/checklist.js b/packages/web/src/components/checklist/AMSTAR2Checklist/checklist.js index a2f648c26..769a6fba9 100644 --- a/packages/web/src/components/checklist/AMSTAR2Checklist/checklist.js +++ b/packages/web/src/components/checklist/AMSTAR2Checklist/checklist.js @@ -1,307 +1,25 @@ -import { AMSTAR_CHECKLIST } from './checklist-map.js'; - -/** - * Creates a new AMSTAR2 checklist object with default empty answers for all questions. - * - * @param {Object} options - Checklist properties. - * @param {string} options.name - The checklist name (required). - * @param {string} options.id - Unique checklist ID (required). - * @param {number} [options.createdAt=Date.now()] - Timestamp of checklist creation. - * @param {string} [options.reviewerName=''] - Name of the reviewer. - * - * @returns {Object} A checklist object with all AMSTAR2 questions initialized to default answers. - * - * @throws {Error} If `id` or `name` is missing or not a non-empty string. - * - * Example: - * createChecklist({ name: 'My Checklist', id: 'chk-123', reviewerName: 'Alice' }); - */ -export function createChecklist({ - name = null, - id = null, - createdAt = Date.now(), - reviewerName = '', -}) { - if (!id || typeof id !== 'string' || !id.trim()) { - throw new Error('AMSTAR2Checklist requires a non-empty string id.'); - } - if (!name || typeof name !== 'string' || !name.trim()) { - throw new Error('AMSTAR2Checklist requires a non-empty string name.'); - } - - let d = new Date(createdAt); - if (isNaN(d)) d = Date.now(); - // Pad month and day - const mm = String(d.getMonth() + 1).padStart(2, '0'); - const dd = String(d.getDate()).padStart(2, '0'); - createdAt = `${d.getFullYear()}-${mm}-${dd}`; - - return { - name: name, - reviewerName: reviewerName || '', - createdAt: createdAt, - id: id, - q1: { answers: [[false, false, false, false], [false], [false, false]], critical: false }, - q2: { - answers: [ - [false, false, false, false], - [false, false, false], - [false, false, false], - ], - critical: true, - }, - q3: { - answers: [ - [false, false, false], - [false, false], - ], - critical: false, - }, - q4: { - answers: [ - [false, false, false], - [false, false, false, false, false], - [false, false, false], - ], - critical: true, - }, - q5: { - answers: [ - [false, false], - [false, false], - ], - critical: false, - }, - q6: { - answers: [ - [false, false], - [false, false], - ], - critical: false, - }, - q7: { answers: [[false], [false], [false, false, false]], critical: true }, - q8: { - answers: [ - [false, false, false, false, false], - [false, false, false, false, false], - [false, false, false], - ], - critical: false, - }, - q9a: { - answers: [ - [false, false], - [false, false], - [false, false, false, false], - ], - critical: true, - }, - q9b: { - answers: [ - [false, false], - [false, false], - [false, false, false, false], - ], - critical: true, - }, - q10: { answers: [[false], [false, false]], critical: false }, - q11a: { - answers: [ - [false, false, false], - [false, false, false], - ], - critical: true, - }, - q11b: { - answers: [ - [false, false, false, false], - [false, false, false], - ], - critical: true, - }, - q12: { - answers: [ - [false, false], - [false, false, false], - ], - critical: false, - }, - q13: { - answers: [ - [false, false], - [false, false], - ], - critical: true, - }, - q14: { - answers: [ - [false, false], - [false, false], - ], - critical: false, - }, - q15: { answers: [[false], [false, false, false]], critical: true }, - q16: { - answers: [ - [false, false], - [false, false], - ], - critical: false, - }, - }; -} - -// Score checklist using the last column of each question taking into account critical vs non-critical -export function scoreChecklist(state) { - if (!state || typeof state !== 'object') return 'Error'; - - let criticalFlaws = 0; - let nonCriticalFlaws = 0; - - // Partial yes is scored same as yes - // No MA is not counted as a flaw - state = consolidateAnswers(state); - - Object.entries(state).forEach(([question, obj]) => { - if (!/^q\d+[a-z]*$/i.test(question)) return; - if (!obj || !Array.isArray(obj.answers)) return; - const selected = getSelectedAnswer(obj.answers, question); - if (!selected || selected === 'No') { - if (obj.critical) { - criticalFlaws++; - } else { - nonCriticalFlaws++; - } - } - }); - - if (criticalFlaws > 1) return 'Critically Low'; - if (criticalFlaws === 1) return 'Low'; - if (nonCriticalFlaws > 1) return 'Moderate'; - return 'High'; -} - -// Helper to get the selected answer from the last column of a question -function getSelectedAnswer(answers, question) { - // Question patterns - const customPatternQuestions = ['q11a', 'q11b', 'q12', 'q15']; - const customLabels = ['Yes', 'No', 'No MA']; - const defaultLabels = ['Yes', 'Partial Yes', 'No', 'No MA']; - - if (!Array.isArray(answers) || answers.length === 0) return null; - const lastCol = answers[answers.length - 1]; - if (!Array.isArray(lastCol)) return null; - const idx = lastCol.findIndex(v => v === true); - if (idx === -1) return null; - if (customPatternQuestions.includes(question)) return customLabels[idx] || null; - if (lastCol.length === 2) return idx === 0 ? 'Yes' : 'No'; - if (lastCol.length >= 3) return defaultLabels[idx] || null; - return null; -} - /** - * Check if an AMSTAR2 checklist is complete (all questions have final answers). - * A question has a final answer if the last column has at least one option selected. + * AMSTAR2 Checklist Logic * - * @param {Object} checklist - The checklist object to validate - * @returns {boolean} True if all questions have final answers, false otherwise + * This file re-exports checklist logic from @corates/shared while maintaining + * backward compatibility with existing imports. UI-specific exports (like CSV) + * are kept here since they're only used in the frontend. */ -export function isAMSTAR2Complete(checklist) { - if (!checklist || typeof checklist !== 'object') return false; - - // All required AMSTAR2 questions - const requiredQuestions = [ - 'q1', - 'q2', - 'q3', - 'q4', - 'q5', - 'q6', - 'q7', - 'q8', - 'q9a', - 'q9b', - 'q10', - 'q11a', - 'q11b', - 'q12', - 'q13', - 'q14', - 'q15', - 'q16', - ]; - - // Check each required question has a final answer - for (const questionKey of requiredQuestions) { - const question = checklist[questionKey]; - if (!question || !Array.isArray(question.answers)) return false; - - // Check if the last column has at least one option selected - const lastCol = question.answers[question.answers.length - 1]; - if (!Array.isArray(lastCol)) return false; - const hasAnswer = lastCol.some(v => v === true); - if (!hasAnswer) return false; - } - - return true; -} - -export function getAnswers(checklist) { - if (!checklist || typeof checklist !== 'object') return null; - const result = {}; - checklist = consolidateAnswers(checklist); - Object.entries(checklist).forEach(([key, value]) => { - if (!/^q\d+[a-z]*$/i.test(key)) return; - if (!value || !Array.isArray(value.answers)) return; - - const selected = getSelectedAnswer(value.answers, key); - result[key] = selected; - }); - - return result; -} - -function consolidateAnswers(prevChecklist) { - const checklist = { ...prevChecklist }; - - // Consolidate q9a and q9b into q9 by taking the lower score - const q9a = getSelectedAnswer(checklist.q9a.answers, 'q9a'); - const q9b = getSelectedAnswer(checklist.q9b.answers, 'q9b'); - if (q9a === null || q9b === null) { - checklist.q9 = checklist.q9a; - } else if (q9a === 'No' || q9b === 'No') { - checklist.q9 = q9a === 'No' ? checklist.q9a : checklist.q9b; - } else if (q9a === 'No MA' && q9b === 'No MA') { - checklist.q9 = checklist.q9a; - } else { - // Both are Yes or Partial Yes - checklist.q9 = checklist.q9a; - } - delete checklist.q9a; - delete checklist.q9b; - - // Consolidate q11a and q11b into q11 by taking the lower score - const q11a = getSelectedAnswer(checklist.q11a.answers, 'q11a'); - const q11b = getSelectedAnswer(checklist.q11b.answers, 'q11b'); - if (q11a === null || q11b === null) { - checklist.q11 = checklist.q11a; - } else if (q11a === 'No' || q11b === 'No') { - checklist.q11 = q11a === 'No' ? checklist.q11a : checklist.q11b; - } else if (q11a === 'No MA' && q11b === 'No MA') { - checklist.q11 = checklist.q11a; - } else { - // Both are Yes or Partial Yes - checklist.q11 = checklist.q11a; - } - delete checklist.q11a; - delete checklist.q11b; +import { amstar2 } from '@corates/shared'; +import { AMSTAR_CHECKLIST } from './checklist-map.js'; - return checklist; -} +// Re-export functions from shared package with original names +export const createChecklist = amstar2.createAMSTAR2Checklist; +export const scoreChecklist = amstar2.scoreAMSTAR2Checklist; +export const isAMSTAR2Complete = amstar2.isAMSTAR2Complete; +export const getAnswers = amstar2.getAnswers; +export const consolidateAnswers = amstar2.consolidateAnswers; +export const getSelectedAnswer = amstar2.getSelectedAnswer; /** * Export a checklist (or array of checklists) to CSV using the checklist map for headers. + * Note: This is UI-specific and stays in the web package. * @param {Array|Object} checklists - One or more checklist objects. * @returns {string} CSV string. */ @@ -313,8 +31,6 @@ export function exportChecklistsToCSV(checklists) { const headers = [ 'Checklist Name', 'Reviewer', - // 'Created At', - // 'Checklist ID', 'Question', 'Question Text', 'Column Label', @@ -343,8 +59,6 @@ export function exportChecklistsToCSV(checklists) { rows.push([ cl.name || '', cl.reviewerName || '', - // cl.createdAt ? new Date(cl.createdAt).toISOString() : '', - // cl.id || '', critical, q, questionText, diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/checklist-map.js b/packages/web/src/components/checklist/ROBINSIChecklist/checklist-map.js index 73b0770fd..937cd5141 100644 --- a/packages/web/src/components/checklist/ROBINSIChecklist/checklist-map.js +++ b/packages/web/src/components/checklist/ROBINSIChecklist/checklist-map.js @@ -1,587 +1,45 @@ -// ROBINS-I V2 Checklist Map -// Risk Of Bias In Non-randomized Studies - of Interventions, Version 2 +/** + * ROBINS-I Checklist Schema/Map + * + * Re-exports the checklist schema from @corates/shared for backward compatibility. + * All new code should import directly from @corates/shared. + */ -import { JUDGEMENTS, OVERALL_DISPLAY } from './scoring/robins-scoring.js'; +import { robinsI } from '@corates/shared'; -// Response option types used across different questions -export const RESPONSE_TYPES = { - YN: ['Y', 'N'], // Yes, No - STANDARD: ['Y', 'PY', 'PN', 'N'], // Yes, Probably Yes, Probably No, No - WITH_NI: ['Y', 'PY', 'PN', 'N', 'NI'], // With No Information - WITH_NA: ['NA', 'Y', 'PY', 'PN', 'NI'], // With Not Applicable (no N option) - WITH_NA_FULL: ['NA', 'Y', 'PY', 'PN', 'N', 'NI'], // NA + all standard options - WITH_NA_NO_NI: ['NA', 'Y', 'PY', 'PN', 'N'], // NA but no NI option - WEAK_STRONG_NO: ['Y', 'PY', 'WN', 'SN', 'NI'], // With Weak No, Strong No - WITH_NA_WEAK_STRONG_NO: ['NA', 'Y', 'PY', 'WN', 'SN', 'NI'], // NA + Weak/Strong No - WEAK_STRONG_YES: ['SY', 'WY', 'PN', 'N', 'NI'], // Strong Yes, Weak Yes - WITH_NA_WEAK_STRONG_YES: ['NA', 'SY', 'WY', 'PN', 'N', 'NI'], // NA + Weak/Strong Yes -}; +// Re-export schema and constants from shared package +export const ROBINS_I_CHECKLIST = robinsI.ROBINS_I_CHECKLIST; -// Human-readable labels for response options -export const RESPONSE_LABELS = { - NA: 'Not Applicable', - Y: 'Yes', - PY: 'Probably Yes', - PN: 'Probably No', - N: 'No', - NI: 'No Information', - WN: 'No, but not substantial', - SN: 'No, and probably substantial', - SY: 'Yes, substantially', - WY: 'Yes, but not substantially', -}; +// Response types +export const RESPONSE_TYPES = robinsI.RESPONSE_TYPES; +export const RESPONSE_LABELS = robinsI.RESPONSE_LABELS; -// Risk of bias judgement options - derived from JUDGEMENTS for consistency -export const ROB_JUDGEMENTS = [ - JUDGEMENTS.LOW, - JUDGEMENTS.LOW_EXCEPT_CONFOUNDING, - JUDGEMENTS.MODERATE, - JUDGEMENTS.SERIOUS, - JUDGEMENTS.CRITICAL, -]; - -// Overall ROB display strings - derived from OVERALL_DISPLAY for consistency -// Note: Plain 'Low' is not a valid overall judgement for ROBINS-I -export const OVERALL_ROB_JUDGEMENTS = [ - OVERALL_DISPLAY.LOW_EXCEPT_CONFOUNDING, - OVERALL_DISPLAY.MODERATE, - OVERALL_DISPLAY.SERIOUS, - OVERALL_DISPLAY.CRITICAL, -]; +// Judgement options +export const ROB_JUDGEMENTS = robinsI.ROB_JUDGEMENTS; +export const OVERALL_ROB_JUDGEMENTS = robinsI.OVERALL_ROB_JUDGEMENTS; // Bias direction options -export const BIAS_DIRECTIONS = [ - 'Upward bias (overestimate the effect)', - 'Downward bias (underestimate the effect)', - 'Favours intervention', - 'Favours comparator', - 'Towards null', - 'Away from null', - 'Unpredictable', -]; - -// Domain 1 specific directions (subset) -export const DOMAIN1_DIRECTIONS = [ - 'Upward bias (overestimate the effect)', - 'Downward bias (underestimate the effect)', - 'Unpredictable', -]; - -// Information sources for Section D -export const INFORMATION_SOURCES = [ - 'Journal article(s)', - 'Study protocol', - 'Statistical analysis plan (SAP)', - 'Non-commercial registry record (e.g. ClinicalTrials.gov record)', - 'Company-owned registry record (e.g. GSK Clinical Study Register record)', - 'Grey literature (e.g. unpublished thesis)', - 'Conference abstract(s)', - 'Regulatory document (e.g. Clinical Study Report, Drug Approval Package)', - 'Individual participant data', - 'Research ethics application', - 'Grant database summary (e.g. NIH RePORTER, Research Councils UK Gateway to Research)', - 'Personal communication with investigator', - 'Personal communication with sponsor', -]; - -// Section D: Information sources -export const SECTION_D = { - title: 'Part D: Information Sources', - description: - 'Which of the following sources have you obtained to help you inform your risk of bias judgements (tick as many as apply)?', - otherField: { - id: 'otherSpecify', - label: 'Please specify any additional sources not listed above', - placeholder: 'e.g., Additional data sources, correspondence, supplementary materials...', - type: 'textarea', - stateKey: 'otherSpecify', - }, -}; - -// Checklist type definition -export const CHECKLIST_TYPES = { - ROBINS_I: { - name: 'ROBINS-I V2', - description: 'Risk Of Bias In Non-randomized Studies – of Interventions (Version 2)', - }, -}; - -// Planning Stage: List confounding factors -export const PLANNING_SECTION = { - title: 'The ROBINS-I V2 Tool', - subtitle: 'At planning stage: list confounding factors', - p1: { - id: 'p1', - label: 'P1', - text: 'List the important confounding factors relevant to all or most studies on this topic. Specify whether these are particular to specific intervention-outcome combinations.', - placeholder: - 'e.g., Age, baseline disease severity, comorbidities, concomitant medications, socioeconomic status...', - type: 'textarea', - stateKey: 'confoundingFactors', - }, -}; - -// Section A: Specify the result being assessed for risk of bias -export const SECTION_A = { - a1: { - id: 'a1', - label: 'A1', - text: 'Specify the numerical result being assessed', - placeholder: 'e.g., OR = 1.5 (95% CI: 1.2-1.9)', - type: 'textarea', - stateKey: 'numericalResult', - }, - a2: { - id: 'a2', - label: 'A2', - text: 'Provide further details about this result (for example, location in the study report, reason it was chosen)', - optional: true, - placeholder: 'e.g., Table 3, primary outcome analysis', - type: 'textarea', - stateKey: 'furtherDetails', - }, - a3: { - id: 'a3', - label: 'A3', - text: 'Specify the outcome to which this result relates', - placeholder: 'e.g., All-cause mortality at 12 months', - type: 'textarea', - stateKey: 'outcome', - }, -}; - -// Section B: Decide whether to proceed with a risk-of-bias assessment -export const SECTION_B = { - b1: { - id: 'b1', - text: 'Did the authors make any attempt to control for confounding in the result being assessed?', - responseType: 'STANDARD', - }, - b2: { - id: 'b2', - text: 'If N/PN to B1: Is there sufficient potential for confounding that this result should not be considered further?', - responseType: 'STANDARD', - info: "If the answer to B2 is 'Yes' or 'Probably yes', the result should be considered to be at 'Critical risk of bias' and no further assessment is required.", - }, - b3: { - id: 'b3', - text: 'Was the method of measuring the outcome inappropriate?', - responseType: 'STANDARD', - info: "If the answer to B3 is 'Yes' or 'Probably yes', the result should be considered to be at 'Critical risk of bias' and no further assessment is required.", - }, -}; - -// Section C: Specify the (hypothetical) target randomized trial specific to the study -export const SECTION_C = { - description: - "The target randomized trial is either explicitly described by the primary study investigators or implied by the study's design and analysis.", - c1: { - id: 'c1', - label: 'C1', - text: 'Specify the participants and eligibility criteria', - placeholder: 'e.g., Adults aged 18+ with type 2 diabetes, no prior cardiovascular disease', - type: 'textarea', - stateKey: 'participants', - }, - c2: { - id: 'c2', - label: 'C2', - text: 'Specify the intervention strategy', - placeholder: 'e.g., Initiation of metformin 500mg twice daily', - type: 'textarea', - stateKey: 'interventionStrategy', - }, - c3: { - id: 'c3', - label: 'C3', - text: 'Specify the comparator strategy', - placeholder: 'e.g., Initiation of sulfonylurea therapy', - type: 'textarea', - stateKey: 'comparatorStrategy', - }, - c4: { - id: 'c4', - label: 'C4', - text: 'Did the analysis account for switches during follow-up between the intervention strategies being compared, or for other protocol deviations during follow-up?', - type: 'radio', - stateKey: 'isPerProtocol', - options: [ - { value: false, label: 'No (the analysis is estimating the intention-to-treat effect)' }, - { value: true, label: 'Yes (the analysis is estimating the per-protocol effect)' }, - ], - }, -}; - -// Domain 1A: Bias due to confounding (Intention-to-Treat Effect) -export const DOMAIN_1A = { - id: 'domain1a', - name: 'Domain 1: Bias due to confounding', - subtitle: - 'Variant A (the analysis is estimating the intention-to-treat effect so only baseline confounding needs to be addressed)', - questions: { - d1a_1: { - id: 'd1a_1', - number: '1.1', - text: 'Did the authors control for all the important confounding factors for which this was necessary?', - responseType: 'WEAK_STRONG_NO', - }, - d1a_2: { - id: 'd1a_2', - number: '1.2', - text: 'If Y/PY/WN to 1.1: Were confounding factors that were controlled for (and for which control was necessary) measured validly and reliably by the variables available in this study?', - responseType: 'WITH_NA_WEAK_STRONG_NO', - }, - d1a_3: { - id: 'd1a_3', - number: '1.3', - text: 'If Y/PY/WN to 1.1: Did the authors control for any post-intervention variables that could have been affected by the intervention?', - responseType: 'WITH_NA_FULL', - }, - d1a_4: { - id: 'd1a_4', - number: '1.4', - text: 'Did the use of negative controls, quantitative bias analysis, or other considerations, suggest serious uncontrolled confounding?', - responseType: 'WITH_NA', - }, - }, - hasDirection: true, - directionOptions: DOMAIN1_DIRECTIONS, -}; - -// Domain 1B: Bias due to confounding (Per-Protocol Effect) -export const DOMAIN_1B = { - id: 'domain1b', - name: 'Domain 1: Bias due to confounding', - subtitle: - 'Variant B (the analysis is estimating the per-protocol effect so both baseline and time-varying confounding need to be addressed)', - questions: { - d1b_1: { - id: 'd1b_1', - number: '1.1', - text: 'Did the authors use an analysis method that was appropriate to control for time-varying as well as baseline confounding?', - responseType: 'WITH_NI', - }, - d1b_2: { - id: 'd1b_2', - number: '1.2', - text: 'If Y/PY to 1.1: Did the authors control for all the important baseline and time-varying confounding factors for which this was necessary?', - responseType: 'WITH_NA_WEAK_STRONG_NO', - }, - d1b_3: { - id: 'd1b_3', - number: '1.3', - text: 'If Y/PY/WN to 1.2: Were confounding factors that were controlled for (and for which control was necessary) measured validly and reliably by the variables available in this study?', - responseType: 'WITH_NA_WEAK_STRONG_NO', - }, - d1b_4: { - id: 'd1b_4', - number: '1.4', - text: 'If N/PN/NI to 1.1: Did the authors control for time-varying factors or other variables measured after the start of intervention?', - responseType: 'WITH_NA_FULL', - }, - d1b_5: { - id: 'd1b_5', - number: '1.5', - text: 'Did the use of negative controls, or other considerations, suggest serious uncontrolled confounding?', - responseType: 'STANDARD', - }, - }, - hasDirection: true, - directionOptions: DOMAIN1_DIRECTIONS, -}; - -// Domain 2: Bias in classification of interventions -export const DOMAIN_2 = { - id: 'domain2', - name: 'Domain 2: Bias in classification of interventions', - questions: { - d2_1: { - id: 'd2_1', - number: '2.1', - text: 'Were the intervention strategies distinguishable at the time when follow-up would have started in the target trial?', - responseType: 'WITH_NI', - }, - d2_2: { - id: 'd2_2', - number: '2.2', - text: 'If N/PN/NI to 2.1: Did all or nearly all outcome events occur after the intervention and comparator strategies could be distinguished?', - responseType: 'WITH_NA_FULL', - }, - d2_3: { - id: 'd2_3', - number: '2.3', - text: 'If N/PN/NI to 2.2: Did the analysis avoid problems arising from intervention strategies that are not distinguishable at the start of follow-up?', - responseType: 'WITH_NA_WEAK_STRONG_YES', - }, - d2_4: { - id: 'd2_4', - number: '2.4', - text: 'Was classification of intervention status influenced by knowledge of the outcome or risk of the outcome?', - responseType: 'WEAK_STRONG_YES', - }, - d2_5: { - id: 'd2_5', - number: '2.5', - text: 'Were further classification errors (not influenced by knowledge of the outcome or risk of the outcome) likely?', - responseType: 'WITH_NI', - }, - }, - hasDirection: true, - directionOptions: BIAS_DIRECTIONS, -}; - -// Domain 3: Bias in selection of participants into the study (or into the analysis) -export const DOMAIN_3 = { - id: 'domain3', - name: 'Domain 3: Bias in selection of participants into the study (or into the analysis)', - subsections: { - a: { - name: 'A. Questions about prevalent user bias and immortal time', - questions: { - d3_1: { - id: 'd3_1', - number: '3.1', - text: 'Did follow up in the analysis begin at the start of the intervention strategies being compared?', - responseType: 'WEAK_STRONG_NO', - }, - d3_2: { - id: 'd3_2', - number: '3.2', - text: 'If Y/PY to 3.1: Were outcome events during a period of follow-up after the start of the interventions excluded from the analysis?', - responseType: 'WITH_NI', - }, - }, - }, - b: { - name: 'B. Questions about other types of selection bias', - questions: { - d3_3: { - id: 'd3_3', - number: '3.3', - text: 'Was selection of participants into the study (or into the analysis) based on participant characteristics observed after the start of intervention (additional to the situations addressed in 3.1 and 3.2)?', - responseType: 'WITH_NI', - }, - d3_4: { - id: 'd3_4', - number: '3.4', - text: 'If Y/PY to 3.3: Were the post-intervention variables that influenced selection likely to be associated with intervention?', - responseType: 'WITH_NA_FULL', - }, - d3_5: { - id: 'd3_5', - number: '3.5', - text: 'If Y/PY to 3.4: Were the post-intervention variables that influenced selection likely to be influenced by the outcome or a cause of the outcome?', - responseType: 'WITH_NA_FULL', - }, - }, - }, - c: { - name: 'C. Questions about analysis, sensitivity analyses and severity of the problem', - questions: { - d3_6: { - id: 'd3_6', - number: '3.6', - text: 'If SN to 3.1 or Y/PY to 3.5: Is it likely that the analysis corrected for all of the potential selection biases identified above?', - responseType: 'WITH_NA_FULL', - }, - d3_7: { - id: 'd3_7', - number: '3.7', - text: 'If N/PN/NI to 3.6: Did sensitivity analyses demonstrate that the likely impact of the potential selection biases identified above was minimal?', - responseType: 'WITH_NA_FULL', - }, - d3_8: { - id: 'd3_8', - number: '3.8', - text: 'If N/PN/NI to 3.7: Were potential selection biases identified above sufficiently severe that the result should not be included in a quantitative synthesis?', - responseType: 'WITH_NA_FULL', - }, - }, - }, - }, - hasDirection: true, - directionOptions: BIAS_DIRECTIONS, -}; - -// Domain 4: Bias due to missing data -export const DOMAIN_4 = { - id: 'domain4', - name: 'Domain 4: Bias due to missing data', - questions: { - d4_1: { - id: 'd4_1', - number: '4.1', - text: 'Were complete data on intervention status available for all, or nearly all, participants?', - responseType: 'WITH_NI', - }, - d4_2: { - id: 'd4_2', - number: '4.2', - text: 'Were complete data on the outcome available for all, or nearly all, participants?', - responseType: 'WITH_NI', - }, - d4_3: { - id: 'd4_3', - number: '4.3', - text: 'Were complete data on important confounding variables available for all, or nearly all, participants?', - responseType: 'WITH_NI', - }, - d4_4: { - id: 'd4_4', - number: '4.4', - text: 'If N/PN/NI to 4.1, 4.2 or 4.3: Is the result based on a complete case analysis?', - responseType: 'WITH_NA_FULL', - }, - d4_5: { - id: 'd4_5', - number: '4.5', - text: 'If Y/PY/NI to 4.4: Was exclusion from the analysis because of missing data (in intervention, confounders or the outcome) likely to be related to the true value of the outcome?', - responseType: 'WITH_NA_FULL', - }, - d4_6: { - id: 'd4_6', - number: '4.6', - text: 'If Y/PY/NI to 4.5: Is the relationship between the outcome and missingness likely to be explained by the variables in the analysis model?', - responseType: 'WITH_NA_WEAK_STRONG_NO', - }, - d4_7: { - id: 'd4_7', - number: '4.7', - text: 'If N/PN to 4.4: Was the analysis based on imputing missing values?', - responseType: 'WITH_NA', - }, - d4_8: { - id: 'd4_8', - number: '4.8', - text: "If Y/PY to 4.7: Is it reasonable to assume that data were 'missing at random' (MAR) or 'missing completely at random' (MCAR)?", - responseType: 'WITH_NA_FULL', - }, - d4_9: { - id: 'd4_9', - number: '4.9', - text: 'If Y/PY to 4.8: Was imputation performed appropriately?', - responseType: 'WITH_NA_WEAK_STRONG_NO', - }, - d4_10: { - id: 'd4_10', - number: '4.10', - text: 'If N/PN/NI to 4.7: Was an appropriate alternative method used to correct for bias due to missing data?', - responseType: 'WITH_NA_WEAK_STRONG_NO', - }, - d4_11: { - id: 'd4_11', - number: '4.11', - text: 'If PN/N/NI to 4.1, 4.2 or 4.3 AND (Y/PY/NI to 4.5 OR WN/SN/NI to 4.9 OR WN/SN/NI to 4.10): Is there evidence that the result was not biased by missing data?', - responseType: 'WITH_NA_NO_NI', - }, - }, - hasDirection: true, - directionOptions: BIAS_DIRECTIONS, -}; - -// Domain 5: Bias in measurement of the outcome -export const DOMAIN_5 = { - id: 'domain5', - name: 'Domain 5: Bias in measurement of the outcome', - questions: { - d5_1: { - id: 'd5_1', - number: '5.1', - text: 'Could measurement or ascertainment of the outcome have differed between intervention groups?', - responseType: 'WITH_NI', - }, - d5_2: { - id: 'd5_2', - number: '5.2', - text: 'Were outcome assessors aware of the intervention received by study participants?', - responseType: 'WITH_NI', - }, - d5_3: { - id: 'd5_3', - number: '5.3', - text: 'If Y/PY/NI to 5.2: Could assessment of the outcome have been influenced by knowledge of the intervention received?', - responseType: 'WITH_NA_WEAK_STRONG_YES', - }, - }, - hasDirection: true, - directionOptions: BIAS_DIRECTIONS, -}; - -// Domain 6: Bias in selection of the reported result -export const DOMAIN_6 = { - id: 'domain6', - name: 'Domain 6: Bias in selection of the reported result', - questions: { - d6_1: { - id: 'd6_1', - number: '6.1', - text: 'Was the result reported in accordance with an available, pre-determined analysis plan?', - responseType: 'WITH_NI', - }, - d6_2: { - id: 'd6_2', - number: '6.2', - text: 'Is the numerical result being assessed likely to have been selected, on the basis of the results, from multiple outcome measurements (e.g. scales, definitions, time points) within the outcome domain?', - responseType: 'WITH_NI', - }, - d6_3: { - id: 'd6_3', - number: '6.3', - text: 'Is the numerical result being assessed likely to have been selected, on the basis of the results, from multiple analyses of the data?', - responseType: 'WITH_NI', - }, - d6_4: { - id: 'd6_4', - number: '6.4', - text: 'Is the numerical result being assessed likely to have been selected, on the basis of the results, from multiple subgroups?', - responseType: 'WITH_NI', - }, - }, - hasDirection: true, - directionOptions: BIAS_DIRECTIONS, -}; - -// Complete ROBINS-I checklist structure -export const ROBINS_I_CHECKLIST = { - sectionB: SECTION_B, - domain1a: DOMAIN_1A, - domain1b: DOMAIN_1B, - domain2: DOMAIN_2, - domain3: DOMAIN_3, - domain4: DOMAIN_4, - domain5: DOMAIN_5, - domain6: DOMAIN_6, -}; - -// Get all domain keys (for iteration) -export function getDomainKeys() { - return ['domain1a', 'domain1b', 'domain2', 'domain3', 'domain4', 'domain5', 'domain6']; -} - -// Get domains that should be displayed based on C4 answer -export function getActiveDomainKeys(isPerProtocol) { - const base = ['domain2', 'domain3', 'domain4', 'domain5', 'domain6']; - return isPerProtocol ? ['domain1b', ...base] : ['domain1a', ...base]; -} - -// Get all questions for a domain (flattened) -export function getDomainQuestions(domainKey) { - const domain = ROBINS_I_CHECKLIST[domainKey]; - if (!domain) return {}; - - if (domain.subsections) { - // Domain 3 has subsections - let allQuestions = {}; - Object.values(domain.subsections).forEach(subsection => { - allQuestions = { ...allQuestions, ...subsection.questions }; - }); - return allQuestions; - } - - return domain.questions || {}; -} - -// Get response options array for a response type -export function getResponseOptions(responseType) { - return RESPONSE_TYPES[responseType] || RESPONSE_TYPES.WITH_NI; -} +export const BIAS_DIRECTIONS = robinsI.BIAS_DIRECTIONS; +export const DOMAIN1_DIRECTIONS = robinsI.DOMAIN1_DIRECTIONS; + +// Information sources +export const INFORMATION_SOURCES = robinsI.INFORMATION_SOURCES; +export const SECTION_D = robinsI.SECTION_D; + +// Sections +export const PLANNING_SECTION = robinsI.PLANNING_SECTION; +export const SECTION_A = robinsI.SECTION_A; +export const SECTION_B = robinsI.SECTION_B; +export const SECTION_C = robinsI.SECTION_C; + +// Confounding factors +export const PREDEFINED_CONFOUNDERS = robinsI.PREDEFINED_CONFOUNDERS; +export const CONFOUNDING_FIELDS = robinsI.CONFOUNDING_FIELDS; + +// Helper functions +export const getActiveDomainKeys = robinsI.getActiveDomainKeys; +export const getDomainQuestions = robinsI.getDomainQuestions; +export const getDomainTitle = robinsI.getDomainTitle; +export const getDomainDescription = robinsI.getDomainDescription; +export const getResponseOptions = robinsI.getResponseOptions; +export const getQuestionOptions = robinsI.getQuestionOptions; diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/checklist.js b/packages/web/src/components/checklist/ROBINSIChecklist/checklist.js index 847ef1e44..b152a0e97 100644 --- a/packages/web/src/components/checklist/ROBINSIChecklist/checklist.js +++ b/packages/web/src/components/checklist/ROBINSIChecklist/checklist.js @@ -1,405 +1,20 @@ -import { - ROBINS_I_CHECKLIST, - INFORMATION_SOURCES, - getActiveDomainKeys, - getDomainQuestions, -} from './checklist-map.js'; -import { - scoreRobinsDomain, - getEffectiveDomainJudgement, - scoreAllDomains, - mapOverallJudgementToDisplay, - JUDGEMENTS, -} from './scoring/robins-scoring.js'; - /** - * Creates a new ROBINS-I V2 checklist object with default empty answers. - * - * @param {Object} options - Checklist properties. - * @param {string} options.name - The checklist name (required). - * @param {string} options.id - Unique checklist ID (required). - * @param {number} [options.createdAt=Date.now()] - Timestamp of checklist creation. - * @param {string} [options.reviewerName=''] - Name of the reviewer. - * - * @returns {Object} A checklist object with all ROBINS-I questions initialized to default answers. + * ROBINS-I Checklist Logic * - * @throws {Error} If `id` or `name` is missing or not a non-empty string. - */ -export function createChecklist({ - name = null, - id = null, - createdAt = Date.now(), - reviewerName = '', -}) { - if (!id || typeof id !== 'string' || !id.trim()) { - throw new Error('ROBINS-I Checklist requires a non-empty string id.'); - } - if (!name || typeof name !== 'string' || !name.trim()) { - throw new Error('ROBINS-I Checklist requires a non-empty string name.'); - } - - let d = new Date(createdAt); - if (isNaN(d)) d = new Date(); - const mm = String(d.getMonth() + 1).padStart(2, '0'); - const dd = String(d.getDate()).padStart(2, '0'); - const formattedDate = `${d.getFullYear()}-${mm}-${dd}`; - - return { - // Metadata - name: name, - reviewerName: reviewerName || '', - createdAt: formattedDate, - id: id, - checklistType: 'ROBINS_I', - - // Planning stage: confounding factors (free text) - planning: { - confoundingFactors: '', - }, - - // Section A: Result being assessed (metadata) - sectionA: { - numericalResult: '', - furtherDetails: '', - outcome: '', - }, - - // Section B: Proceed with assessment - sectionB: { - b1: { answer: null, comment: '' }, - b2: { answer: null, comment: '' }, - b3: { answer: null, comment: '' }, - stopAssessment: false, - }, - - // Section C: Target randomized trial - sectionC: { - participants: '', - interventionStrategy: '', - comparatorStrategy: '', - isPerProtocol: false, // false = ITT, true = Per-Protocol - }, - - // Section D: Information sources - sectionD: { - sources: INFORMATION_SOURCES.reduce((acc, source) => { - acc[source] = false; - return acc; - }, {}), - otherSpecify: '', - }, - - // Confounding factors evaluation - confoundingEvaluation: { - predefined: [], // Array of { factor, variables, controlled, validReliable, unnecessary, direction, comment } - additional: [], // Same structure - }, - - // Domain 1A: Confounding (ITT) - domain1a: createDomainState('domain1a'), - - // Domain 1B: Confounding (Per-Protocol) - domain1b: createDomainState('domain1b'), - - // Domain 2: Classification of interventions - domain2: createDomainState('domain2'), - - // Domain 3: Selection of participants - domain3: createDomainState('domain3'), - - // Domain 4: Missing data - domain4: createDomainState('domain4'), - - // Domain 5: Measurement of outcome - domain5: createDomainState('domain5'), - - // Domain 6: Selection of reported result - domain6: createDomainState('domain6'), - - // Overall risk of bias - overall: { - judgement: null, // 'Low (except confounding)', 'Moderate', 'Serious', 'Critical' - judgementSource: 'auto', // 'auto' | 'manual' - tracks whether judgement is auto-calculated or manually set - direction: null, - }, - }; -} - -/** - * Creates the initial state for a domain - * @param {string} domainKey - The domain key (e.g., 'domain1a') - * @returns {Object} Domain state object - */ -function createDomainState(domainKey) { - const questions = getDomainQuestions(domainKey); - const answers = {}; - - Object.keys(questions).forEach(qKey => { - answers[qKey] = { answer: null, comment: '' }; - }); - - const domain = ROBINS_I_CHECKLIST[domainKey]; - - return { - answers, - judgement: null, // 'Low', 'Moderate', 'Serious', 'Critical' - judgementSource: 'auto', // 'auto' | 'manual' - tracks whether judgement is auto-calculated or manually set - direction: domain?.hasDirection ? null : undefined, - }; -} - -/** - * Determines if assessment should stop based on Section B answers - * @param {Object} sectionB - Section B state - * @returns {boolean} True if assessment should stop - */ -export function shouldStopAssessment(sectionB) { - if (!sectionB) return false; - - const b2Answer = sectionB.b2?.answer; - const b3Answer = sectionB.b3?.answer; - - // Stop if B2 or B3 is Yes or Probably Yes - return b2Answer === 'Y' || b2Answer === 'PY' || b3Answer === 'Y' || b3Answer === 'PY'; -} - -/** - * Score the overall checklist based on domain judgements (uses effective judgements from smart scoring) - * @param {Object} state - The complete checklist state - * @returns {string} Overall risk of bias: 'Low', 'Moderate', 'Serious', 'Critical', or 'Incomplete' - */ -export function scoreChecklist(state) { - if (!state || typeof state !== 'object') return 'Error'; - - // Check if assessment was stopped early - if (shouldStopAssessment(state.sectionB)) { - return 'Critical'; - } - - // Use the smart scoring engine to get all effective judgements - const { overall, isComplete } = scoreAllDomains(state); - - if (!isComplete) { - return 'Incomplete'; - } - - return overall || 'Incomplete'; -} - -/** - * Get detailed scoring information for all domains using the smart scoring engine - * @param {Object} state - The complete checklist state - * @returns {Object} { domains, overall, isComplete } with auto/effective/source per domain + * Re-exports checklist functions from @corates/shared for backward compatibility. + * All new code should import directly from @corates/shared. */ -export function getSmartScoring(state) { - if (!state || typeof state !== 'object') { - return { domains: {}, overall: null, isComplete: false }; - } - - // Check if assessment was stopped early - if (shouldStopAssessment(state.sectionB)) { - return { - domains: {}, - overall: 'Critical', - isComplete: true, - stoppedEarly: true, - }; - } - - return scoreAllDomains(state); -} - -/** - * Get the algorithmic suggestion for a domain's risk of bias based on signalling questions - * Uses the table-driven smart scoring engine for accurate, deterministic results - * @param {string} domainKey - The domain key - * @param {Object} answers - The domain's answers object - * @returns {string|null} Suggested judgement or null if incomplete - */ -export function suggestDomainJudgement(domainKey, answers) { - const result = scoreRobinsDomain(domainKey, answers); - return result.judgement; -} - -// Re-export smart scoring functions for use in components -export { scoreRobinsDomain, getEffectiveDomainJudgement, mapOverallJudgementToDisplay, JUDGEMENTS }; - -/** - * Get the selected answer for a specific question - * @param {string} domainKey - The domain key - * @param {string} questionKey - The question key - * @param {Object} state - The checklist state - * @returns {string|null} The selected answer or null - */ -export function getSelectedAnswer(domainKey, questionKey, state) { - return state?.[domainKey]?.answers?.[questionKey]?.answer || null; -} - -/** - * Get all answers in a flat format for export/display - * @param {Object} checklist - The complete checklist - * @returns {Object} Flat object with all answers - */ -export function getAnswers(checklist) { - if (!checklist || typeof checklist !== 'object') return null; - - const result = { - metadata: { - name: checklist.name, - reviewerName: checklist.reviewerName, - createdAt: checklist.createdAt, - id: checklist.id, - }, - sectionB: {}, - domains: {}, - overall: checklist.overall, - }; - - // Section B - Object.keys(ROBINS_I_CHECKLIST.sectionB).forEach(key => { - result.sectionB[key] = checklist.sectionB?.[key]?.answer || null; - }); - - // Domains - const isPerProtocol = checklist.sectionC?.isPerProtocol || false; - const activeDomains = getActiveDomainKeys(isPerProtocol); - - activeDomains.forEach(domainKey => { - const domain = checklist[domainKey]; - if (!domain) return; - - result.domains[domainKey] = { - judgement: domain.judgement, - direction: domain.direction, - questions: {}, - }; - - Object.keys(domain.answers || {}).forEach(qKey => { - result.domains[domainKey].questions[qKey] = domain.answers[qKey]?.answer || null; - }); - }); - - return result; -} - -/** - * Get a summary of domain judgements - * @param {Object} checklist - The complete checklist - * @returns {Object} Summary of domain judgements - */ -export function getDomainSummary(checklist) { - if (!checklist) return null; - - const isPerProtocol = checklist.sectionC?.isPerProtocol || false; - const activeDomains = getActiveDomainKeys(isPerProtocol); - - const summary = {}; - - activeDomains.forEach(domainKey => { - const domain = checklist[domainKey]; - summary[domainKey] = { - judgement: domain?.judgement || null, - direction: domain?.direction || null, - complete: isQuestionnaireComplete(domainKey, domain?.answers), - }; - }); - - return summary; -} - -/** - * Check if all questions in a domain are answered - * @param {string} domainKey - The domain key - * @param {Object} answers - The domain's answers - * @returns {boolean} True if all questions have answers - */ -function isQuestionnaireComplete(domainKey, answers) { - if (!answers) return false; - - const questions = getDomainQuestions(domainKey); - const requiredKeys = Object.keys(questions); - - return requiredKeys.every(key => answers[key]?.answer !== null); -} - -/** - * Export checklist to CSV format - * @param {Array|Object} checklists - One or more checklist objects - * @returns {string} CSV string - */ -export function exportChecklistsToCSV(checklists) { - const list = Array.isArray(checklists) ? checklists : [checklists]; - - const headers = [ - 'Checklist Name', - 'Reviewer', - 'Created At', - 'Domain', - 'Question', - 'Answer', - 'Comment', - 'Domain Judgement', - 'Domain Direction', - 'Overall Judgement', - 'Overall Direction', - ]; - - const rows = []; - - list.forEach(cl => { - const isPerProtocol = cl.sectionC?.isPerProtocol || false; - const activeDomains = getActiveDomainKeys(isPerProtocol); - const overallScore = scoreChecklist(cl); - - // Section B - Object.entries(ROBINS_I_CHECKLIST.sectionB).forEach(([key, def]) => { - const ans = cl.sectionB?.[key]; - rows.push([ - cl.name || '', - cl.reviewerName || '', - cl.createdAt || '', - 'Section B', - def.text, - ans?.answer || '', - ans?.comment || '', - '', - '', - overallScore, - cl.overall?.direction || '', - ]); - }); - - // Domains - activeDomains.forEach(domainKey => { - const domainDef = ROBINS_I_CHECKLIST[domainKey]; - const domain = cl[domainKey]; - const questions = getDomainQuestions(domainKey); - Object.entries(questions).forEach(([qKey, qDef]) => { - const ans = domain?.answers?.[qKey]; - rows.push([ - cl.name || '', - cl.reviewerName || '', - cl.createdAt || '', - domainDef?.name || domainKey, - `${qDef.number}: ${qDef.text}`, - ans?.answer || '', - ans?.comment || '', - domain?.judgement || '', - domain?.direction || '', - overallScore, - cl.overall?.direction || '', - ]); - }); - }); - }); +import { robinsI } from '@corates/shared'; - // CSV encode - const csvEscape = val => `"${String(val).replace(/"/g, '""').replace(/\n/g, ' ')}"`; - const csv = - headers.map(csvEscape).join(',') + - '\n' + - rows.map(row => row.map(csvEscape).join(',')).join('\n'); +// Re-export functions with original names for backward compatibility +export const createChecklist = robinsI.createROBINSIChecklist; +export const scoreChecklist = robinsI.scoreROBINSIChecklist; +export const isROBINSIComplete = robinsI.isROBINSIComplete; +export const shouldStopAssessment = robinsI.shouldStopAssessment; +export const getAnswers = robinsI.getAnswers; +export const getDomainSummary = robinsI.getDomainSummary; - return csv; -} +// Re-export smart scoring functions (also available from ./scoring/robins-scoring.js) +export const getSmartScoring = robinsI.scoreAllDomains; +export const mapOverallJudgementToDisplay = robinsI.mapOverallJudgementToDisplay; diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/scoring/robins-scoring.js b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/robins-scoring.js index bd39f99d0..834e02006 100644 --- a/packages/web/src/components/checklist/ROBINSIChecklist/scoring/robins-scoring.js +++ b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/robins-scoring.js @@ -1,1122 +1,28 @@ /** - * ROBINS-I V2 Smart Scoring Engine + * ROBINS-I Smart Scoring Engine * - * Implements deterministic, table-driven scoring for all ROBINS-I domains - * based on the official decision tables. + * Re-exports scoring functions from @corates/shared for backward compatibility. + * All new code should import directly from @corates/shared. */ -// Helper: check if answer matches any value in a set -const inSet = (answer, ...values) => values.includes(answer); +import { robinsI } from '@corates/shared'; -// Normalization: treat NA as NI for scoring to avoid "stuck" branches -// (The mermaid decision diagrams generally model NI but omit NA.) -const normalizeAnswer = answer => (answer === 'NA' ? 'NI' : answer); +// Re-export all scoring functions and constants +export const JUDGEMENTS = robinsI.JUDGEMENTS; +export const OVERALL_DISPLAY = robinsI.OVERALL_DISPLAY; +export const DOMAIN_SCORING_RULES = robinsI.DOMAIN_SCORING_RULES; -// Helper: check if answer is Yes or Probably Yes -const isYesPY = answer => inSet(answer, 'Y', 'PY'); +// Domain scoring functions +export const scoreDomain1A = robinsI.scoreDomain1A; +export const scoreDomain1B = robinsI.scoreDomain1B; +export const scoreDomain2 = robinsI.scoreDomain2; +export const scoreDomain3 = robinsI.scoreDomain3; +export const scoreDomain4 = robinsI.scoreDomain4; +export const scoreDomain5 = robinsI.scoreDomain5; +export const scoreDomain6 = robinsI.scoreDomain6; -// Helper: check if answer is No or Probably No -const isNoPPN = answer => inSet(answer, 'N', 'PN'); - -// Helper: check if answer is No, Probably No, or No Information -const isNoPPNNI = answer => inSet(answer, 'N', 'PN', 'NI'); - -// Canonical judgement values - single source of truth for all ROBINS-I scoring -export const JUDGEMENTS = { - LOW: 'Low', - LOW_EXCEPT_CONFOUNDING: 'Low (except for concerns about uncontrolled confounding)', - MODERATE: 'Moderate', - SERIOUS: 'Serious', - CRITICAL: 'Critical', -}; - -/** - * Score Domain 1A (Bias due to confounding - ITT effect) - * - * Questions: d1a_1, d1a_2, d1a_3, d1a_4 - * Flow from domain-1-a.md mermaid diagram - */ -function scoreDomain1A(answers) { - const q1 = normalizeAnswer(answers.d1a_1?.answer); // 1.1 Controlled for all the important confounding factors? - const q2 = normalizeAnswer(answers.d1a_2?.answer); // 1.2 Confounding factors measured validly and reliably? - const q3 = normalizeAnswer(answers.d1a_3?.answer); // 1.3 Controlled for any post-intervention variables? - const q4 = normalizeAnswer(answers.d1a_4?.answer); // 1.4 Negative controls etc suggest serious uncontrolled confounding? - - // Must have Q1 to start - if (q1 === null || q1 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // Path: Q1 -> if SN/NI -> NC1 -> outcomes - if (inSet(q1, 'SN', 'NI')) { - if (q4 === null || q4 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q4)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1A.R8' }; - } - if (isYesPY(q4)) { - return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D1A.R9' }; - } - } - - // Path: Q1 -> if Y/PY -> Q3a - if (isYesPY(q1)) { - if (q3 === null || q3 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // Q3a -> if Y/PY -> NC3 - if (isYesPY(q3)) { - if (q4 === null || q4 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q4)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1A.R5' }; - } - if (isYesPY(q4)) { - return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D1A.R4' }; - } - } - - // Q3a -> if N/PN/NI -> Q2a - if (isNoPPNNI(q3)) { - if (q2 === null || q2 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // Q2a -> if SN/NI -> SER (terminal, no NC needed) - if (inSet(q2, 'SN', 'NI')) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1A.R3' }; - } - - // Q2a -> if Y/PY or WN -> NC2 - if (isYesPY(q2) || q2 === 'WN') { - if (q4 === null || q4 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q4)) { - return { - judgement: JUDGEMENTS.LOW_EXCEPT_CONFOUNDING, - isComplete: true, - ruleId: 'D1A.R1', - }; - } - if (isYesPY(q4)) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D1A.R2' }; - } - } - } - } - - // Path: Q1 -> if WN -> Q3b - if (q1 === 'WN') { - if (q3 === null || q3 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // Q3b -> if Y/PY -> NC4 - if (isYesPY(q3)) { - if (q4 === null || q4 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q4)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1A.R6' }; - } - if (isYesPY(q4)) { - return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D1A.R7' }; - } - } - - // Q3b -> if N/PN/NI -> Q2b - if (isNoPPNNI(q3)) { - if (q2 === null || q2 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // Q2b -> if SN/NI -> SER (terminal, no NC needed) - if (inSet(q2, 'SN', 'NI')) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1A.R10' }; - } - - // Q2b -> if Y/PY/WN -> NC2 - if (isYesPY(q2) || q2 === 'WN') { - if (q4 === null || q4 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q4)) { - return { - judgement: JUDGEMENTS.LOW_EXCEPT_CONFOUNDING, - isComplete: true, - ruleId: 'D1A.R1', - }; - } - if (isYesPY(q4)) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D1A.R2' }; - } - } - } - } - - // Incomplete - need more answers - return { judgement: null, isComplete: false, ruleId: null }; -} - -/** - * Score Domain 1B (Bias due to confounding - Per-Protocol effect) - * - * Questions: d1b_1, d1b_2, d1b_3, d1b_4, d1b_5 - * Flow from domain-1-b.md mermaid diagram - */ -function scoreDomain1B(answers) { - const q1 = normalizeAnswer(answers.d1b_1?.answer); // 1.1 Appropriate analysis method? - const q2 = normalizeAnswer(answers.d1b_2?.answer); // 1.2 Controlled for all the important confounding factors? - const q3 = normalizeAnswer(answers.d1b_3?.answer); // 1.3 Confounding factors measured validly and reliably? - const q4 = normalizeAnswer(answers.d1b_4?.answer); // 1.4 Controlled for variables measured after start of intervention? - const q5 = normalizeAnswer(answers.d1b_5?.answer); // 1.5 Negative controls etc suggest serious uncontrolled confounding? - - // Must have Q1 to start - if (q1 === null || q1 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // Path: Q1 -> if N/PN/NI -> Q4 - if (isNoPPNNI(q1)) { - if (q4 === null || q4 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // Q4 -> if Y/PY -> CRIT (terminal, no NC needed) - if (isYesPY(q4)) { - return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D1B.R6' }; - } - - // Q4 -> if N/PN/NI -> NC3 - if (isNoPPNNI(q4)) { - if (q5 === null || q5 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q5)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R8' }; - } - if (isYesPY(q5)) { - return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D1B.R7' }; - } - } - } - - // Path: Q1 -> if Y/PY -> Q2 - if (isYesPY(q1)) { - if (q2 === null || q2 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // Q2 -> if SN/NI -> SER (terminal, no Q3 needed) - if (inSet(q2, 'SN', 'NI')) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R5' }; - } - - // Q2 -> if Y/PY -> Q3a - if (isYesPY(q2)) { - if (q3 === null || q3 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // Q3a -> if SN/NI -> SER (terminal, no NC needed) - if (inSet(q3, 'SN', 'NI')) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R5' }; - } - - // Q3a -> if Y/PY -> NC1 - if (isYesPY(q3)) { - if (q5 === null || q5 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q5)) { - return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D1B.R1' }; - } - if (isYesPY(q5)) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D1B.R2' }; - } - } - - // Q3a -> if WN -> NC2 - if (q3 === 'WN') { - if (q5 === null || q5 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q5)) { - return { - judgement: JUDGEMENTS.LOW_EXCEPT_CONFOUNDING, - isComplete: true, - ruleId: 'D1B.R3', - }; - } - if (isYesPY(q5)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R4' }; - } - } - } - - // Q2 -> if WN -> Q3b - if (q2 === 'WN') { - if (q3 === null || q3 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // Q3b -> if SN/NI -> SER (terminal, no NC needed) - if (inSet(q3, 'SN', 'NI')) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R5' }; - } - - // Q3b -> if Y/PY/WN -> NC2 - if (isYesPY(q3) || q3 === 'WN') { - if (q5 === null || q5 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q5)) { - return { - judgement: JUDGEMENTS.LOW_EXCEPT_CONFOUNDING, - isComplete: true, - ruleId: 'D1B.R3', - }; - } - if (isYesPY(q5)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R4' }; - } - } - } - } - - // Incomplete - need more answers - return { judgement: null, isComplete: false, ruleId: null }; -} - -/** - * Score Domain 2 (Bias in classification of interventions) - * - * Questions: d2_1, d2_2, d2_3, d2_4, d2_5 - * Flow from domain-2.md mermaid diagram - */ -function scoreDomain2(answers) { - const q1 = normalizeAnswer(answers.d2_1?.answer); // 2.1 Intervention distinguishable at start of follow-up? - const q2 = normalizeAnswer(answers.d2_2?.answer); // 2.2 Almost all outcome events after strategies distinguishable? - const q3 = normalizeAnswer(answers.d2_3?.answer); // 2.3 Appropriate analysis? - const q4 = normalizeAnswer(answers.d2_4?.answer); // 2.4 Classification of intervention influenced by outcome? - const q5 = normalizeAnswer(answers.d2_5?.answer); // 2.5 Further classification errors likely? - - // Must have A1 (q1) to start - if (q1 === null || q1 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // Path: A1 -> if Y/PY -> C1 - if (isYesPY(q1)) { - if (q4 === null || q4 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // C1 -> if SY -> E3 - if (q4 === 'SY') { - if (q5 === null || q5 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q5)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R4' }; - } - if (inSet(q5, 'Y', 'PY', 'NI')) { - return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D2.R4' }; - } - } - - // C1 -> if WY/NI -> E2 - if (inSet(q4, 'WY', 'NI')) { - if (q5 === null || q5 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q5)) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D2.R3' }; - } - if (inSet(q5, 'Y', 'PY', 'NI')) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R3' }; - } - } - - // C1 -> if N/PN -> E1 - if (isNoPPN(q4)) { - if (q5 === null || q5 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q5)) { - return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D2.R1' }; - } - if (inSet(q5, 'Y', 'PY', 'NI')) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D2.R2' }; - } - } - } - - // Path: A1 -> if N/PN/NI -> A2 - if (isNoPPNNI(q1)) { - if (q2 === null || q2 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // A2 -> if Y/PY -> C1 (same logic as above) - if (isYesPY(q2)) { - if (q4 === null || q4 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - if (q4 === 'SY') { - if (q5 === null || q5 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q5)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R4' }; - } - if (inSet(q5, 'Y', 'PY', 'NI')) { - return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D2.R4' }; - } - } - - if (inSet(q4, 'WY', 'NI')) { - if (q5 === null || q5 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q5)) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D2.R3' }; - } - if (inSet(q5, 'Y', 'PY', 'NI')) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R3' }; - } - } - - if (isNoPPN(q4)) { - if (q5 === null || q5 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q5)) { - return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D2.R5' }; - } - if (inSet(q5, 'Y', 'PY', 'NI')) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D2.R2' }; - } - } - } - - // A2 -> if N/PN/NI -> A3 - if (isNoPPNNI(q2)) { - if (q3 === null || q3 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // A3 -> if N/PN -> C3 - if (isNoPPN(q3)) { - if (q4 === null || q4 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - // C3 -> if SY/WY/NI -> CRIT (terminal, no E needed) - if (inSet(q4, 'SY', 'WY', 'NI')) { - return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D2.R7' }; - } - // C3 -> if N/PN -> E3 - if (isNoPPN(q4)) { - if (q5 === null || q5 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q5)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R7' }; - } - if (inSet(q5, 'Y', 'PY', 'NI')) { - return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D2.R7' }; - } - } - } - - // A3 -> if SY/WY/NI -> C2 - if (inSet(q3, 'SY', 'WY', 'NI')) { - if (q4 === null || q4 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - // C2 -> if SY -> E3 - if (q4 === 'SY') { - if (q5 === null || q5 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q5)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R6' }; - } - if (inSet(q5, 'Y', 'PY', 'NI')) { - return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D2.R6' }; - } - } - // C2 -> if N/PN -> E2 - if (isNoPPN(q4)) { - if (q5 === null || q5 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isNoPPN(q5)) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D2.R6' }; - } - if (inSet(q5, 'Y', 'PY', 'NI')) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R6' }; - } - } - } - } - } - - // Incomplete - need more answers - return { judgement: null, isComplete: false, ruleId: null }; -} - -/** - * Score Domain 3 Part A (Selection bias - prevalent user bias and immortal time) - * - * Questions: d3_1, d3_2 - * Flow from domain-3.md mermaid diagram Section A - */ -function scoreDomain3PartA(answers) { - const q1 = normalizeAnswer(answers.d3_1?.answer); // 3.1 Participants followed from start of intervention? - const q2 = normalizeAnswer(answers.d3_2?.answer); // 3.2 Early outcome events excluded? - - if (q1 === null || q1 === undefined) { - return { result: null, isComplete: false }; - } - - // A1 -> if SN -> A_SER (Serious) - if (q1 === 'SN') { - return { result: 'Serious', isComplete: true }; - } - - // A1 -> if WN/NI -> A_MOD (Moderate) - if (inSet(q1, 'WN', 'NI')) { - return { result: 'Moderate', isComplete: true }; - } - - // A1 -> if Y/PY -> A2 - if (isYesPY(q1)) { - if (q2 === null || q2 === undefined) { - return { result: null, isComplete: false }; - } - // A2 -> if N/PN/NI -> A_LOW (Low) - if (isNoPPNNI(q2)) { - return { result: 'Low', isComplete: true }; - } - // A2 -> if Y/PY -> A_MOD (Moderate) - if (isYesPY(q2)) { - return { result: 'Moderate', isComplete: true }; - } - } - - return { result: null, isComplete: q2 !== null && q2 !== undefined }; -} - -/** - * Score Domain 3 Part B (Selection bias - other types) - * - * Questions: d3_3, d3_4, d3_5 - * Flow from domain-3.md mermaid diagram Section B - */ -function scoreDomain3PartB(answers) { - const q3 = normalizeAnswer(answers.d3_3?.answer); // 3.3 Selection based on characteristics after start? - const q4 = normalizeAnswer(answers.d3_4?.answer); // 3.4 Selection variables associated with intervention? - const q5 = normalizeAnswer(answers.d3_5?.answer); // 3.5 Selection variables influenced by outcome? - - if (q3 === null || q3 === undefined) { - return { result: null, isComplete: false }; - } - - // B1 -> if N/PN -> B_LOW1 (Low) - if (isNoPPN(q3)) { - return { result: 'Low', isComplete: true }; - } - - // B1 -> if NI -> B_MOD1 (Moderate) - if (q3 === 'NI') { - return { result: 'Moderate', isComplete: true }; - } - - // B1 -> if Y/PY -> B2 - if (isYesPY(q3)) { - if (q4 === null || q4 === undefined) { - return { result: null, isComplete: false }; - } - - // B2 -> if N/PN -> B_LOW2 (Low) - if (isNoPPN(q4)) { - return { result: 'Low', isComplete: true }; - } - - // B2 -> if NI -> B_MOD2 (Moderate) - if (q4 === 'NI') { - return { result: 'Moderate', isComplete: true }; - } - - // B2 -> if Y/PY -> B3 - if (isYesPY(q4)) { - if (q5 === null || q5 === undefined) { - return { result: null, isComplete: false }; - } - // B3 -> if N/PN/NI -> B_MOD3 (Moderate) - if (isNoPPNNI(q5)) { - return { result: 'Moderate', isComplete: true }; - } - // B3 -> if Y/PY -> B_SER (Serious) - if (isYesPY(q5)) { - return { result: 'Serious', isComplete: true }; - } - } - } - - const allAnswered = [q3, q4, q5].every(a => a !== null && a !== undefined); - return { result: null, isComplete: allAnswered }; -} - -/** - * Score Domain 3 Final (combines Part A + Part B with correction questions) - * - * Questions: d3_6, d3_7, d3_8 - * Flow from domain-3.md mermaid diagram - combines Section A and B, then applies corrections - */ -function scoreDomain3(answers) { - const partA = scoreDomain3PartA(answers); - const partB = scoreDomain3PartB(answers); - - // If either part is incomplete, domain is incomplete - if (!partA.isComplete || !partB.isComplete) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - const q6 = normalizeAnswer(answers.d3_6?.answer); // 3.6 Analysis corrected for selection biases? - const q7 = normalizeAnswer(answers.d3_7?.answer); // 3.7 Sensitivity analyses demonstrate minimal impact? - const q8 = normalizeAnswer(answers.d3_8?.answer); // 3.8 Selection biases severe? - - // Determine combined result: All LOW, At worst MODERATE, or At least one SERIOUS - const rankMap = { Low: 0, Moderate: 1, Serious: 2 }; - const aRank = rankMap[partA.result] ?? 0; - const bRank = rankMap[partB.result] ?? 0; - const worstRank = Math.max(aRank, bRank); - - // All LOW -> LOW_RISK (terminal, no correction questions needed) - if (partA.result === 'Low' && partB.result === 'Low') { - return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D3.R1' }; - } - - // At worst MODERATE -> MOD_RISK (terminal, no correction questions needed) - if (worstRank <= 1) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D3.R2' }; - } - - // At least one SERIOUS -> need correction/sensitivity questions - if (worstRank >= 2) { - // C1 (3.6) -> if Y/PY -> MOD_RISK - if (isYesPY(q6)) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D3.R3' }; - } - - // C1 -> if N/PN/NI -> C2 (3.7) - if (isNoPPNNI(q6)) { - // C2 -> if Y/PY -> MOD_RISK - if (isYesPY(q7)) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D3.R3' }; - } - - // C2 -> if N/PN/NI -> C3 (3.8) - if (isNoPPNNI(q7)) { - if (q8 === null || q8 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - // C3 -> if N/PN/NI -> SER_RISK - if (isNoPPNNI(q8)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D3.R4' }; - } - // C3 -> if Y/PY -> CRIT_RISK - if (isYesPY(q8)) { - return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D3.R5' }; - } - } - } - } - - // Need more answers for final determination - return { judgement: null, isComplete: false, ruleId: null }; -} - -/** - * Score Domain 4 (Bias due to missing data) - * - * Questions: d4_1 through d4_11 - * Flow from domain-4.md mermaid diagram - */ -function scoreDomain4(answers) { - const q1 = normalizeAnswer(answers.d4_1?.answer); // 4.1 Complete data on intervention - const q2 = normalizeAnswer(answers.d4_2?.answer); // 4.2 Complete data on outcome - const q3 = normalizeAnswer(answers.d4_3?.answer); // 4.3 Complete data on confounders - const q4 = normalizeAnswer(answers.d4_4?.answer); // 4.4 Complete-case analysis? - const q5 = normalizeAnswer(answers.d4_5?.answer); // 4.5 Exclusion related to true outcome? - const q6 = normalizeAnswer(answers.d4_6?.answer); // 4.6 Outcome–missingness relationship explained by model? - const q7 = normalizeAnswer(answers.d4_7?.answer); // 4.7 Analysis based on imputation? - const q8 = normalizeAnswer(answers.d4_8?.answer); // 4.8 MAR/MCAR reasonable? - const q9 = normalizeAnswer(answers.d4_9?.answer); // 4.9 Appropriate imputation? - const q10 = normalizeAnswer(answers.d4_10?.answer); // 4.10 Alternative appropriate method? - const q11 = normalizeAnswer(answers.d4_11?.answer); // 4.11 Evidence result not biased? - - // A: Check 4.1-4.3 complete data - const completeDataAnswered = [q1, q2, q3].every(a => a !== null && a !== undefined); - if (!completeDataAnswered) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - const allCompleteData = [q1, q2, q3].every(a => isYesPY(a)); - - // A -> if All Y/PY -> LOW1 (terminal) - if (allCompleteData) { - return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D4.R1' }; - } - - // A -> if Any N/PN/NI -> B (4.4) - if ([q1, q2, q3].some(a => isNoPPNNI(a))) { - if (q4 === null || q4 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // B -> if Y/PY/NI -> C (4.5) - complete-case path - if (isYesPY(q4) || q4 === 'NI') { - if (q5 === null || q5 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // C -> if N/PN -> LOW2 (terminal) - if (isNoPPN(q5)) { - return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D4.R2' }; - } - - // C -> if Y/PY/NI -> E (4.6) - if (isYesPY(q5) || q5 === 'NI') { - if (q6 === null || q6 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // E -> if Y/PY -> F1 (4.11) - if (isYesPY(q6)) { - if (q11 === null || q11 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isYesPY(q11)) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D4.R3' }; - } - if (isNoPPN(q11)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R4' }; - } - } - - // E -> if WN/NI -> F2 (4.11) - if (inSet(q6, 'WN', 'NI')) { - if (q11 === null || q11 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isYesPY(q11)) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D4.R6' }; - } - if (isNoPPN(q11)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' }; - } - } - - // E -> if SN -> F3 (4.11) - if (q6 === 'SN') { - if (q11 === null || q11 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isYesPY(q11)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' }; - } - if (isNoPPN(q11)) { - return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D4.R8' }; - } - } - } - } - - // B -> if N/PN -> D (4.7) - imputation/alternative method path - if (isNoPPN(q4)) { - if (q7 === null || q7 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // D -> if Y/PY -> G (4.8) - imputation path - if (isYesPY(q7)) { - if (q8 === null || q8 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // G -> if Y/PY -> I (4.9) - if (isYesPY(q8)) { - if (q9 === null || q9 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - // I -> if Y/PY -> LOW3 (terminal) - if (isYesPY(q9)) { - return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D4.R5' }; - } - // I -> if WN/NI -> F2 (4.11) - if (inSet(q9, 'WN', 'NI')) { - if (q11 === null || q11 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isYesPY(q11)) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D4.R6' }; - } - if (isNoPPN(q11)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' }; - } - } - // I -> if SN -> F3 (4.11) - if (q9 === 'SN') { - if (q11 === null || q11 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isYesPY(q11)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' }; - } - if (isNoPPN(q11)) { - return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D4.R8' }; - } - } - } - - // G -> if N/PN/NI -> F2 (4.11) - if (isNoPPNNI(q8)) { - if (q11 === null || q11 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isYesPY(q11)) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D4.R6' }; - } - if (isNoPPN(q11)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' }; - } - } - } - - // D -> if N/PN/NI -> H (4.10) - alternative method path - if (isNoPPNNI(q7)) { - if (q10 === null || q10 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - // H -> if Y/PY -> LOW4 (terminal) - if (isYesPY(q10)) { - return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D4.R2' }; - } - // H -> if WN/NI -> F2 (4.11) - if (inSet(q10, 'WN', 'NI')) { - if (q11 === null || q11 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isYesPY(q11)) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D4.R6' }; - } - if (isNoPPN(q11)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' }; - } - } - // H -> if SN -> F3 (4.11) - if (q10 === 'SN') { - if (q11 === null || q11 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - if (isYesPY(q11)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' }; - } - if (isNoPPN(q11)) { - return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D4.R8' }; - } - } - } - } - } - - // Incomplete - need more answers - return { judgement: null, isComplete: false, ruleId: null }; -} - -/** - * Score Domain 5 (Bias in measurement of the outcome) - * - * Questions: d5_1, d5_2, d5_3 - * Flow from domain-5.md mermaid diagram - */ -function scoreDomain5(answers) { - const q1 = normalizeAnswer(answers.d5_1?.answer); // 5.1 Measurement of outcome differs by intervention? - const q2 = normalizeAnswer(answers.d5_2?.answer); // 5.2 Outcome assessors aware of intervention received? - const q3 = normalizeAnswer(answers.d5_3?.answer); // 5.3 Assessment could be influenced by knowledge of intervention? - - // Must have Q1 to start - if (q1 === null || q1 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // Q1 -> if Y/PY -> SER (terminal, no Q2/Q3 needed) - if (isYesPY(q1)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D5.R1' }; - } - - // Q1 -> if N/PN -> Q2a - if (isNoPPN(q1)) { - if (q2 === null || q2 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // Q2a -> if N/PN -> LOW (terminal, no Q3 needed) - if (isNoPPN(q2)) { - return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D5.R2' }; - } - - // Q2a -> if Y/PY/NI -> Q3a - if (inSet(q2, 'Y', 'PY', 'NI')) { - if (q3 === null || q3 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - // Q3a -> if N/PN -> LOW - if (isNoPPN(q3)) { - return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D5.R3' }; - } - // Q3a -> if WY/NI -> MOD - if (inSet(q3, 'WY', 'NI')) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D5.R4' }; - } - // Q3a -> if SY -> SER - if (q3 === 'SY') { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D5.R5' }; - } - } - } - - // Q1 -> if NI -> Q2b - if (q1 === 'NI') { - if (q2 === null || q2 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // Q2b -> if N/PN -> MOD (terminal, no Q3 needed) - if (isNoPPN(q2)) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D5.R6' }; - } - - // Q2b -> if Y/PY/NI -> Q3b - if (inSet(q2, 'Y', 'PY', 'NI')) { - if (q3 === null || q3 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - // Q3b -> if WY/N/PN/NI -> MOD - if (inSet(q3, 'WY', 'N', 'PN', 'NI')) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D5.R7' }; - } - // Q3b -> if SY -> SER - if (q3 === 'SY') { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D5.R7' }; - } - } - } - - // Incomplete - need more answers - return { judgement: null, isComplete: false, ruleId: null }; -} - -/** - * Score Domain 6 (Bias in selection of the reported result) - * - * Questions: d6_1, d6_2, d6_3, d6_4 - * Flow from domain-6.md mermaid diagram - */ -function scoreDomain6(answers) { - const q1 = normalizeAnswer(answers.d6_1?.answer); // 6.1 Result reported according to analysis plan? - const q2 = normalizeAnswer(answers.d6_2?.answer); // 6.2 Multiple outcome measurements? - const q3 = normalizeAnswer(answers.d6_3?.answer); // 6.3 Multiple analyses of the data? - const q4 = normalizeAnswer(answers.d6_4?.answer); // 6.4 Multiple subgroups? - - // Must have Q1 to start - if (q1 === null || q1 === undefined) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // Q1 -> if Y/PY -> LOW (terminal, no selection questions needed) - if (isYesPY(q1)) { - return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D6.R1' }; - } - - // Q1 -> if N/PN/NI -> SEL (aggregated from 6.2-6.4) - if (isNoPPNNI(q1)) { - // Check if selection questions are answered - const selectionQuestions = [q2, q3, q4]; - const allSelectionAnswered = selectionQuestions.every(a => a !== null && a !== undefined); - - if (!allSelectionAnswered) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - // Count Y/PY and NI among selection questions - const yesCount = selectionQuestions.filter(a => isYesPY(a)).length; - const hasNI = selectionQuestions.some(a => a === 'NI'); - const allNI = selectionQuestions.every(a => a === 'NI'); - const allNPN = selectionQuestions.every(a => isNoPPN(a)); - - // SEL -> if All N/PN -> LOW - if (allNPN) { - return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D6.R2' }; - } - - // SEL -> if At least one NI, but none Y/PY -> MOD - // (This means: hasNI is true, yesCount is 0, but not all NI) - if (yesCount === 0 && hasNI && !allNI) { - return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D6.R3' }; - } - - // SEL -> if One Y/PY, or all NI -> SER - if (yesCount === 1 || (yesCount === 0 && allNI)) { - return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D6.R4' }; - } - - // SEL -> if Two or more Y/PY -> CRIT - if (yesCount >= 2) { - return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D6.R5' }; - } - } - - // Incomplete - need more answers - return { judgement: null, isComplete: false, ruleId: null }; -} - -/** - * Main entry point: score a ROBINS-I domain - * - * @param {string} domainKey - e.g., 'domain1a', 'domain1b', 'domain2', etc. - * @param {Object} answers - The domain's answers object { questionKey: { answer, comment } } - * @param {Object} options - Additional options - * @param {boolean} options.isPerProtocol - Whether this is per-protocol analysis (for domain1) - * @returns {Object} { judgement, isComplete, ruleId } - */ -export function scoreRobinsDomain(domainKey, answers, _options = {}) { - if (!answers) { - return { judgement: null, isComplete: false, ruleId: null }; - } - - switch (domainKey) { - case 'domain1a': - return scoreDomain1A(answers); - case 'domain1b': - return scoreDomain1B(answers); - case 'domain2': - return scoreDomain2(answers); - case 'domain3': - return scoreDomain3(answers); - case 'domain4': - return scoreDomain4(answers); - case 'domain5': - return scoreDomain5(answers); - case 'domain6': - return scoreDomain6(answers); - default: - return { judgement: null, isComplete: false, ruleId: null }; - } -} - -/** - * Get effective domain judgement (respects manual override) - * - * @param {Object} domainState - The domain state { answers, judgement, judgementSource, direction } - * @param {Object} autoScore - Result from scoreRobinsDomain - * @returns {string|null} The effective judgement - */ -export function getEffectiveDomainJudgement(domainState, autoScore) { - if (domainState?.judgementSource === 'manual' && domainState?.judgement) { - return domainState.judgement; - } - return autoScore?.judgement || null; -} - -/** - * Score all active domains and return a summary - * - * @param {Object} checklistState - Full checklist state - * @returns {Object} { domains: { [key]: { auto, effective, source } }, overall } - */ -export function scoreAllDomains(checklistState) { - if (!checklistState) { - return { domains: {}, overall: null }; - } - - const isPerProtocol = checklistState.sectionC?.isPerProtocol || false; - const activeDomainKeys = - isPerProtocol ? - ['domain1b', 'domain2', 'domain3', 'domain4', 'domain5', 'domain6'] - : ['domain1a', 'domain2', 'domain3', 'domain4', 'domain5', 'domain6']; - - const domains = {}; - const effectiveJudgements = []; - - for (const domainKey of activeDomainKeys) { - const domainState = checklistState[domainKey]; - const auto = scoreRobinsDomain(domainKey, domainState?.answers, { isPerProtocol }); - const effective = getEffectiveDomainJudgement(domainState, auto); - const source = domainState?.judgementSource || 'auto'; - - domains[domainKey] = { - auto, - effective, - source, - isOverridden: source === 'manual' && effective !== auto.judgement, - }; - - if (effective) { - effectiveJudgements.push(effective); - } - } - - // Calculate overall from effective judgements - let overall = null; - if (effectiveJudgements.length === activeDomainKeys.length) { - if (effectiveJudgements.includes(JUDGEMENTS.CRITICAL)) { - overall = JUDGEMENTS.CRITICAL; - } else if (effectiveJudgements.includes(JUDGEMENTS.SERIOUS)) { - overall = JUDGEMENTS.SERIOUS; - } else if (effectiveJudgements.includes(JUDGEMENTS.MODERATE)) { - overall = JUDGEMENTS.MODERATE; - } else { - overall = JUDGEMENTS.LOW; - } - } - - return { domains, overall, isComplete: effectiveJudgements.length === activeDomainKeys.length }; -} - -// Overall risk of bias display strings for UI -// Note: ROBINS-I overall judgement cannot be plain 'Low' - confounding is always a concern -export const OVERALL_DISPLAY = { - LOW_EXCEPT_CONFOUNDING: 'Low risk of bias except for concerns about uncontrolled confounding', - MODERATE: 'Moderate risk', - SERIOUS: 'Serious risk', - CRITICAL: 'Critical risk', -}; - -/** - * Map internal overall judgement to the OVERALL_ROB_JUDGEMENTS display strings - */ -export function mapOverallJudgementToDisplay(judgement) { - switch (judgement) { - case JUDGEMENTS.LOW: - case JUDGEMENTS.LOW_EXCEPT_CONFOUNDING: - // ROBINS-I overall can only be 'Low except confounding' - plain Low is not valid - return OVERALL_DISPLAY.LOW_EXCEPT_CONFOUNDING; - case JUDGEMENTS.MODERATE: - return OVERALL_DISPLAY.MODERATE; - case JUDGEMENTS.SERIOUS: - return OVERALL_DISPLAY.SERIOUS; - case JUDGEMENTS.CRITICAL: - return OVERALL_DISPLAY.CRITICAL; - default: - return null; - } -} +// Main scoring functions +export const scoreRobinsDomain = robinsI.scoreRobinsDomain; +export const scoreAllDomains = robinsI.scoreAllDomains; +export const getEffectiveDomainJudgement = robinsI.getEffectiveDomainJudgement; +export const mapOverallJudgementToDisplay = robinsI.mapOverallJudgementToDisplay; diff --git a/packages/web/src/components/mock/MockIndex.jsx b/packages/web/src/components/mock/MockIndex.jsx deleted file mode 100644 index c2764da2e..000000000 --- a/packages/web/src/components/mock/MockIndex.jsx +++ /dev/null @@ -1,38 +0,0 @@ -import { A } from '@solidjs/router'; - -/** - * Mock Index - Landing page for visual-only mockups - * These are temporary wireframes with no data/logic - */ -export default function MockIndex() { - return ( -
-
-

Mock Pages

-

- These are visual-only wireframes with no data loading, Yjs logic, or backend integration. - They are temporary and will not reach production. -

-
- - -
- ); -} diff --git a/packages/web/src/components/mocks/AddStudiesInline.jsx b/packages/web/src/components/mocks/AddStudiesInline.jsx new file mode 100644 index 000000000..086fc2ff9 --- /dev/null +++ b/packages/web/src/components/mocks/AddStudiesInline.jsx @@ -0,0 +1,532 @@ +/** + * Add Studies Mock - Inline Progressive Disclosure + * + * An inline approach that lives directly on the page. + * Starts compact, expands with progressive disclosure. + * Good for quick additions without losing context. + */ + +import { For, Show, createSignal } from 'solid-js'; +import { + FiX, + FiCheck, + FiUpload, + FiFile, + FiFolder, + FiLink, + FiTrash2, + FiCheckCircle, + FiLoader, + FiChevronDown, + FiChevronUp, + FiExternalLink, + FiEdit2, + FiPlus, +} from 'solid-icons/fi'; + +// ============================================================================ +// MOCK DATA +// ============================================================================ + +const mockPendingStudies = [ + { + id: '1', + title: + 'Mindfulness-Based Stress Reduction for Chronic Low Back Pain: A Randomized Controlled Trial', + authors: 'Cherkin DC, Sherman KJ, Balderson BH, et al.', + journal: 'JAMA', + year: 2016, + doi: '10.1001/jama.2016.0086', + source: 'pdf', + fileName: 'cherkin-2016-mbsr.pdf', + hasPdf: true, + status: 'ready', + }, + { + id: '2', + title: 'Effects of Mindfulness-Based Cognitive Therapy on Body Awareness', + authors: 'de Jong M, Lazar SW, Hug K, et al.', + journal: 'Frontiers in Psychology', + year: 2016, + doi: '10.3389/fpsyg.2016.00967', + source: 'doi', + hasPdf: false, + status: 'ready', + }, +]; + +const mockExistingStudies = [ + { + id: 'e1', + title: 'A Pilot Study of Mindfulness Meditation for Pediatric Chronic Pain', + authors: 'Jastrowski Mano KE, et al.', + journal: 'Children', + year: 2019, + status: 'in-review', + }, +]; + +// ============================================================================ +// HELPER COMPONENTS +// ============================================================================ + +function SourceIcon(props) { + const colors = { + pdf: 'bg-blue-100 text-blue-600', + doi: 'bg-emerald-100 text-emerald-600', + reference: 'bg-purple-100 text-purple-600', + 'google-drive': 'bg-amber-100 text-amber-600', + }; + + const renderIcon = () => { + switch (props.source) { + case 'pdf': + return ; + case 'doi': + return ; + case 'reference': + return ; + case 'google-drive': + return ; + default: + return ; + } + }; + + return ( +
+ {renderIcon()} +
+ ); +} + +function QuickImportButton(props) { + return ( + + ); +} + +function ProcessingCard(props) { + return ( +
+
+ +
+
+

{props.message}

+

{props.detail}

+
+
+ ); +} + +// ============================================================================ +// INLINE IMPORT SECTIONS +// ============================================================================ + +function DoiInputSection(props) { + const [input, setInput] = createSignal(''); + const [isSearching, setIsSearching] = createSignal(false); + + return ( +
+
+

+ + DOI / PMID Lookup +

+ +
+
+ setInput(e.target.value)} + placeholder='10.1001/jama.2016.0086 or PMID:26903338' + class='flex-1 rounded-lg border border-slate-200 px-3 py-2 text-sm placeholder-slate-400 focus:border-violet-300 focus:ring-2 focus:ring-violet-100 focus:outline-none' + /> + +
+

+ Paste multiple identifiers separated by commas or newlines +

+
+ ); +} + +function ReferenceUploadSection(props) { + const [dragOver, setDragOver] = createSignal(false); + + return ( +
+
+

+ + Reference Manager Import +

+ +
+
{ + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={() => setDragOver(false)} + > + +

Drop files or click to browse

+

RIS, BibTeX (.bib), EndNote XML

+
+
+ ); +} + +function GoogleDriveSection(props) { + const [connected, setConnected] = createSignal(false); + + return ( +
+
+

+ + + + Google Drive +

+ +
+ setConnected(true)} + > + Connect Google Drive + + } + > +
+
+ + Connected +
+ +
+
+
+ ); +} + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export default function AddStudiesInline() { + const [dragOver, setDragOver] = createSignal(false); + const [expanded, setExpanded] = createSignal(false); + const [activeImport, setActiveImport] = createSignal(null); // 'doi', 'reference', 'drive' + const [pendingStudies, setPendingStudies] = createSignal(mockPendingStudies); + const [isProcessing, setIsProcessing] = createSignal(false); + + const toggleImport = type => { + setActiveImport(prev => (prev === type ? null : type)); + setExpanded(true); + }; + + const removeStudy = id => { + setPendingStudies(prev => prev.filter(s => s.id !== id)); + }; + + const addAllStudies = () => { + // Would add to project + setPendingStudies([]); + setExpanded(false); + }; + + return ( +
+ {/* Demo Header */} +
+
+

Add Studies - Inline Approach

+

Progressive disclosure, lives directly on the page

+
+
+ + {/* Main Content */} +
+ {/* ADD STUDIES SECTION */} +
+ {/* Primary Drop Zone */} +
{ + e.preventDefault(); + setDragOver(true); + setExpanded(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={() => { + setDragOver(false); + setIsProcessing(true); + }} + > +
+
+ +
+
+

+ + Drop to upload + +

+

+ Drop PDFs here or use other import options below +

+
+ +
+ + {/* Quick Import Buttons */} +
+ Or import from: + } + label='DOI / PMID' + active={activeImport() === 'doi'} + onClick={() => toggleImport('doi')} + /> + } + label='Reference File' + active={activeImport() === 'reference'} + onClick={() => toggleImport('reference')} + /> + + + + } + label='Google Drive' + active={activeImport() === 'drive'} + onClick={() => toggleImport('drive')} + /> +
+
+ + {/* Expandable Import Sections */} + +
+ + setActiveImport(null)} /> + + + setActiveImport(null)} /> + + + setActiveImport(null)} /> + +
+
+ + {/* Processing Indicator */} + +
+ +
+
+ + {/* Pending Studies (Staging Area) */} + 0}> +
+ */} + } + > + + +
+ + + +
+ + {study => ( +
+ +
+

+ {study.title} +

+

{study.authors}

+
+ + {study.journal} + + + ({study.year}) + + + + + DOI + + + + + + PDF + + +
+
+
+ + +
+
+ )} +
+
+
+
+ + + {/* Deduplication Info */} + 0}> +
+
+ + No duplicates detected with existing studies +
+
+
+
+ + {/* EXISTING STUDIES (for context) */} +
+

+ Existing Studies ({mockExistingStudies.length}) +

+
+ + {study => ( +
+
+ +
+
+

{study.title}

+

+ {study.journal} ({study.year}) +

+
+ + {study.status} + +
+ )} +
+
+
+
+ + {/* Styles */} + + + ); +} diff --git a/packages/web/src/components/mocks/AddStudiesPanel.jsx b/packages/web/src/components/mocks/AddStudiesPanel.jsx new file mode 100644 index 000000000..edb287ea6 --- /dev/null +++ b/packages/web/src/components/mocks/AddStudiesPanel.jsx @@ -0,0 +1,554 @@ +/** + * Add Studies Mock - Slide-over Panel with Tabs + * + * A slide-over panel approach where all import options are available via tabs. + * Shows real-time deduplication and metadata enrichment as studies are added. + * Studies queue up in a staging area before final confirmation. + */ + +import { For, Show, createSignal, createMemo } from 'solid-js'; +import { + FiX, + FiCheck, + FiUpload, + FiSearch, + FiFile, + FiFolder, + FiLink, + FiTrash2, + FiAlertCircle, + FiCheckCircle, + FiLoader, + FiChevronDown, + FiExternalLink, + FiEdit2, + FiRefreshCw, + FiPlus, + FiArrowRight, + FiCopy, + FiInfo, +} from 'solid-icons/fi'; + +// ============================================================================ +// MOCK DATA +// ============================================================================ + +const mockStagedStudies = [ + { + id: '1', + title: 'Mindfulness-Based Stress Reduction for Chronic Low Back Pain', + authors: 'Cherkin DC, Sherman KJ, Balderson BH, et al.', + journal: 'JAMA', + year: 2016, + doi: '10.1001/jama.2016.0086', + source: 'pdf', + fileName: 'cherkin-2016-mbsr.pdf', + hasPdf: true, + status: 'ready', + metadataScore: 100, + }, + { + id: '2', + title: 'Effects of Mindfulness-Based Cognitive Therapy on Body Awareness', + authors: 'de Jong M, Lazar SW, Hug K, et al.', + journal: 'Frontiers in Psychology', + year: 2016, + doi: '10.3389/fpsyg.2016.00967', + source: 'doi', + hasPdf: false, + status: 'ready', + metadataScore: 100, + }, + { + id: '3', + title: 'Scanning PDF for metadata...', + authors: null, + journal: null, + year: null, + doi: null, + source: 'pdf', + fileName: 'mindfulness-study-2019.pdf', + hasPdf: true, + status: 'processing', + metadataScore: 0, + }, +]; + +// ============================================================================ +// HELPER COMPONENTS +// ============================================================================ + +function SourceBadge(props) { + const config = { + pdf: { label: 'PDF', color: 'bg-blue-100 text-blue-700', icon: FiFile }, + reference: { label: 'RIS', color: 'bg-purple-100 text-purple-700', icon: FiFolder }, + doi: { label: 'DOI', color: 'bg-emerald-100 text-emerald-700', icon: FiLink }, + pmid: { label: 'PMID', color: 'bg-teal-100 text-teal-700', icon: FiLink }, + 'google-drive': { label: 'Drive', color: 'bg-amber-100 text-amber-700', icon: FiFolder }, + }; + const c = config[props.source] || { label: props.source, color: 'bg-slate-100 text-slate-600', icon: FiFile }; + const Icon = c.icon; + + return ( + + + {c.label} + + ); +} + +function MetadataScoreRing(props) { + const circumference = 2 * Math.PI * 12; + const offset = circumference - (props.score / 100) * circumference; + const color = props.score >= 80 ? '#10b981' : props.score >= 50 ? '#f59e0b' : '#ef4444'; + + return ( +
+ + + + + + {props.score} + +
+ ); +} + +function DuplicateAlert(props) { + return ( +
+
+ +
+

Duplicate detected

+

+ This appears to match "{props.matchTitle}" already in your staging area. + We'll merge the metadata automatically. +

+
+ +
+
+ ); +} + +// ============================================================================ +// TAB CONTENT COMPONENTS +// ============================================================================ + +function PdfUploadTab() { + const [dragOver, setDragOver] = createSignal(false); + + return ( +
+
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={() => setDragOver(false)} + > +
+
+ +
+
+

Drop PDF files here

+

or click to browse

+
+
+
+ +
+
+ +

+ We'll automatically extract metadata (title, authors, DOI) from your PDFs. + If we find a DOI, we'll enrich with data from CrossRef and PubMed. +

+
+
+
+ ); +} + +function DoiLookupTab() { + const [input, setInput] = createSignal(''); + const [isSearching, setIsSearching] = createSignal(false); + + return ( +
+
+ +
+ setInput(e.target.value)} + placeholder="10.1001/jama.2016.0086 or 26903338" + class="flex-1 rounded-lg border border-slate-200 px-3 py-2.5 text-sm placeholder-slate-400 focus:border-violet-300 focus:outline-none focus:ring-2 focus:ring-violet-100" + /> + +
+
+ +
+
+
+
+
+ or paste multiple +
+
+ +
+