diff --git a/.claude/settings.json b/.claude/settings.json
index 24448ed81..c12f94c5d 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,6 +1,7 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true,
- "plugin-dev@claude-plugins-official": true
+ "plugin-dev@claude-plugins-official": true,
+ "feature-dev@claude-plugins-official": true
}
}
diff --git a/packages/docs/audits/error-boundary-assessment.md b/packages/docs/audits/error-boundary-assessment.md
new file mode 100644
index 000000000..6fab6ba66
--- /dev/null
+++ b/packages/docs/audits/error-boundary-assessment.md
@@ -0,0 +1,156 @@
+# Error Boundary Implementation Assessment
+
+## What Was Done
+
+### 1. Centralized Error Logger (`packages/web/src/lib/errorLogger.js`)
+
+Created a new centralized error logging module that provides:
+
+- **`logError(error, context)`** - Logs caught exceptions with component/action context
+- **`logWarning(message, context)`** - Logs non-fatal issues (cache misses, degraded functionality)
+- **`logInfo(message, context)`** - Logs important state transitions
+- **`bestEffort(promise, context)`** - Wraps operations that can fail silently (cleanup, cache updates)
+- **`withErrorLogging(component, action)`** - Decorator for async functions
+- **`logAndRethrow(component, action)`** - For catch blocks that need to log but propagate errors
+
+All functions normalize errors through `@corates/shared` and include structured context (component name, action, timestamp).
+
+### 2. Enhanced Error Boundaries
+
+**AppErrorBoundary** (main component):
+
+- Now uses `logError` instead of raw `console.error`
+- Passes component name context for better debugging
+
+**SectionErrorBoundary** (new capabilities):
+
+- Added `name` prop for identifying which section failed
+- Added `onRetry` callback for custom retry logic (e.g., query invalidation)
+- Added `retryLabel` prop for custom button text
+- Error message now includes section name: "Error in Projects"
+
+### 3. Section-Level Error Isolation
+
+Wrapped major UI sections with `SectionErrorBoundary`:
+
+| Location | Sections Wrapped |
+| -------------------- | ------------------------------------------------------------- |
+| `Dashboard.jsx` | Projects, Local Appraisals |
+| `ProjectView.jsx` | Overview, All Studies, To-Do, Reconcile, Completed (each tab) |
+| `AdminLayout.jsx` | Admin content area |
+| `SettingsLayout.jsx` | Settings content area |
+
+### 4. Best-Effort Operation Cleanup
+
+Replaced silent `.catch(() => {})` patterns with `bestEffort()`:
+
+```javascript
+// Before
+clearFormState(type).catch(() => {});
+
+// After
+bestEffort(clearFormState(type), { operation: 'clearFormState', type });
+```
+
+This ensures failures are logged as warnings rather than completely swallowed.
+
+### 5. Admin Queries Refactor
+
+- Switched from raw `fetch` to `apiFetch` for consistent error handling
+- Extracted duplicate config into `ADMIN_QUERY_CONFIG` constant
+- Removed redundant comments
+
+---
+
+## Why It Was Done
+
+### Problem 1: Silent Failures
+
+Best-effort operations were using `.catch(() => {})` which completely swallowed errors. If cleanup routines started failing (e.g., IndexedDB quota exceeded), there was no visibility into the issue.
+
+### Problem 2: No Error Context
+
+When `AppErrorBoundary` caught errors, it logged them with minimal context. Debugging required correlating timestamps with user actions manually.
+
+### Problem 3: Catastrophic Failures
+
+A single component error (e.g., bad data in one project card) would crash the entire dashboard. Users lost access to all functionality instead of just the affected section.
+
+### Problem 4: No Monitoring Integration Point
+
+Error logging was scattered across the codebase. Integrating Sentry or similar would require finding and modifying dozens of locations.
+
+### Problem 5: Inconsistent Error Handling in Admin
+
+Admin queries used raw `fetch` while the rest of the app used `apiFetch`, leading to inconsistent error normalization and handling.
+
+---
+
+## Next Steps
+
+### 1. Integrate Sentry (or Alternative)
+
+The `errorLogger.js` module includes commented placeholder code for Sentry integration. When ready:
+
+```javascript
+// In errorLogger.js, uncomment and configure:
+if (typeof window !== 'undefined' && window.Sentry) {
+ window.Sentry.captureException(error, {
+ tags: { component, action },
+ extra: context,
+ });
+}
+```
+
+**Why:** Centralized logging is only useful if errors are aggregated somewhere. Sentry provides alerting, deduplication, and release tracking.
+
+### 2. Add Error Boundaries to Remaining High-Risk Areas
+
+Current coverage is good for main layouts, but these areas should be considered:
+
+- Individual study cards in lists (prevent one bad study from hiding all)
+- Checklist domain sections (isolate domain rendering failures)
+- PDF viewer (already somewhat isolated, but could benefit from explicit boundary)
+- Modal/dialog content (prevent modal errors from crashing parent)
+
+**Why:** Finer-grained boundaries mean smaller blast radius. A corrupted study shouldn't hide the entire study list.
+
+### 3. Add User-Facing Error Reporting
+
+The current error UI shows "Try Again" but doesn't let users report issues. Consider:
+
+- "Report this issue" button that captures error context
+- Session replay integration for debugging user-reported issues
+- Error ID display so users can reference specific failures in support requests
+
+**Why:** Users encountering errors are a valuable signal. Making it easy to report helps identify edge cases.
+
+### 4. Add Error Boundary Recovery Strategies
+
+`SectionErrorBoundary` now supports `onRetry` for custom recovery. Use this for:
+
+- Query-backed sections: invalidate and refetch on retry
+- WebSocket sections: reconnect on retry
+- Form sections: restore from draft state on retry
+
+**Why:** Generic "reset and re-render" often fails for the same reason. Section-specific recovery can actually fix the issue.
+
+### 5. Add Error Metrics/Analytics
+
+Track error rates over time:
+
+- Errors per session
+- Errors by component/section
+- Error recovery success rate (did retry work?)
+
+**Why:** Helps identify regressions. If error rate spikes after a deploy, you know something broke.
+
+### 6. Consider Suspense Boundaries
+
+SolidJS supports Suspense for async loading. Combining error boundaries with suspense boundaries would provide:
+
+- Loading states during data fetch
+- Error states on fetch failure
+- Smooth transitions between states
+
+**Why:** Currently some components handle their own loading/error states inconsistently. Suspense + ErrorBoundary provides a unified pattern.
diff --git a/packages/docs/audits/rob2-checklist-implementation.md b/packages/docs/audits/rob2-checklist-implementation.md
new file mode 100644
index 000000000..59ca1eaea
--- /dev/null
+++ b/packages/docs/audits/rob2-checklist-implementation.md
@@ -0,0 +1,182 @@
+# ROB-2 Checklist Implementation
+
+**Date:** 2026-01-10
+**Branch:** `262-add-rob-2`
+**Status:** Core implementation complete
+
+## Overview
+
+Implemented the RoB 2 (Risk of Bias 2) checklist for assessing risk of bias in randomized trials. This is the third checklist type in CoRATES, following AMSTAR2 and ROBINS-I.
+
+ROB-2 is the Cochrane Collaboration's tool for assessing risk of bias in randomized controlled trials. It evaluates bias across 5 domains with signalling questions that feed into algorithmic judgements.
+
+## What Was Accomplished
+
+### Shared Package (`@corates/shared`)
+
+Created the core ROB-2 logic in `packages/shared/src/checklists/rob2/`:
+
+| File | Purpose |
+| ------------ | ----------------------------------------------------------------------------------- |
+| `schema.ts` | Question definitions, domain structures, response types, constants |
+| `scoring.ts` | Decision algorithms for each domain (from official ROB-2 decision diagrams) |
+| `create.ts` | Factory function `createROB2Checklist()` |
+| `answers.ts` | Utilities: `scoreROB2Checklist`, `isROB2Complete`, `getAnswers`, `getDomainSummary` |
+| `index.ts` | Module exports |
+
+**Key schema elements:**
+
+- 5 domains with signalling questions
+- Domain 2 has two variants: 2a (effect of assignment/ITT) and 2b (effect of adhering/per-protocol)
+- Response types: Y (Yes), PY (Probably Yes), PN (Probably No), N (No), NI (No Information), NA (Not Applicable)
+- Judgement levels: Low, Some concerns, High
+- Preliminary section for study metadata and aim selection
+
+### UI Components (`packages/web`)
+
+Created components in `packages/web/src/components/checklist/ROB2Checklist/`:
+
+| Component | Purpose |
+| ------------------------ | ------------------------------------------------- |
+| `ROB2Checklist.jsx` | Main orchestrating component |
+| `PreliminarySection.jsx` | Study design, aims, interventions, sources |
+| `DomainSection.jsx` | Individual domain with questions and auto-scoring |
+| `SignallingQuestion.jsx` | Response buttons for each question |
+| `DomainJudgement.jsx` | Judgement display badges |
+| `ScoringSummary.jsx` | Compact summary strip with domain chips |
+| `OverallSection.jsx` | Final overall risk of bias section |
+| `checklist.js` | Helper functions and re-exports |
+| `checklist-map.js` | Schema re-exports from shared package |
+| `index.js` | Module entry point |
+
+### Yjs Integration
+
+Created `packages/web/src/primitives/useProject/checklists/handlers/rob2.js`:
+
+- `ROB2Handler` class for real-time collaborative editing
+- Methods: `extractAnswersFromTemplate`, `createAnswersYMap`, `serializeAnswers`, `updateAnswer`, `getTextGetter`
+
+### Registry Integration
+
+Modified files to register ROB-2:
+
+- `packages/web/src/checklist-registry/types.js` - Added ROB2 type constant and metadata
+- `packages/web/src/checklist-registry/index.js` - Registered scoring and creation functions
+- `packages/web/src/primitives/useProject/checklists/index.js` - Added handler and `getRob2Text()`
+- `packages/web/src/components/checklist/GenericChecklist.jsx` - Added ROB2Checklist rendering
+- `packages/shared/package.json` - Added export paths for checklists
+
+## Key Features
+
+### Auto-Scoring
+
+Domain judgements are automatically calculated from signalling question responses using the official ROB-2 decision algorithms. The overall risk of bias is then derived from all domain judgements:
+
+- If any domain is "High" -> Overall is "High"
+- If any domain is "Some concerns" (and none High) -> Overall is "Some concerns"
+- If all domains are "Low" -> Overall is "Low"
+
+### Domain 2 Variants
+
+The preliminary section includes an "aim" selection that determines which Domain 2 variant to show:
+
+- **Assignment (ITT)**: Shows Domain 2a - Effect of assignment to intervention
+- **Adhering (per-protocol)**: Shows Domain 2b - Effect of adhering to intervention
+
+### Collaborative Editing
+
+Full Yjs integration enables real-time collaboration:
+
+- All text fields (experimental intervention, comparator, numerical result) are Y.Text
+- Signalling question responses sync across users
+- Domain judgements update automatically as questions are answered
+
+## File Structure
+
+```
+packages/shared/src/checklists/rob2/
+ schema.ts # Questions, domains, constants
+ scoring.ts # Decision algorithms
+ create.ts # Factory function
+ answers.ts # Answer utilities
+ index.ts # Exports
+
+packages/web/src/components/checklist/ROB2Checklist/
+ ROB2Checklist.jsx
+ PreliminarySection.jsx
+ DomainSection.jsx
+ SignallingQuestion.jsx
+ DomainJudgement.jsx
+ ScoringSummary.jsx
+ OverallSection.jsx
+ checklist.js
+ checklist-map.js
+ index.js
+
+packages/web/src/primitives/useProject/checklists/handlers/
+ rob2.js # Yjs handler
+```
+
+## Verification
+
+- Build passes: `pnpm --filter web build`
+- Type check passes: `pnpm --filter @corates/shared typecheck`
+- No ROB2-related lint errors
+- Unit tests pass: `pnpm --filter @corates/shared test` (64 ROB-2 tests)
+
+## Testing
+
+Unit tests are located at `packages/shared/src/checklists/__tests__/rob2.test.ts` and cover:
+
+- `createROB2Checklist` - Factory function validation and initialization
+- `scoreRob2Domain` - All decision tree paths for each domain:
+ - Domain 1 (Randomization): 8 test cases covering all paths
+ - Domain 2a (Assignment/ITT): 5 test cases including Part 1/Part 2 combination
+ - Domain 2b (Adhering): 5 test cases covering major paths
+ - Domain 3 (Missing data): 5 test cases
+ - Domain 4 (Measurement): 8 test cases including NI branches
+ - Domain 5 (Selection): 6 test cases
+- `scoreAllDomains` - Overall calculation with different aim selections
+- `scoreROB2Checklist` - High-level scoring
+- `isROB2Complete` - Completion detection
+- `getAnswers` - Answer extraction
+
+Run tests with: `pnpm --filter @corates/shared test`
+
+## Next Steps
+
+### Immediate
+
+1. **Integration Testing** - Test the full flow in the browser:
+ - Create a new ROB-2 checklist from the study view
+ - Complete preliminary section and verify Domain 2 variant switching
+ - Answer signalling questions and verify auto-scoring
+ - Test collaborative editing with multiple users
+
+### Short-term
+
+2. **Question Notes** - Add support for free-text notes on individual signalling questions (similar to AMSTAR2)
+3. **Direction of Bias** - Implement predicted direction of bias per domain (currently only overall)
+4. **Export/Import** - Add CSV export functionality (similar to AMSTAR2)
+5. **Reconciliation** - Implement checklist comparison and reconciliation for ROB-2
+
+### Future Enhancements
+
+6. **Validation Warnings** - Show warnings for incomplete or inconsistent responses
+7. **Conditional Questions** - Some questions should be skipped based on earlier answers (currently all shown)
+8. **Help Text** - Add inline help text for signalling questions from the official guidance document
+9. **Traffic Light Visualization** - Add the standard ROB-2 traffic light plot for visualizing results
+
+## Decision Diagram Sources
+
+The scoring algorithms were implemented from the decision diagrams in:
+
+- `packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/`
+
+These files contain the official ROB-2 decision algorithms that determine domain judgements based on signalling question responses.
+
+## References
+
+- [RoB 2 Tool (Official)](https://www.riskofbias.info/welcome/rob-2-0-tool)
+- [Cochrane Handbook Chapter 8](https://training.cochrane.org/handbook/current/chapter-08)
+- [RoB 2 Detailed Guidance Document](https://drive.google.com/file/d/19R9savfPdCHC8XLz2iiMvL_71lPJERWK/view)
diff --git a/packages/docs/plans/robins-i-smart-flow.md b/packages/docs/plans/robins-i-smart-flow.md
deleted file mode 100644
index d78a5d27c..000000000
--- a/packages/docs/plans/robins-i-smart-flow.md
+++ /dev/null
@@ -1,327 +0,0 @@
-# ROBINS-I Smart Flow UI Plan
-
-This plan outlines the implementation of smart early-exit detection for the ROBINS-I checklist UI. The scoring engine already has deterministic decision trees that can terminate early. This enhancement surfaces that intelligence in the UI to guide users through the checklist more efficiently.
-
-## Problem Statement
-
-Currently, ROBINS-I domains require users to answer all signalling questions sequentially, even when the scoring logic has already reached a definitive judgement. This wastes time and creates confusion when certain answer combinations make subsequent questions irrelevant.
-
-### Example: Domain 5 (Outcome Measurement Bias)
-
-```
-Q1: "Was outcome measured in a way likely to be influenced by knowledge of intervention?"
- -> If Y/PY: Judgement = SERIOUS (complete) -- Q2 and Q3 are irrelevant
-
-Q2: "Could outcome assessor's awareness of intervention status influence the assessment?"
- -> If N/PN: Judgement = LOW (complete) -- Q3 is irrelevant
-
-Q3: "Were there systematic differences in outcome assessment?"
- -> Final path completes based on Q3 answer
-```
-
-When a user answers Q1 with "Yes", the scoring is complete at "Serious" risk. Questions 2 and 3 are now irrelevant, but the UI shows no indication of this.
-
----
-
-## Current State Analysis
-
-### Scoring Engine ([scoring.ts](packages/shared/src/checklists/robins-i/scoring.ts))
-
-The scoring engine is deterministic and returns:
-
-```typescript
-interface ScoringResult {
- judgement: Judgement | null; // The calculated judgement (or null if incomplete)
- isComplete: boolean; // Whether enough answers exist to determine judgement
- ruleId: string | null; // Which decision rule was matched (e.g., 'D5.R1')
-}
-```
-
-Key insight: when `isComplete === true`, remaining questions in that domain are not needed for the scoring decision. **We already have this data - no new utilities needed.**
-
-### UI Components
-
-| Component | Location | Purpose |
-| -------------------- | -------------------------------------------------------------- | -------------------------------------- |
-| `ROBINSIChecklist` | `components/checklist/ROBINSIChecklist/ROBINSIChecklist.jsx` | Main checklist container |
-| `DomainSection` | `components/checklist/ROBINSIChecklist/DomainSection.jsx` | Domain with questions and judgement |
-| `SignallingQuestion` | `components/checklist/ROBINSIChecklist/SignallingQuestion.jsx` | Individual question with radio buttons |
-
-### Current Flow
-
-1. User expands a domain section
-2. All questions are displayed equally
-3. User answers questions top-to-bottom
-4. Auto-scoring calculates judgement in real-time via `scoreRobinsDomain()`
-5. No indication of which questions are still relevant
-
----
-
-## Requirements
-
-### Must Have
-
-1. **Visual indication when a domain completes early** - Users should clearly see that remaining questions are optional
-2. **Gray out / de-emphasize skippable questions** - Reduced opacity or visual treatment for questions that won't affect the score
-3. **Allow users to still answer skipped questions** - Questions should remain editable for documentation purposes
-4. **Clear messaging explaining why questions are skipped** - Brief explanation when early exit occurs
-
-### Nice to Have
-
-1. **Progressive disclosure** - Hide optional questions by default, expandable if user wants to document
-2. **Reconciliation view awareness** - Indicate skipped questions in the reconciliation UI
-
-### Non-Goals
-
-1. Changing the scoring logic itself
-2. Preventing users from answering any questions
-3. Different behavior between local and synced checklists
-
----
-
-## Technical Design
-
-### Core Approach: Leverage Existing Scoring
-
-The scoring engine already returns `isComplete: true` when a judgement is determined. We don't need a separate skip-detection module - just use what we have:
-
-```jsx
-// In DomainSection.jsx - already exists:
-const autoScore = createMemo(() => {
- return scoreRobinsDomain(props.domainKey, props.domainState?.answers);
-});
-
-// Add these simple derived signals:
-const isEarlyComplete = () => autoScore().isComplete && autoScore().judgement !== null;
-
-const isQuestionSkippable = qKey => {
- return isEarlyComplete() && !props.domainState?.answers?.[qKey]?.answer;
-};
-```
-
-This is ~5 lines of code that reuses the existing scoring infrastructure.
-
-### UI Component Updates
-
-#### 1. Update `DomainSection.jsx`
-
-Add early completion detection and pass to questions:
-
-```jsx
-export function DomainSection(props) {
- // Existing auto-score memo
- const autoScore = createMemo(() => {
- return scoreRobinsDomain(props.domainKey, props.domainState?.answers);
- });
-
- // NEW: Check if scoring completed early
- const isEarlyComplete = () => autoScore().isComplete && autoScore().judgement !== null;
-
- // NEW: Check if a specific question can be skipped
- const isQuestionSkippable = qKey => {
- return isEarlyComplete() && !props.domainState?.answers?.[qKey]?.answer;
- };
-
- return (
-
- {/* NEW: Early completion banner */}
-
-
-
-
-
-
Scoring Complete
-
- Remaining questions are optional but can still be answered for documentation.
-
-
-
-
-
-
- {/* Pass skip status to each question */}
-
- {([qKey, qDef]) => (
- handleQuestionUpdate(qKey, newAnswer)}
- disabled={props.disabled}
- isSkippable={isQuestionSkippable(qKey)}
- // ... other existing props
- />
- )}
-
-
- );
-}
-```
-
-#### 2. Update `SignallingQuestion.jsx`
-
-Add visual states for skippable questions:
-
-```jsx
-export function SignallingQuestion(props) {
- // props.isSkippable - NEW prop indicating this question can be skipped
-
- return (
-
+
+ ROB-2 Resources
+
+
+ Official guidance and documentation for the RoB 2 assessment tool.
+
+
+
+
+
+
+
+
+
+
+
+
About Auto Scoring
+
+ This tool automatically calculates domain judgements based on your signalling
+ question responses, following the official RoB 2 decision algorithms.
+
+ );
+}
+
+export default SignallingQuestion;
diff --git a/packages/web/src/components/checklist/ROB2Checklist/checklist-map.js b/packages/web/src/components/checklist/ROB2Checklist/checklist-map.js
new file mode 100644
index 000000000..17674e416
--- /dev/null
+++ b/packages/web/src/components/checklist/ROB2Checklist/checklist-map.js
@@ -0,0 +1,47 @@
+/**
+ * ROB-2 Checklist Map
+ *
+ * Re-exports schema from shared package for component use.
+ */
+
+import { rob2 } from '@corates/shared/checklists';
+
+// Response types and labels
+export const RESPONSE_TYPES = rob2.RESPONSE_TYPES;
+export const RESPONSE_LABELS = rob2.RESPONSE_LABELS;
+export const getResponseOptions = rob2.getResponseOptions;
+
+// Judgements
+export const JUDGEMENTS = rob2.JUDGEMENTS;
+
+// Bias directions
+export const BIAS_DIRECTIONS = rob2.BIAS_DIRECTIONS;
+
+// Study design options
+export const STUDY_DESIGNS = rob2.STUDY_DESIGNS;
+
+// Aim options
+export const AIM_OPTIONS = rob2.AIM_OPTIONS;
+
+// Deviation options
+export const DEVIATION_OPTIONS = rob2.DEVIATION_OPTIONS;
+
+// Information sources
+export const INFORMATION_SOURCES = rob2.INFORMATION_SOURCES;
+
+// Preliminary section schema
+export const PRELIMINARY_SECTION = rob2.PRELIMINARY_SECTION;
+
+// Domain definitions
+export const DOMAIN_1 = rob2.DOMAIN_1;
+export const DOMAIN_2A = rob2.DOMAIN_2A;
+export const DOMAIN_2B = rob2.DOMAIN_2B;
+export const DOMAIN_3 = rob2.DOMAIN_3;
+export const DOMAIN_4 = rob2.DOMAIN_4;
+export const DOMAIN_5 = rob2.DOMAIN_5;
+export const ROB2_CHECKLIST = rob2.ROB2_CHECKLIST;
+
+// Domain key helpers
+export const getDomainKeys = rob2.getDomainKeys;
+export const getActiveDomainKeys = rob2.getActiveDomainKeys;
+export const getDomainQuestions = rob2.getDomainQuestions;
diff --git a/packages/web/src/components/checklist/ROB2Checklist/checklist.js b/packages/web/src/components/checklist/ROB2Checklist/checklist.js
new file mode 100644
index 000000000..7d3bcbd0e
--- /dev/null
+++ b/packages/web/src/components/checklist/ROB2Checklist/checklist.js
@@ -0,0 +1,63 @@
+/**
+ * ROB-2 Checklist Utilities
+ *
+ * Helper functions for ROB-2 checklist operations.
+ * Re-exports from @corates/shared while maintaining the expected interface.
+ */
+
+import { rob2 } from '@corates/shared/checklists';
+
+// Re-export functions from shared package with original names for registry compatibility
+export const createChecklist = rob2.createROB2Checklist;
+export const scoreChecklist = rob2.scoreROB2Checklist;
+export const isROB2Complete = rob2.isROB2Complete;
+export const getAnswers = rob2.getAnswers;
+export const getSelectedAnswer = rob2.getSelectedAnswer;
+export const getDomainSummary = rob2.getDomainSummary;
+export const scoreRob2Domain = rob2.scoreRob2Domain;
+export const scoreAllDomains = rob2.scoreAllDomains;
+export const createROB2Checklist = rob2.createROB2Checklist;
+
+/**
+ * Get smart scoring for a checklist (domain and overall scores)
+ */
+export function getSmartScoring(checklistState) {
+ if (!checklistState) {
+ return { domains: {}, overall: null, isComplete: false };
+ }
+
+ const result = scoreAllDomains(checklistState);
+
+ // Convert to expected format
+ const domains = {};
+ Object.entries(result.domains).forEach(([key, info]) => {
+ domains[key] = {
+ auto: info.auto?.judgement || null,
+ effective: info.judgement,
+ source: 'auto',
+ isOverridden: false,
+ };
+ });
+
+ return {
+ domains,
+ overall: result.overall,
+ isComplete: result.isComplete,
+ };
+}
+
+/**
+ * Map overall score to display format
+ */
+export function mapOverallJudgementToDisplay(score) {
+ switch (score) {
+ case 'Low':
+ return 'Low risk of bias';
+ case 'Some concerns':
+ return 'Some concerns';
+ case 'High':
+ return 'High risk of bias';
+ default:
+ return score;
+ }
+}
diff --git a/packages/web/src/components/checklist/ROB2Checklist/index.js b/packages/web/src/components/checklist/ROB2Checklist/index.js
new file mode 100644
index 000000000..aca34b9ee
--- /dev/null
+++ b/packages/web/src/components/checklist/ROB2Checklist/index.js
@@ -0,0 +1,22 @@
+/**
+ * ROB-2 Checklist Module
+ *
+ * Main entry point for ROB-2 checklist component and utilities.
+ */
+
+export { ROB2Checklist, default } from './ROB2Checklist.jsx';
+export { PreliminarySection } from './PreliminarySection.jsx';
+export { DomainSection } from './DomainSection.jsx';
+export { OverallSection } from './OverallSection.jsx';
+export { SignallingQuestion, ResponseLegend } from './SignallingQuestion.jsx';
+export { DomainJudgement, JudgementBadge } from './DomainJudgement.jsx';
+export { ScoringSummary } from './ScoringSummary.jsx';
+
+// Re-export scoring and utilities from checklist.js
+export {
+ createROB2Checklist as createChecklist,
+ scoreROB2Checklist as scoreChecklist,
+ getAnswers,
+ isROB2Complete as isComplete,
+ getSmartScoring,
+} from './checklist.js';
diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/1.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/checklist.js
similarity index 100%
rename from packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/1.md
rename to packages/web/src/components/checklist/ROB2Checklist/scoring/checklist.js
diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-1.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-1.md
new file mode 100644
index 000000000..26feefca9
--- /dev/null
+++ b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-1.md
@@ -0,0 +1,138 @@
+Signalling questions Elaboration Response options
+1.1 Was the allocation
+sequence random?
+
+Answer ‘Yes’ if a random component was used in the sequence generation process. Examples include
+computer-generated random numbers; reference to a random number table; coin tossing; shuffling cards
+or envelopes; throwing dice; or drawing lots. Minimization is generally implemented with a random
+element (at least when the scores are equal), so an allocation sequence that is generated using
+minimization should generally be considered to be random.
+Answer ‘No’ if no random element was used in generating the allocation sequence or the sequence is
+predictable. Examples include alternation; methods based on dates (of birth or admission); patient
+record numbers; allocation decisions made by clinicians or participants; allocation based on the
+availability of the intervention; or any other systematic or haphazard method.
+Answer ‘No information’ if the only information about randomization methods is a statement that the
+study is randomized.
+In some situations a judgement may be made to answer ‘Probably no’ or ‘Probably yes’. For example, , in
+the context of a large trial run by an experienced clinical trials unit, absence of specific information about
+generation of the randomization sequence, in a paper published in a journal with rigorously enforced word
+count limits, is likely to result in a response of ‘Probably yes’ rather than ‘No information’. Alternatively, if
+other (contemporary) trials by the same investigator team have clearly used non-random sequences, it
+might be reasonable to assume that the current study was done using similar methods.
+
+Y/PY/PN/N/NI
+
+1.2 Was the allocation
+sequence concealed until
+participants were
+enrolled and assigned to
+interventions?
+
+Answer ‘Yes’ if the trial used any form of remote or centrally administered method to allocate
+interventions to participants, where the process of allocation is controlled by an external unit or
+organization, independent of the enrolment personnel (e.g. independent central pharmacy, telephone or
+internet-based randomization service providers).
+Answer ‘Yes’ if envelopes or drug containers were used appropriately. Envelopes should be opaque,
+sequentially numbered, sealed with a tamper-proof seal and opened only after the envelope has been
+irreversibly assigned to the participant. Drug containers should be sequentially numbered and of
+identical appearance, and dispensed or administered only after they have been irreversibly assigned to
+the participant. This level of detail is rarely provided in reports, and a judgement may be required to
+justify an answer of ‘Probably yes’ or ‘Probably no’.
+Answer ‘No’ if there is reason to suspect that the enrolling investigator or the participant had knowledge
+of the forthcoming allocation.
+
+Y/PY/PN/N/NI
+
+5
+
+1.3 Did baseline
+differences between
+intervention groups
+suggest a problem with
+the randomization
+process?
+
+Note that differences that are compatible with chance do not lead to a risk of bias. A small number of
+differences identified as ‘statistically significant’ at the conventional 0.05 threshold should usually be
+considered to be compatible with chance.
+Answer ‘No’ if no imbalances are apparent or if any observed imbalances are compatible with chance.
+Answer ‘Yes’ if there are imbalances that indicate problems with the randomization process, including:
+(1) substantial differences between intervention group sizes, compared with the intended allocation
+ratio;
+or
+(2) a substantial excess in statistically significant differences in baseline characteristics between
+intervention groups, beyond that expected by chance; or
+(3) imbalance in one or more key prognostic factors, or baseline measures of outcome variables,
+that is very unlikely to be due to chance and for which the between-group difference is big
+enough to result in bias in the intervention effect estimate.
+Also answer ‘Yes’ if there are other reasons to suspect that the randomization process was problematic:
+(4) excessive similarity in baseline characteristics that is not compatible with chance.
+Answer ‘No information’ when there is no useful baseline information available (e.g. abstracts, or studies
+that reported only baseline characteristics of participants in the final analysis).
+The answer to this question should not influence answers to questions 1.1 or 1.2. For example, if the trial
+has large baseline imbalances, but authors report adequate randomization methods, questions 1.1 and
+1.2 should still be answered on the basis of the reported adequate methods, and any concerns about the
+
+imbalance should be raised in the answer to the question 1.3 and reflected in the domain-level risk-of-
+bias judgement.
+
+Trialists may undertake analyses that attempt to deal with flawed randomization by controlling for
+imbalances in prognostic factors at baseline. To remove the risk of bias caused by problems in the
+randomization process, it would be necessary to know, and measure, all the prognostic factors that were
+imbalanced at baseline. It is unlikely that all important prognostic factors are known and measured, so
+such analyses will at best reduce the risk of bias. If review authors wish to assess the risk of bias in a trial
+that controlled for baseline imbalances in order to mitigate failures of randomization, the study should
+be assessed using the ROBINS-I tool.
+
+Y/PY/PN/N/NI
+
+Risk-of-bias judgement See algorithm. Low / High / Some
+concerns
+
+6
+
+Algorithm for suggested judgement of risk of bias arising from the randomization process
+
+Optional: What is the
+predicted direction of
+bias arising from the
+randomization process?
+
+If the likely direction of bias can be predicted, it is helpful to state this. The direction might be
+characterized either as being towards (or away from) the null, or as being in favour of one of the
+interventions.
+
+NA / Favours
+experimental /
+Favours comparator /
+Towards null /Away
+from null /
+Unpredictable
+
+flowchart LR
+A["1.2 Allocation sequence concealed?"]
+
+ B["1.1 Allocation sequence random?"]
+ C1["1.3 Baseline imbalances suggest a problem?"]
+ C2["1.3 Baseline imbalances suggest a problem?"]
+
+ L["Low risk"]
+ M["Some concerns"]
+ H["High risk"]
+
+ %% Paths from allocation concealment
+ A -- "Y/PY" --> B
+ A -- "NI" --> C2
+ A -- "N/PN" --> H
+
+ %% Paths from random sequence
+ B -- "Y/PY/NI" --> C1
+ B -- "N/PN" --> M
+
+ %% Baseline imbalance (top branch)
+ C1 -- "N/PN/NI" --> L
+ C1 -- "Y/PY" --> M
+
+ %% Baseline imbalance (middle branch)
+ C2 -- "N/PN/NI" --> M
+ C2 -- "Y/PY" --> H
diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-2-a.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-2-a.md
new file mode 100644
index 000000000..444468d99
--- /dev/null
+++ b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-2-a.md
@@ -0,0 +1,206 @@
+Domain 2: Risk of bias due to deviations from the intended interventions (effect of assignment to intervention)
+Signalling questions Elaboration Response options
+2.1. Were participants
+aware of their assigned
+intervention during the
+trial?
+
+If participants are aware of their assigned intervention it is more likely that health-related behaviours will
+differ between the intervention groups. Blinding participants, most commonly through use of a placebo
+or sham intervention, may prevent such differences. If participants experienced side effects or toxicities
+that they knew to be specific to one of the interventions, answer this question ‘Yes’ or ‘Probably yes’.
+
+Y/PY/PN/N/NI
+
+2.2. Were carers and
+people delivering the
+interventions aware of
+participants' assigned
+intervention during the
+trial?
+
+If carers or people delivering the interventions are aware of the assigned intervention then its
+implementation, or administration of non-protocol interventions, may differ between the intervention
+groups. Blinding may prevent such differences. If participants experienced side effects or toxicities that
+carers or people delivering the interventions knew to be specific to one of the interventions, answer
+question ‘Yes’ or ‘Probably yes’. If randomized allocation was not concealed, then it is likely that carers
+and people delivering the interventions were aware of participants' assigned intervention during the
+trial.
+
+Y/PY/PN/N/NI
+
+8
+
+2.3. If Y/PY/NI to 2.1 or
+2.2: Were there
+deviations from the
+intended intervention
+that arose because of the
+trial context?
+
+For the effect of assignment to intervention, this domain assesses problems that arise when changes from
+assigned intervention that are inconsistent with the trial protocol arose because of the trial context. We
+use the term trial context to refer to effects of recruitment and engagement activities on trial participants
+and when trial personnel (carers or people delivering the interventions) undermine the implementation of
+the trial protocol in ways that would not happen outside the trial. For example, the process of securing
+informed consent may lead participants subsequently assigned to the comparator group to feel unlucky
+and therefore seek the experimental intervention, or other interventions that improve their prognosis.
+Answer ‘Yes’ or ‘Probably yes’ only if there is evidence, or strong reason to believe, that the trial context
+led to failure to implement the protocol interventions or to implementation of interventions not allowed
+by the protocol.
+Answer ‘No’ or ‘Probably no’ if there were changes from assigned intervention that are inconsistent with
+the trial protocol, such as non-adherence to intervention, but these are consistent with what could occur
+outside the trial context.
+Answer ‘No’ or ‘Probably no’ for changes to intervention that are consistent with the trial protocol, for
+example cessation of a drug intervention because of acute toxicity or use of additional interventions whose
+aim is to treat consequences of one of the intended interventions.
+If blinding is compromised because participants report side effects or toxicities that are specific to one of
+the interventions, answer ‘Yes’ or ‘Probably yes’ only if there were changes from assigned intervention
+that are inconsistent with the trial protocol and arose because of the trial context.
+The answer ‘No information’ may be appropriate, because trialists do not always report whether
+deviations arose because of the trial context.
+
+NA/Y/PY/PN/N/NI
+
+2.4 If Y/PY to 2.3: Were
+these deviations likely to
+have affected the
+outcome?
+
+Changes from assigned intervention that are inconsistent with the trial protocol and arose because of the
+trial context will impact on the intervention effect estimate if they affect the outcome, but not
+otherwise.
+
+NA/Y/PY/PN/N/NI
+
+9
+
+2.5. If Y/PY/NI to 2.4:
+Were these deviations
+from intended
+intervention balanced
+between groups?
+
+Changes from assigned intervention that are inconsistent with the trial protocol and arose because of the
+trial context are more likely to impact on the intervention effect estimate if they are not balanced
+between the intervention groups.
+
+NA/Y/PY/PN/N/NI
+
+2.6 Was an appropriate
+analysis used to estimate
+the effect of assignment
+to intervention?
+
+Both intention-to-treat (ITT) analyses and modified intention-to-treat (mITT) analyses excluding
+participants with missing outcome data should be considered appropriate. Both naïve ‘per-protocol’
+analyses (excluding trial participants who did not receive their assigned intervention) and ‘as treated’
+analyses (in which trial participants are grouped according to the intervention that they received, rather
+than according to their assigned intervention) should be considered inappropriate. Analyses excluding
+
+eligible trial participants post-randomization should also be considered inappropriate, but post-
+randomization exclusions of ineligible participants (when eligibility was not confirmed until after
+
+randomization, and could not have been influenced by intervention group assignment) can be
+considered appropriate.
+
+Y/PY/PN/N/NI
+
+2.7 If N/PN/NI to 2.6:
+Was there potential for a
+substantial impact (on
+the result) of the failure
+to analyse participants in
+the group to which they
+were randomized?
+
+This question addresses whether the number of participants who were analysed in the wrong
+intervention group, or excluded from the analysis, was sufficient that there could have been a substantial
+impact on the result. It is not possible to specify a precise rule: there may be potential for substantial
+impact even if fewer than 5% of participants were analysed in the wrong group or excluded, if the
+outcome is rare or if exclusions are strongly related to prognostic factors.
+
+NA/Y/PY/PN/N/NI
+
+Risk-of-bias judgement See algorithm. Low / High / Some
+concerns
+
+Optional: What is the
+predicted direction of
+bias due to deviations
+from intended
+interventions?
+
+If the likely direction of bias can be predicted, it is helpful to state this. The direction might be
+characterized either as being towards (or away from) the null, or as being in favour of one of the
+interventions.
+
+NA / Favours
+experimental / Favours
+comparator / Towards
+null /Away from null /
+Unpredictable
+
+flowchart LR
+%% -----------------------
+%% Part 1
+%% -----------------------
+subgraph P1["Part 1: Questions 2.1 to 2.5"]
+Q21["2.1 Participants aware of intervention?\n\n2.2 Personnel aware of intervention?"]
+Q23["2.3 Deviations that arose because of the trial context?"]
+Q24["2.4 Deviations affect outcome?"]
+Q25["2.5 Deviations balanced between groups?"]
+
+ P1Low["Low risk"]
+ P1Some["Some concerns"]
+ P1High["High risk"]
+
+ Q21 -- "Both N/PN" --> P1Low
+ Q21 -- "Either Y/PY/NI" --> Q23
+
+ Q23 -- "N/PN" --> P1Low
+ Q23 -- "NI" --> P1Some
+ Q23 -- "Y/PY" --> Q24
+
+ Q24 -- "N/PN" --> P1Some
+ Q24 -- "Y/PY/NI" --> Q25
+
+ Q25 -- "Y/PY" --> P1Some
+ Q25 -- "N/PN/NI" --> P1High
+ end
+
+ %% -----------------------
+ %% Part 2
+ %% -----------------------
+ subgraph P2["Part 2: Questions 2.6 & 2.7"]
+ Q26["2.6 Appropriate analysis to estimate the effect of assignment?"]
+ Q27["2.7 Substantial impact of the failure to analyse participants in randomized groups?"]
+
+ P2Low["Low risk"]
+ P2Some["Some concerns"]
+ P2High["High risk"]
+
+ Q26 -- "Y/PY" --> P2Low
+ Q26 -- "N/PN/NI" --> Q27
+
+ Q27 -- "N/PN" --> P2Some
+ Q27 -- "Y/PY/NI" --> P2High
+ end
+
+ %% -----------------------
+ %% Overall domain judgement
+ %% -----------------------
+ subgraph D["Criteria for the domain"]
+ DLow["Low risk"]
+ DSome["Some concerns"]
+ DHigh["High risk"]
+ end
+
+ P1Low --> DLow
+ P2Low --> DLow
+
+ P1Some --> DSome
+ P2Some --> DSome
+
+ P1High --> DHigh
+ P2High --> DHigh
diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-2-b.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-2-b.md
new file mode 100644
index 000000000..f1de23448
--- /dev/null
+++ b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-2-b.md
@@ -0,0 +1,157 @@
+Domain 2: Risk of bias due to deviations from the intended interventions (effect of adhering to intervention)
+Signalling questions Elaboration Response options
+2.1. Were participants
+aware of their assigned
+intervention during the
+trial?
+
+If participants are aware of their assigned intervention it is more likely that health-related behaviours will
+differ between the intervention groups. Blinding participants, most commonly through use of a placebo
+or sham intervention, may prevent such differences. If participants experienced side effects or toxicities
+that they knew to be specific to one of the interventions, answer this question ‘Yes’ or ‘Probably yes’.
+
+Y/PY/PN/N/NI
+
+2.2. Were carers and
+people delivering the
+interventions aware of
+participants' assigned
+intervention during the
+trial?
+
+If carers or people delivering the interventions are aware of the assigned intervention then its
+implementation, or administration of non-protocol interventions, may differ between the intervention
+groups. Blinding may prevent such differences. If participants experienced side effects or toxicities that
+carers or people delivering the interventions knew to be specific to one of the interventions, answer ‘Yes’
+or ‘Probably yes’. If randomized allocation was not concealed, then it is likely that carers and people
+delivering the interventions were aware of participants' assigned intervention during the trial.
+
+Y/PY/PN/N/NI
+
+2.3. [If applicable:] If
+Y/PY/NI to 2.1 or 2.2:
+
+Were important non-
+protocol interventions
+
+balanced across
+intervention groups?
+
+This question is asked only if the preliminary considerations specify that the assessment will address
+
+imbalance of important non-protocol interventions between intervention groups. Important non-
+protocol interventions are the additional interventions or exposures that: (1) are inconsistent with the
+
+trial protocol; (2) trial participants might receive with or after starting their assigned intervention; and (3)
+are prognostic for the outcome. Risk of bias will be higher if there is imbalance in such interventions
+between the intervention groups.
+
+NA/Y/PY/PN/N/NI
+
+2.4. [If applicable:] Were
+there failures in
+implementing the
+intervention that could
+have affected the
+outcome?
+
+This question is asked only if the preliminary considerations specify that the assessment will address
+failures in implementing the intervention that could have affected the outcome. Risk of bias will be
+higher if the intervention was not implemented as intended by, for example, the health care
+professionals delivering care. Answer ‘No’ or ‘Probably no’ if implementation of the intervention was
+successful for most participants.
+
+NA/Y/PY/PN/N/NI
+
+2.5. [If applicable:] Was
+there non-adherence to
+the assigned intervention
+regimen that could have
+affected participants’
+outcomes?
+
+This question is asked only if the preliminary considerations specify that the assessment will address non-
+adherence that could have affected participants’ outcomes. Non-adherence includes imperfect
+
+compliance with a sustained intervention, cessation of intervention, crossovers to the comparator
+intervention and switches to another active intervention. Consider available information on the
+proportion of study participants who continued with their assigned intervention throughout follow up,
+and answer ‘Yes’ or ‘Probably yes’ if the proportion who did not adhere is high enough to raise concerns.
+Answer ‘No’ for studies of interventions that are administered once, so that imperfect adherence is not
+possible, and all or most participants received the assigned intervention.
+
+NA/Y/PY/PN/N/NI
+
+12
+
+2.6. If N/PN/NI to 2.3, or
+Y/PY/NI to 2.4 or 2.5:
+Was an appropriate
+analysis used to estimate
+the effect of adhering to
+the intervention?
+
+Both ‘ naïve ‘per-protocol’ analyses (excluding trial participants who did not receive their allocated
+intervention) and ‘as treated’ analyses (comparing trial participants according to the intervention they
+actually received) will usually be inappropriate for estimating the effect of adhering to intervention (the
+‘per-protocol’ effect). However, it is possible to use data from a randomized trial to derive an unbiased
+estimate of the effect of adhering to intervention. Examples of appropriate methods include: (1)
+instrumental variable analyses to estimate the effect of receiving the assigned intervention in trials in
+which a single intervention, administered only at baseline and with all-or-nothing adherence, is compared
+with standard care; and (2) inverse probability weighting to adjust for censoring of participants who cease
+adherence to their assigned intervention, in trials of sustained treatment strategies. These methods
+depend on strong assumptions, which should be appropriate and justified if the answer to this question is
+‘Yes’ or ‘Probably yes’. It is possible that a paper reports an analysis based on such methods without
+reporting information on the deviations from intended intervention, but it would be hard to judge such an
+analysis to be appropriate in the absence of such information.
+If an important non-protocol intervention was administered to all participants in one intervention group,
+adjustments cannot be made to overcome this.
+Some examples of analysis strategies that would not be appropriate to estimate the effect of adhering to
+intervention are (i) ‘Intention to treat (ITT) analysis’, (ii) ‘per protocol analysis’, (iii) ‘as-treated analysis’,
+(iv) ‘analysis by treatment received’.
+
+NA/Y/PY/PN/N/NI
+
+Risk-of-bias judgement See algorithm. Low / High / Some
+concerns
+
+Optional: What is the
+predicted direction of
+bias due to deviations
+from intended
+interventions?
+
+If the likely direction of bias can be predicted, it is helpful to state this. The direction might be
+characterized either as being towards (or away from) the null, or as being in favour of one of the
+interventions.
+
+NA / Favours
+experimental / Favours
+comparator / Towards
+null /Away from null
+
+flowchart LR
+Q21["2.1 Participants aware of intervention?\n\n2.2 Personnel aware of intervention?"]
+Q23["2.3 Balanced non-protocol interventions?"]
+Q24["2.4 Failures in implementation affecting outcome?\n\n2.5 Non-adherence affecting outcome?"]
+Q26["2.6 Appropriate analysis to estimate the effect of adhering?"]
+
+ L["Low risk"]
+ M["Some concerns"]
+ H["High risk"]
+
+ %% Awareness
+ Q21 -- "Both N/PN" --> Q24
+ Q21 -- "Either Y/PY/NI" --> Q23
+
+ %% Balanced non-protocol interventions
+ Q23 -- "NA/Y/PY" --> Q24
+ Q23 -- "N/PN/NI" --> Q26
+
+ %% Failures / non-adherence
+ Q24 -- "Both NA/N/PN" --> L
+ Q24 -- "Either Y/PY/NI" --> Q26
+
+ %% Analysis appropriateness
+ Q26 -- "Y/PY" --> M
+ Q26 -- "N/PN/NI" --> H
diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-3.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-3.md
new file mode 100644
index 000000000..dfabfa4c5
--- /dev/null
+++ b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-3.md
@@ -0,0 +1,132 @@
+Domain 3: Risk of bias due to missing outcome data
+Signalling questions Elaboration Response options
+3.1 Were data for this
+outcome available for all,
+or nearly all, participants
+randomized?
+
+The appropriate study population for an analysis of the intention to treat effect is all randomized
+participants.
+“Nearly all” should be interpreted as that the number of participants with missing outcome data is
+sufficiently small that their outcomes, whatever they were, could have made no important difference to
+the estimated effect of intervention.
+For continuous outcomes, availability of data from 95% of the participants will often be sufficient. For
+dichotomous outcomes, the proportion required is directly linked to the risk of the event. If the observed
+number of events is much greater than the number of participants with missing outcome data, the bias
+would necessarily be small.
+Only answer ‘No information’ if the trial report provides no information about the extent of missing
+outcome data. This situation will usually lead to a judgement that there is a high risk of bias due to missing
+outcome data.
+Note that imputed data should be regarded as missing data, and not considered as ‘outcome data’ in
+the context of this question.
+
+Y/PY/PN/N/NI
+
+3.2 If N/PN/NI to 3.1: Is
+there evidence that the
+result was not biased by
+missing outcome data?
+
+Evidence that the result was not biased by missing outcome data may come from: (1) analysis methods
+that correct for bias; or (2) sensitivity analyses showing that results are little changed under a range of
+plausible assumptions about the relationship between missingness in the outcome and its true value.
+
+However, imputing the outcome variable, either through methods such as ‘last-observation-carried-
+forward’ or via multiple imputation based only on intervention group, should not be assumed to correct
+
+for bias due to missing outcome data.
+
+NA/Y/PY/PN/N
+
+3.3 If N/PN to 3.2: Could
+missingness in the
+outcome depend on its
+true value?
+
+If loss to follow up, or withdrawal from the study, could be related to participants’ health status, then it
+is possible that missingness in the outcome was influenced by its true value. However, if all missing
+outcome data occurred for documented reasons that are unrelated to the outcome then the risk of bias
+due to missing outcome data will be low (for example, failure of a measuring device or interruptions to
+routine data collection).
+In time-to-event analyses, participants censored during trial follow-up, for example because they
+withdrew from the study, should be regarded as having missing outcome data, even though some of their
+follow up is included in the analysis. Note that such participants may be shown as included in analyses in
+CONSORT flow diagrams.
+
+NA/Y/PY/PN/N/NI
+
+15
+
+3.4 If Y/PY/NI to 3.3: Is it
+likely that missingness in
+the outcome depended on
+its true value?
+
+This question distinguishes between situations in which (i) missingness in the outcome could depend on
+its true value (assessed as ‘Some concerns’) from those in which (ii) it is likely that missingness in the
+outcome depended on its true value (assessed as ‘High risk of bias’). Five reasons for answering ‘Yes’ are:
+
+1. Differences between intervention groups in the proportions of missing outcome data. If there is a
+ difference between the effects of the experimental and comparator interventions on the outcome,
+ and the missingness in the outcome is influenced by its true value, then the proportions of missing
+ outcome data are likely to differ between intervention groups. Such a difference suggests a risk of
+ bias due to missing outcome data, because the trial result will be sensitive to missingness in the
+ outcome being related to its true value. For time-to-event-data, the analogue is that rates of
+ censoring (loss to follow-up) differ between the intervention groups.
+2. Reported reasons for missing outcome data provide evidence that missingness in the outcome
+ depends on its true value;
+3. Reported reasons for missing outcome data differ between the intervention groups;
+4. The circumstances of the trial make it likely that missingness in the outcome depends on its true
+ value. For example, in trials of interventions to treat schizophrenia it is widely understood that
+ continuing symptoms make drop out more likely.
+5. In time-to-event analyses, participants’ follow up is censored when they stop or change their
+ assigned intervention, for example because of drug toxicity or, in cancer trials, when participants
+ switch to second-line chemotherapy.
+ Answer ‘No’ if the analysis accounted for participant characteristics that are likely to explain the
+ relationship between missingness in the outcome and its true value.
+
+NA/Y/PY/PN/N/NI
+
+Risk-of-bias judgement See algorithm. Low / High / Some
+concerns
+
+Optional: What is the
+predicted direction of bias
+due to missing outcome
+data?
+
+If the likely direction of bias can be predicted, it is helpful to state this. The direction might be
+characterized either as being towards (or away from) the null, or as being in favour of one of the
+interventions.
+
+NA / Favours
+experimental / Favours
+comparator / Towards
+null /Away from null /
+Unpredictable
+
+flowchart LR
+Q31["3.1 Outcome data for all participants?"]
+Q32["3.2 Evidence that result is not biased?"]
+Q33["3.3 Missingness could depend on true value?"]
+Q34["3.4 Likely that missingness depended on true value?"]
+
+ L["Low risk"]
+ M["Some concerns"]
+ H["High risk"]
+
+ %% Question 3.1
+ Q31 -- "Y/PY" --> L
+ Q31 -- "N/PN/NI" --> Q32
+
+ %% Question 3.2
+ Q32 -- "Y/PY" --> L
+ Q32 -- "N/PN" --> Q33
+
+ %% Question 3.3
+ Q33 -- "N/PN" --> L
+ Q33 -- "Y/PY/NI" --> Q34
+
+ %% Question 3.4
+ Q34 -- "N/PN" --> M
+ Q34 -- "Y/PY/NI" --> H
diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-4.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-4.md
new file mode 100644
index 000000000..853cc093f
--- /dev/null
+++ b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-4.md
@@ -0,0 +1,137 @@
+Domain 4: Risk of bias in measurement of the outcome
+Signalling questions Elaboration Response options
+4.1 Was the method of
+measuring the outcome
+inappropriate?
+
+This question aims to identify methods of outcome measurement (data collection) that are unsuitable for
+the outcome they are intended to evaluate. The question does not aim to assess whether the choice of
+outcome being evaluated was sensible (e.g. because it is a surrogate or proxy for the main outcome of
+interest). In most circumstances, for pre-specified outcomes, the answer to this question will be ‘No’ or
+‘Probably no’.
+Answer ‘Yes’ or ‘Probably yes’ if the method of measuring the outcome is inappropriate, for example
+because:
+(1) it is unlikely to be sensitive to plausible intervention effects (e.g. important ranges of outcome
+values fall outside levels that are detectable using the measurement method); or
+(2) the measurement instrument has been demonstrated to have poor validity.
+
+Y/PY/PN/N/NI
+
+4.2 Could measurement
+or ascertainment of the
+outcome have differed
+between intervention
+groups?
+
+Comparable methods of outcome measurement (data collection) involve the same measurement
+methods and thresholds, used at comparable time points. Differences between intervention groups may
+arise because of ‘diagnostic detection bias’ in the context of passive collection of outcome data, or if an
+intervention involves additional visits to a healthcare provider, leading to additional opportunities for
+outcome events to be identified.
+
+Y/PY/PN/N/NI
+
+4.3 If N/PN/NI to 4.1 and
+4.2: Were outcome
+assessors aware of the
+intervention received by
+study participants?
+
+Answer ‘No’ if outcome assessors were blinded to intervention status. For participant-reported
+outcomes, the outcome assessor is the study participant.
+
+NA/Y/PY/PN/N/NI
+
+4.4 If Y/PY/NI to 4.3:
+Could assessment of the
+outcome have been
+influenced by knowledge
+of intervention received?
+
+Knowledge of the assigned intervention could influence participant-reported outcomes (such as level of
+pain), observer-reported outcomes involving some judgement, and intervention provider decision
+outcomes. They are unlikely to influence observer-reported outcomes that do not involve judgement, for
+example all-cause mortality.
+
+NA/Y/PY/PN/N/NI
+
+18
+
+4.5 If Y/PY/NI to 4.4: Is it
+likely that assessment of
+the outcome was
+influenced by knowledge
+of intervention received?
+
+This question distinguishes between situations in which (i) knowledge of intervention status could have
+influenced outcome assessment but there is no reason to believe that it did (assessed as ‘Some
+concerns’) from those in which (ii) knowledge of intervention status was likely to influence outcome
+assessment (assessed as ‘High’). When there are strong levels of belief in either beneficial or harmful
+effects of the intervention, it is more likely that the outcome was influenced by knowledge of the
+intervention received. Examples may include patient-reported symptoms in trials of homeopathy, or
+assessments of recovery of function by a physiotherapist who delivered the intervention.
+
+NA/Y/PY/PN/N/NI
+
+Risk-of-bias judgement See algorithm. Low / High / Some
+concerns
+
+Optional: What is the
+predicted direction of
+bias in measurement of
+the outcome?
+
+If the likely direction of bias can be predicted, it is helpful to state this. The direction might be
+characterized either as being towards (or away from) the null, or as being in favour of one of the
+interventions.
+
+NA / Favours
+experimental / Favours
+comparator / Towards
+null /Away from null /
+Unpredictable
+
+flowchart LR
+Q41["4.1 Method of measuring the outcome inappropriate?"]
+Q42["4.2 Measurement or ascertainment of outcome differ between groups?"]
+
+ Q43a["4.3 Outcome assessors aware of intervention received?"]
+ Q44a["4.4 Could assessment have been influenced by knowledge of intervention?"]
+ Q45a["4.5 Likely that assessment was influenced by knowledge of intervention?"]
+
+ Q43b["4.3 Outcome assessors aware of intervention received?"]
+ Q44b["4.4 Could assessment have been influenced by knowledge of intervention?"]
+ Q45b["4.5 Likely that assessment was influenced by knowledge of intervention?"]
+
+ L["Low risk"]
+ M["Some concerns"]
+ H["High risk"]
+
+ %% 4.1
+ Q41 -- "N/PN/NI" --> Q42
+ Q41 -- "Y/PY" --> H
+
+ %% 4.2
+ Q42 -- "N/PN" --> Q43a
+ Q42 -- "NI" --> Q43b
+ Q42 -- "Y/PY" --> H
+
+ %% Branch A (from N/PN)
+ Q43a -- "N/PN" --> L
+ Q43a -- "Y/PY/NI" --> Q44a
+
+ Q44a -- "N/PN" --> L
+ Q44a -- "Y/PY/NI" --> Q45a
+
+ Q45a -- "N/PN" --> M
+ Q45a -- "Y/PY/NI" --> H
+
+ %% Branch B (from NI)
+ Q43b -- "N/PN" --> M
+ Q43b -- "Y/PY/NI" --> Q44b
+
+ Q44b -- "N/PN" --> M
+ Q44b -- "Y/PY/NI" --> Q45b
+
+ Q45b -- "N/PN" --> M
+ Q45b -- "Y/PY/NI" --> H
diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-5.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-5.md
new file mode 100644
index 000000000..ff59697e2
--- /dev/null
+++ b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-5.md
@@ -0,0 +1,152 @@
+Domain 5: Risk of bias in selection of the reported result
+Signalling questions Elaboration Response options
+5.1 Were the data that
+produced this result
+analysed in accordance with
+a pre-specified analysis plan
+that was finalized before
+unblinded outcome data
+were available for analysis?
+
+If the researchers’ pre-specified intentions are available in sufficient detail, then planned outcome
+measurements and analyses can be compared with those presented in the published report(s). To
+avoid the possibility of selection of the reported result, finalization of the analysis intentions must
+precede availability of unblinded outcome data to the trial investigators.
+Changes to analysis plans that were made before unblinded outcome data were available, or that
+were clearly unrelated to the results (e.g. due to a broken machine making data collection impossible)
+do not raise concerns about bias in selection of the reported result.
+
+Y/PY/PN/N/NI
+
+Is the numerical result being
+assessed likely to have been
+selected, on the basis of the
+results, from...
+5.2. ... multiple eligible
+outcome measurements
+(e.g. scales, definitions,
+time points) within the
+outcome domain?
+
+A particular outcome domain (i.e. a true state or endpoint of interest) may be measured in multiple
+ways. For example, the domain pain may be measured using multiple scales (e.g. a visual analogue
+
+scale and the McGill Pain Questionnaire), each at multiple time points (e.g. 3, 6 and 12 weeks post-
+treatment). If multiple measurements were made, but only one or a subset is reported on the basis of
+
+the results (e.g. statistical significance), there is a high risk of bias in the fully reported result.
+Attention should be restricted to outcome measurements that are eligible for consideration by the
+RoB 2 tool user. For example, if only a result using a specific measurement scale is eligible for
+inclusion in a meta-analysis (e.g. Hamilton Depression Rating Scale), and this is reported by the trial,
+then there would not be an issue of selection even if this result was reported (on the basis of the
+results) in preference to the result from a different measurement scale (e.g. Beck Depression
+Inventory).
+Answer ‘Yes’ or ‘Probably yes’ if:
+There is clear evidence (usually through examination of a trial protocol or statistical analysis plan)
+that a domain was measured in multiple eligible ways, but data for only one or a subset of
+measures is fully reported (without justification), and the fully reported result is likely to have been
+selected on the basis of the results. Selection on the basis of the results can arise from a desire for
+findings to be newsworthy, sufficiently noteworthy to merit publication, or to confirm a prior
+hypothesis. For example, trialists who have a preconception, or vested interest in showing, that an
+
+Y/PY/PN/N/NI
+
+21
+
+experimental intervention is beneficial may be inclined to report outcome measurements
+selectively that are favourable to the experimental intervention.
+Answer ‘No’ or ‘Probably no’ if:
+There is clear evidence (usually through examination of a trial protocol or statistical analysis plan)
+that all eligible reported results for the outcome domain correspond to all intended outcome
+measurements.
+or
+There is only one possible way in which the outcome domain can be measured (hence there is no
+opportunity to select from multiple measures).
+or
+Outcome measurements are inconsistent across different reports on the same trial, but the
+trialists have provided the reason for the inconsistency and it is not related to the nature of the
+results.
+Answer ‘No information’ if:
+Analysis intentions are not available, or the analysis intentions are not reported in sufficient detail to
+enable an assessment, and there is more than one way in which the outcome domain could have
+been measured.
+
+5.3 ... multiple eligible
+analyses of the data?
+
+A particular outcome measurement may be analysed in multiple ways. Examples include: unadjusted
+and adjusted models; final value vs change from baseline vs analysis of covariance; transformations of
+variables; different definitions of composite outcomes (e.g. ‘major adverse event’); conversion of
+continuously scaled outcome to categorical data with different cut-points; different sets of covariates
+for adjustment; and different strategies for dealing with missing data. Application of multiple
+methods generates multiple effect estimates for a specific outcome measurement. If multiple
+estimates are generated but only one or a subset is reported on the basis of the results (e.g. statistical
+significance), there is a high risk of bias in the fully reported result. Attention should be restricted to
+analyses that are eligible for consideration by the RoB 2 tool user. For example, if only the result from
+an analysis of post-intervention values is eligible for inclusion in a meta-analysis (e.g. at 12 weeks
+after randomization), and this is reported by the trial, then there would not be an issue of selection
+even if this result was reported (on the basis of the results) in preference to the result from an
+analysis of changes from baseline.
+Answer ‘Yes’ or ‘Probably yes’ if:
+
+Y/PY/PN/N/NI
+
+22
+
+There is clear evidence (usually through examination of a trial protocol or statistical analysis plan)
+that a measurement was analysed in multiple eligible ways, but data for only one or a subset of
+analyses is fully reported (without justification), and the fully reported result is likely to have been
+selected on the basis of the results. Selection on the basis of the results arises from a desire for
+findings to be newsworthy, sufficiently noteworthy to merit publication, or to confirm a prior
+hypothesis. For example, trialists who have a preconception or vested interest in showing that an
+experimental intervention is beneficial may be inclined to selectively report analyses that are
+favourable to the experimental intervention.
+Answer ‘No’ or ‘Probably no’ if:
+There is clear evidence (usually through examination of a trial protocol or statistical analysis plan)
+that all eligible reported results for the outcome measurement correspond to all intended
+analyses.
+or
+There is only one possible way in which the outcome measurement can be analysed (hence there
+is no opportunity to select from multiple analyses).
+or
+Analyses are inconsistent across different reports on the same trial, but the trialists have provided
+the reason for the inconsistency and it is not related to the nature of the results.
+Answer ‘No information’ if:
+Analysis intentions are not available, or the analysis intentions are not reported in sufficient detail to
+enable an assessment, and there is more than one way in which the outcome measurement could
+have been analysed.
+
+Risk-of-bias judgement See algorithm. Low / High / Some
+concerns
+
+Optional: What is the
+predicted direction of bias
+due to selection of the
+reported result?
+
+If the likely direction of bias can be predicted, it is helpful to state this. The direction might be
+characterized either as being towards (or away from) the null, or as being in favour of one of the
+interventions.
+
+NA / Favours
+experimental / Favours
+comparator / Towards
+null /Away from null /
+Unpredictable
+
+flowchart LR
+Q52["Result selected from…\n\n5.2 …multiple outcome measurements?\n\n5.3 …multiple analyses of the data?"]
+Q51["5.1 Trial analysed in accordance with a pre-specified plan?"]
+
+ L["Low risk"]
+ M["Some concerns"]
+ H["High risk"]
+
+ %% From result selection questions
+ Q52 -- "Both N/PN" --> Q51
+ Q52 -- "At least one NI,\nbut neither Y/PY" --> M
+ Q52 -- "Either Y/PY" --> H
+
+ %% From pre-specified analysis plan
+ Q51 -- "Y/PY" --> L
+ Q51 -- "N/PN/NI" --> M
diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/overall.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/overall.md
new file mode 100644
index 000000000..7ea38d050
--- /dev/null
+++ b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/overall.md
@@ -0,0 +1,23 @@
+Overall risk of bias
+Risk-of-bias judgement Low / High / Some
+concerns
+
+Optional: What is the overall
+predicted direction of bias for this
+outcome?
+
+Favours experimental /
+Favours comparator /
+Towards null /Away from
+null / Unpredictable / NA
+
+Overall risk-of-bias judgement Criteria
+Low risk of bias The study is judged to be at low risk of bias for all domains for this result.
+Some concerns The study is judged to raise some concerns in at least one domain for this result, but not to be at high risk of bias for any
+
+domain.
+
+High risk of bias The study is judged to be at high risk of bias in at least one domain for this result.
+
+Or
+The study is judged to have
diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/preliminary.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/preliminary.md
new file mode 100644
index 000000000..f2b18b65f
--- /dev/null
+++ b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/preliminary.md
@@ -0,0 +1,37 @@
+Preliminary considerations
+Study design
+ Individually-randomized parallel-group trial
+ Cluster-randomized parallel-group trial
+ Individually randomized cross-over (or other matched) trial
+For the purposes of this assessment, the interventions being compared are defined as
+Experimental: Comparator:
+
+Specify which outcome is being assessed for risk of bias
+Specify the numerical result being assessed. In case of multiple alternative
+analyses being presented, specify the numeric result (e.g. RR = 1.52 (95% CI
+0.83 to 2.77) and/or a reference (e.g. to a table, figure or paragraph) that
+uniquely defines the result being assessed.
+Is the review team’s aim for this result...?
+ to assess the effect of assignment to intervention (the ‘intention-to-treat’ effect)
+ to assess the effect of adhering to intervention (the ‘per-protocol’ effect)
+If the aim is to assess the effect of adhering to intervention, select the deviations from intended intervention that should be addressed (at least one must be
+checked):
+ occurrence of non-protocol interventions
+ failures in implementing the intervention that could have affected the outcome
+ non-adherence to their assigned intervention by trial participants
+
+3
+
+Which of the following sources were obtained to help inform the risk-of-bias assessment? (tick as many as apply)
+ Journal article(s)
+ Trial protocol
+ Statistical analysis plan (SAP)
+ Non-commercial trial registry record (e.g. ClinicalTrials.gov record)
+ Company-owned trial registry record (e.g. GSK Clinical Study Register record)
+ “Grey literature” (e.g. unpublished thesis)
+ Conference abstract(s) about the trial
+ Regulatory document (e.g. Clinical Study Report, Drug Approval Package)
+ Research ethics application
+ Grant database summary (e.g. NIH RePORTER or Research Councils UK Gateway to Research)
+ Personal communication with trialist
+ Personal communication with the sponsor
diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/score.js b/packages/web/src/components/checklist/ROB2Checklist/scoring/score.js
deleted file mode 100644
index e69de29bb..000000000
diff --git a/packages/web/src/components/dashboard/Dashboard.jsx b/packages/web/src/components/dashboard/Dashboard.jsx
index 96b8f04fa..579b97068 100644
--- a/packages/web/src/components/dashboard/Dashboard.jsx
+++ b/packages/web/src/components/dashboard/Dashboard.jsx
@@ -23,6 +23,7 @@ import ActivityFeed from './ActivityFeed.jsx';
import { ProjectsSection } from './ProjectsSection.jsx';
import { LocalAppraisalsSection } from './LocalAppraisalsSection.jsx';
import { useInitialAnimation } from './useInitialAnimation.js';
+import { SectionErrorBoundary } from '@components/ErrorBoundary.jsx';
// Animation context - allows child components to access animation state
export const AnimationContext = createContext({
@@ -174,14 +175,18 @@ export function Dashboard() {
{/* Projects - only for logged in users */}
-
+
+
+
{/* Local appraisals - always shown */}
-
+
+
+
{/* Right sidebar */}
diff --git a/packages/web/src/components/dev/DevImportProject.jsx b/packages/web/src/components/dev/DevImportProject.jsx
index bea3a1100..94c5ef72c 100644
--- a/packages/web/src/components/dev/DevImportProject.jsx
+++ b/packages/web/src/components/dev/DevImportProject.jsx
@@ -45,7 +45,8 @@ export default function DevImportProject() {
let parsed;
try {
parsed = JSON.parse(jsonText());
- } catch {
+ } catch (err) {
+ console.warn('JSON parse error:', err.message);
setResult({ success: false, message: 'Invalid JSON' });
return;
}
@@ -118,7 +119,8 @@ export default function DevImportProject() {
const text = await file.text();
setJsonText(text);
setResult(null);
- } catch {
+ } catch (err) {
+ console.warn('Failed to read file:', err.message);
setResult({ success: false, message: 'Failed to read file' });
}
};
diff --git a/packages/web/src/components/dev/DevJsonEditor.jsx b/packages/web/src/components/dev/DevJsonEditor.jsx
index 0f0e0f7b7..be4a8cbbc 100644
--- a/packages/web/src/components/dev/DevJsonEditor.jsx
+++ b/packages/web/src/components/dev/DevJsonEditor.jsx
@@ -55,7 +55,8 @@ export default function DevJsonEditor(props) {
let parsed;
try {
parsed = JSON.parse(jsonText());
- } catch {
+ } catch (err) {
+ console.warn('JSON parse error:', err.message);
setResult({ success: false, message: 'Invalid JSON' });
return;
}
diff --git a/packages/web/src/components/profile/ProfilePage.jsx b/packages/web/src/components/profile/ProfilePage.jsx
index 0b0939089..321bae7a5 100644
--- a/packages/web/src/components/profile/ProfilePage.jsx
+++ b/packages/web/src/components/profile/ProfilePage.jsx
@@ -109,7 +109,8 @@ export default function ProfilePage() {
syncProfileToProjects();
showToast.success('Profile Updated', 'Your name has been updated successfully.');
setIsEditingName(false);
- } catch {
+ } catch (err) {
+ console.warn('Failed to update profile name:', err.message);
showToast.error('Update Failed', 'Failed to update name. Please try again.');
} finally {
setSaving(false);
@@ -125,7 +126,8 @@ export default function ProfilePage() {
});
showToast.success('Profile Updated', 'Your persona has been updated successfully.');
setIsEditingRole(false);
- } catch {
+ } catch (err) {
+ console.warn('Failed to update profile persona:', err.message);
showToast.error('Update Failed', 'Failed to update persona. Please try again.');
} finally {
setSaving(false);
diff --git a/packages/web/src/components/project/ProjectView.jsx b/packages/web/src/components/project/ProjectView.jsx
index 8caaffa4d..4d95a8242 100644
--- a/packages/web/src/components/project/ProjectView.jsx
+++ b/packages/web/src/components/project/ProjectView.jsx
@@ -33,6 +33,7 @@ import { AllStudiesTab } from './all-studies-tab/index.js';
import { ToDoTab } from './todo-tab/index.js';
import { ReconcileTab } from './reconcile-tab/index.js';
import { CompletedTab } from './completed-tab/index.js';
+import { SectionErrorBoundary } from '@components/ErrorBoundary.jsx';
export default function ProjectView(props) {
const params = useParams();
@@ -299,23 +300,33 @@ export default function ProjectView(props) {
{tabValue => (
<>
-
+
+
+
-
+
+
+
-
+
+
+
-
+
+
+
-
+
+
+
>
)}
diff --git a/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.jsx b/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.jsx
index b1db35829..a62c91554 100644
--- a/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.jsx
+++ b/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.jsx
@@ -133,8 +133,8 @@ export default function GoogleDrivePickerLauncher(props) {
const handleConnectGoogle = async () => {
try {
await connect();
- } catch {
- // primitive sets error
+ } catch (err) {
+ console.warn('Google Drive connect failed:', err.message);
}
};
@@ -149,8 +149,8 @@ export default function GoogleDrivePickerLauncher(props) {
const picked = await openPicker({ multiselect: !!props.multiselect });
if (!picked || picked.length === 0) return;
await props.onPick?.(picked, studyId);
- } catch {
- // primitive sets error
+ } catch (err) {
+ console.warn('Google Drive picker failed:', err.message);
}
};
diff --git a/packages/web/src/components/settings/SettingsLayout.jsx b/packages/web/src/components/settings/SettingsLayout.jsx
index ec4378c13..43995d6f3 100644
--- a/packages/web/src/components/settings/SettingsLayout.jsx
+++ b/packages/web/src/components/settings/SettingsLayout.jsx
@@ -1,5 +1,6 @@
import { createSignal } from 'solid-js';
import SettingsSidebar from './SettingsSidebar.jsx';
+import { SectionErrorBoundary } from '@components/ErrorBoundary.jsx';
// Share the same localStorage keys as main sidebar so state is unified
const SIDEBAR_MODE_KEY = 'corates-sidebar-mode';
@@ -19,8 +20,8 @@ function getInitialSidebarMode() {
if (stored === 'expanded' || stored === 'collapsed') {
return stored;
}
- } catch {
- // localStorage not available (SSR or private browsing)
+ } catch (err) {
+ console.warn('Failed to read sidebar mode from localStorage:', err.message);
}
return 'collapsed';
}
@@ -37,8 +38,8 @@ function getInitialSidebarWidth() {
return parsed;
}
}
- } catch {
- // localStorage not available
+ } catch (err) {
+ console.warn('Failed to read sidebar width from localStorage:', err.message);
}
return DEFAULT_SIDEBAR_WIDTH;
}
@@ -79,7 +80,9 @@ export default function SettingsLayout(props) {
width={sidebarWidth()}
onWidthChange={handleWidthChange}
/>
- {props.children}
+
+ {props.children}
+
);
}
diff --git a/packages/web/src/components/settings/pages/MergeAccountsDialog.jsx b/packages/web/src/components/settings/pages/MergeAccountsDialog.jsx
index 912eb06d0..bbf699207 100644
--- a/packages/web/src/components/settings/pages/MergeAccountsDialog.jsx
+++ b/packages/web/src/components/settings/pages/MergeAccountsDialog.jsx
@@ -180,8 +180,8 @@ export default function MergeAccountsDialog(props) {
if (mergeToken()) {
try {
await cancelMerge(mergeToken());
- } catch {
- // Ignore cancel errors
+ } catch (err) {
+ console.warn('Failed to cancel merge:', err.message);
}
}
props.onOpenChange?.(false);
diff --git a/packages/web/src/components/sidebar/useRecentsNav.js b/packages/web/src/components/sidebar/useRecentsNav.js
index 78fb7331a..3306bae4f 100644
--- a/packages/web/src/components/sidebar/useRecentsNav.js
+++ b/packages/web/src/components/sidebar/useRecentsNav.js
@@ -22,8 +22,8 @@ export default function useRecentsNav() {
setRecents(parsed.slice(0, MAX_RECENTS));
}
}
- } catch {
- // Ignore parse errors
+ } catch (err) {
+ console.warn('Failed to parse recents from localStorage:', err.message);
}
});
@@ -41,8 +41,8 @@ export default function useRecentsNav() {
// Persist to localStorage
try {
localStorage.setItem(RECENTS_STORAGE_KEY, JSON.stringify(updated));
- } catch {
- // Ignore storage errors
+ } catch (err) {
+ console.warn('Failed to persist recents to localStorage:', err.message);
}
return updated;
});
diff --git a/packages/web/src/lib/errorLogger.js b/packages/web/src/lib/errorLogger.js
new file mode 100644
index 000000000..8362c39cb
--- /dev/null
+++ b/packages/web/src/lib/errorLogger.js
@@ -0,0 +1,207 @@
+/**
+ * Error Logger - Centralized error logging for monitoring integration
+ *
+ * Provides a single point of integration for error monitoring services (Sentry, LogRocket, etc.)
+ * All error logging should go through this module to ensure consistent handling and easy
+ * integration with monitoring services in the future.
+ *
+ * Usage:
+ * import { logError, logWarning, bestEffort } from '@lib/errorLogger.js';
+ *
+ * // Log an error with context
+ * logError(error, { component: 'ProjectView', action: 'loadProject' });
+ *
+ * // Log a warning for non-fatal issues
+ * logWarning('Cache miss for user avatar', { userId: '123' });
+ *
+ * // Wrap best-effort operations that can fail silently
+ * bestEffort(clearFormState(type), { operation: 'clearFormState' });
+ */
+
+import { normalizeError } from '@corates/shared';
+
+/**
+ * Log levels for categorizing messages
+ */
+const LogLevel = {
+ ERROR: 'error',
+ WARNING: 'warning',
+ INFO: 'info',
+};
+
+/**
+ * Format error data for logging
+ * Normalizes different error formats into a consistent structure
+ */
+function formatErrorData(error) {
+ const normalized = normalizeError(error);
+ return {
+ code: normalized.code || 'UNKNOWN',
+ message: normalized.message || String(error),
+ statusCode: normalized.statusCode,
+ details: normalized.details,
+ stack: error?.stack,
+ };
+}
+
+/**
+ * Core logging function
+ * Handles both console output and future monitoring service integration
+ */
+function log(level, message, context = {}) {
+ const timestamp = new Date().toISOString();
+ const logData = {
+ level,
+ message,
+ timestamp,
+ ...context,
+ };
+
+ // Console output with appropriate method
+ switch (level) {
+ case LogLevel.ERROR:
+ console.error(`[Error] ${message}`, logData);
+ break;
+ case LogLevel.WARNING:
+ console.warn(`[Warning] ${message}`, logData);
+ break;
+ default:
+ console.info(`[Info] ${message}`, logData);
+ }
+
+ // Future Sentry integration point
+ // When Sentry is configured, add integration here:
+ //
+ // if (typeof window !== 'undefined' && window.Sentry) {
+ // if (level === LogLevel.ERROR && context.error) {
+ // window.Sentry.captureException(context.error, {
+ // tags: {
+ // component: context.component,
+ // action: context.action,
+ // },
+ // extra: context,
+ // });
+ // } else {
+ // window.Sentry.captureMessage(message, {
+ // level: level === LogLevel.ERROR ? 'error' : level,
+ // tags: {
+ // component: context.component,
+ // action: context.action,
+ // },
+ // extra: context,
+ // });
+ // }
+ // }
+}
+
+/**
+ * Log an error with context
+ * Use this for caught exceptions and error boundary errors
+ *
+ * @param {Error|DomainError|TransportError|unknown} error - The error to log
+ * @param {Object} context - Additional context for debugging
+ * @param {string} [context.component] - Component where error occurred
+ * @param {string} [context.action] - Action that triggered the error
+ * @param {Object} [context.metadata] - Additional metadata
+ */
+export function logError(error, context = {}) {
+ const errorData = formatErrorData(error);
+ const message = context.action ? `${context.action}: ${errorData.message}` : errorData.message;
+
+ log(LogLevel.ERROR, message, {
+ ...context,
+ error: errorData,
+ });
+}
+
+/**
+ * Log a warning for non-fatal issues
+ * Use this for degraded functionality, cache misses, etc.
+ *
+ * @param {string} message - Warning message
+ * @param {Object} context - Additional context for debugging
+ */
+export function logWarning(message, context = {}) {
+ log(LogLevel.WARNING, message, context);
+}
+
+/**
+ * Log informational messages
+ * Use sparingly - mainly for important state transitions
+ *
+ * @param {string} message - Info message
+ * @param {Object} context - Additional context
+ */
+export function logInfo(message, context = {}) {
+ log(LogLevel.INFO, message, context);
+}
+
+/**
+ * Wrap a best-effort operation that can fail silently
+ * Logs warnings on failure but doesn't throw
+ *
+ * Use for cleanup operations, cache updates, and other non-critical tasks
+ * where failure shouldn't break the user experience.
+ *
+ * @param {Promise} promise - The operation to run
+ * @param {Object} context - Context for logging if operation fails
+ * @returns {Promise} Resolves to the result or undefined on failure
+ *
+ * @example
+ * // Instead of: clearFormState(type).catch(() => {});
+ * bestEffort(clearFormState(type), { operation: 'clearFormState', type });
+ */
+export function bestEffort(promise, context = {}) {
+ return promise.catch(error => {
+ logWarning(`Best-effort operation failed: ${context.operation || 'unknown'}`, {
+ ...context,
+ error: formatErrorData(error),
+ });
+ return undefined;
+ });
+}
+
+/**
+ * Create a logging wrapper for async functions
+ * Catches errors, logs them, and rethrows
+ *
+ * @param {string} component - Component name for context
+ * @param {string} action - Action name for context
+ * @returns {Function} Wrapper function that logs and rethrows errors
+ *
+ * @example
+ * async function loadProject(id) {
+ * return withErrorLogging('ProjectView', 'loadProject')(async () => {
+ * const data = await apiFetch(`/api/projects/${id}`);
+ * return data;
+ * });
+ * }
+ */
+export function withErrorLogging(component, action) {
+ return async fn => {
+ try {
+ return await fn();
+ } catch (error) {
+ logError(error, { component, action });
+ throw error;
+ }
+ };
+}
+
+/**
+ * Log error and rethrow - for catch blocks that need to log but propagate
+ *
+ * @param {string} component - Component name for context
+ * @param {string} action - Action name for context
+ * @param {Object} metadata - Additional metadata
+ * @returns {Function} Error handler that logs and rethrows
+ *
+ * @example
+ * fetchData().catch(logAndRethrow('ProjectView', 'fetchData'));
+ */
+export function logAndRethrow(component, action, metadata = {}) {
+ return error => {
+ logError(error, { component, action, ...metadata });
+ throw error;
+ };
+}
diff --git a/packages/web/src/lib/formStatePersistence.js b/packages/web/src/lib/formStatePersistence.js
index c0093279f..7c59939e8 100644
--- a/packages/web/src/lib/formStatePersistence.js
+++ b/packages/web/src/lib/formStatePersistence.js
@@ -5,6 +5,7 @@
*/
import { db } from '@primitives/db.js';
+import { bestEffort } from '@lib/errorLogger.js';
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -50,7 +51,11 @@ export async function getFormState(type, projectId) {
if (!record) return null;
if (Date.now() - record.timestamp > MAX_AGE_MS) {
- clearFormState(type, projectId).catch(() => {});
+ bestEffort(clearFormState(type, projectId), {
+ operation: 'clearExpiredFormState',
+ type,
+ projectId,
+ });
return null;
}
diff --git a/packages/web/src/lib/lastLoginMethod.js b/packages/web/src/lib/lastLoginMethod.js
index b1c334da7..9fdd9e223 100644
--- a/packages/web/src/lib/lastLoginMethod.js
+++ b/packages/web/src/lib/lastLoginMethod.js
@@ -45,7 +45,8 @@ export function saveLastLoginMethod(method) {
export function getLastLoginMethod() {
try {
return localStorage.getItem(STORAGE_KEY);
- } catch {
+ } catch (err) {
+ console.warn('Failed to get last login method from localStorage:', err.message);
return null;
}
}
@@ -65,7 +66,7 @@ export function getLastLoginMethodLabel() {
export function clearLastLoginMethod() {
try {
localStorage.removeItem(STORAGE_KEY);
- } catch {
- // ignore
+ } catch (err) {
+ console.warn('Failed to clear last login method from localStorage:', err.message);
}
}
diff --git a/packages/web/src/lib/queryClient.js b/packages/web/src/lib/queryClient.js
index 7a9bf5235..054c844b0 100644
--- a/packages/web/src/lib/queryClient.js
+++ b/packages/web/src/lib/queryClient.js
@@ -146,8 +146,8 @@ async function setupPersistence(queryClient) {
CACHE_SNAPSHOT_KEY,
JSON.stringify({ queries: criticalQueries, timestamp: Date.now() }),
);
- } catch {
- // Silently fail - localStorage may be full or unavailable
+ } catch (err) {
+ console.warn('Failed to save query cache snapshot to localStorage:', err.message);
}
// Still try async persist (may complete if unload is slow)
@@ -177,8 +177,8 @@ async function setupPersistence(queryClient) {
// Clear snapshot after restoration
localStorage.removeItem(CACHE_SNAPSHOT_KEY);
}
- } catch {
- // Silently fail
+ } catch (err) {
+ console.warn('Failed to restore query cache from localStorage:', err.message);
}
}
diff --git a/packages/web/src/lib/referenceLookup.js b/packages/web/src/lib/referenceLookup.js
index 96a959fc9..51301cf7d 100644
--- a/packages/web/src/lib/referenceLookup.js
+++ b/packages/web/src/lib/referenceLookup.js
@@ -394,7 +394,8 @@ export async function fetchReferenceByIdentifier(identifier) {
try {
ref = await fetchFromDOI(trimmed);
ref.importSource = 'doi';
- } catch {
+ } catch (err) {
+ console.warn('Reference lookup failed:', err.message);
throw new Error(
'Could not identify reference. Please enter a valid DOI (e.g., 10.1234/example) or PubMed ID (e.g., 12345678).',
);
diff --git a/packages/web/src/main.jsx b/packages/web/src/main.jsx
index 5b5e54f0b..6a1f9a237 100644
--- a/packages/web/src/main.jsx
+++ b/packages/web/src/main.jsx
@@ -6,11 +6,10 @@ import { initBfcacheHandler } from '@lib/bfcache-handler.js';
import AppErrorBoundary from './components/ErrorBoundary.jsx';
import { QueryClientProvider } from '@tanstack/solid-query';
import { queryClient } from '@lib/queryClient.js';
+import { bestEffort } from '@lib/errorLogger.js';
// Clean up any expired form state entries from IndexedDB on app load
-cleanupExpiredStates().catch(() => {
- // Silent fail - cleanup is best-effort
-});
+bestEffort(cleanupExpiredStates(), { operation: 'cleanupExpiredStates' });
// Initialize bfcache restoration handler
// This detects when Safari (and other browsers) restore pages from bfcache
diff --git a/packages/web/src/primitives/avatarCache.js b/packages/web/src/primitives/avatarCache.js
index 4f4aa0079..ca1233ac3 100644
--- a/packages/web/src/primitives/avatarCache.js
+++ b/packages/web/src/primitives/avatarCache.js
@@ -223,8 +223,8 @@ export async function getAvatarWithCache(userId, imageUrl) {
try {
const dataUrl = await fetchAndCacheAvatar(userId, imageUrl);
return dataUrl;
- } catch {
- // If fetch fails, try to return cached version
+ } catch (err) {
+ console.warn('Failed to fetch avatar, using cache:', err.message);
return getCachedAvatar(userId);
}
}
diff --git a/packages/web/src/primitives/useAddStudies/serialization.js b/packages/web/src/primitives/useAddStudies/serialization.js
index 10354266a..61611381c 100644
--- a/packages/web/src/primitives/useAddStudies/serialization.js
+++ b/packages/web/src/primitives/useAddStudies/serialization.js
@@ -18,8 +18,8 @@ export function cloneArrayBuffer(buffer) {
const copy = new ArrayBuffer(buffer.byteLength);
new Uint8Array(copy).set(new Uint8Array(buffer));
return copy;
- } catch {
- // Buffer is likely detached
+ } catch (err) {
+ console.warn('Failed to copy ArrayBuffer (likely detached):', err.message);
return null;
}
}
diff --git a/packages/web/src/primitives/useAdminQueries.js b/packages/web/src/primitives/useAdminQueries.js
index 6d3be3da3..adc2e4728 100644
--- a/packages/web/src/primitives/useAdminQueries.js
+++ b/packages/web/src/primitives/useAdminQueries.js
@@ -4,8 +4,8 @@
*/
import { useQuery } from '@tanstack/solid-query';
-import { API_BASE } from '@config/api.js';
import { queryKeys } from '@lib/queryKeys.js';
+import { apiFetch } from '@lib/apiFetch.js';
import {
fetchOrgs,
fetchOrgDetails,
@@ -17,21 +17,24 @@ import {
/**
* Helper for admin fetch calls
- * Uses cache: 'no-store' to prevent browser HTTP caching from serving stale data
+ * Uses apiFetch for proper error handling and normalization
*/
-async function adminFetch(path, options = {}) {
- const response = await fetch(`${API_BASE}/api/admin/${path}`, {
- credentials: 'include',
- cache: 'no-store',
- ...options,
+async function adminFetch(path) {
+ return apiFetch(`/api/admin/${path}`, {
+ showToast: false, // Admin panel handles its own error display via TanStack Query
});
- if (!response.ok) {
- const error = await response.json().catch(() => ({}));
- throw new Error(error.error || `Failed to fetch ${path}`);
- }
- return response.json();
}
+/**
+ * Default cache config for admin queries
+ * Admin data should always be fresh - no stale data shown
+ */
+const ADMIN_QUERY_CONFIG = {
+ staleTime: 0,
+ gcTime: 1000 * 60 * 5,
+ refetchOnMount: 'always',
+};
+
/**
* Hook to fetch admin dashboard stats
*/
@@ -39,9 +42,7 @@ export function useAdminStats() {
return useQuery(() => ({
queryKey: queryKeys.admin.stats,
queryFn: () => adminFetch('stats'),
- staleTime: 0, // Always consider data stale to force refetch
- gcTime: 1000 * 60 * 5, // 5 minutes
- refetchOnMount: 'always', // Always refetch on mount, even if data exists
+ ...ADMIN_QUERY_CONFIG,
}));
}
@@ -65,9 +66,7 @@ export function useAdminUsers(getParams) {
if (search) searchParams.set('search', search);
return adminFetch(`users?${searchParams.toString()}`);
},
- staleTime: 0, // Always consider data stale to force refetch
- gcTime: 1000 * 60 * 5, // 5 minutes
- refetchOnMount: 'always', // Always refetch on mount, even if data exists
+ ...ADMIN_QUERY_CONFIG,
};
});
}
@@ -82,9 +81,7 @@ export function useAdminUserDetails(getUserId) {
queryKey: queryKeys.admin.userDetails(userId),
queryFn: () => adminFetch(`users/${userId}`),
enabled: !!userId,
- staleTime: 0, // Always consider data stale to force refetch
- gcTime: 1000 * 60 * 5, // 5 minutes
- refetchOnMount: 'always', // Always refetch on mount, even if data exists
+ ...ADMIN_QUERY_CONFIG,
};
});
}
@@ -111,9 +108,7 @@ export function useAdminProjects(getParams) {
if (orgId) searchParams.set('orgId', orgId);
return adminFetch(`projects?${searchParams.toString()}`);
},
- staleTime: 0,
- gcTime: 1000 * 60 * 5,
- refetchOnMount: 'always',
+ ...ADMIN_QUERY_CONFIG,
};
});
}
@@ -128,9 +123,7 @@ export function useAdminProjectDetails(getProjectId) {
queryKey: queryKeys.admin.projectDetails(projectId),
queryFn: () => adminFetch(`projects/${projectId}`),
enabled: !!projectId,
- staleTime: 0,
- gcTime: 1000 * 60 * 5,
- refetchOnMount: 'always',
+ ...ADMIN_QUERY_CONFIG,
};
});
}
@@ -157,9 +150,7 @@ export function useStorageDocuments(getParams) {
if (search) searchParams.set('search', search);
return adminFetch(`storage/documents?${searchParams.toString()}`);
},
- staleTime: 0, // Always consider data stale to force refetch
- gcTime: 1000 * 60 * 5, // 5 minutes
- refetchOnMount: 'always', // Always refetch on mount, even if data exists
+ ...ADMIN_QUERY_CONFIG,
};
});
}
@@ -171,9 +162,7 @@ export function useStorageStats() {
return useQuery(() => ({
queryKey: queryKeys.admin.storageStats,
queryFn: () => adminFetch('storage/stats'),
- staleTime: 0, // Always consider data stale to force refetch
- gcTime: 1000 * 60 * 5, // 5 minutes
- refetchOnMount: 'always', // Always refetch on mount, even if data exists
+ ...ADMIN_QUERY_CONFIG,
}));
}
@@ -190,9 +179,7 @@ export function useAdminOrgs(getParams) {
return {
queryKey: queryKeys.admin.orgs(page, limit, search),
queryFn: () => fetchOrgs({ page, limit, search }),
- staleTime: 0,
- gcTime: 1000 * 60 * 5,
- refetchOnMount: 'always',
+ ...ADMIN_QUERY_CONFIG,
};
});
}
@@ -207,9 +194,7 @@ export function useAdminOrgDetails(getOrgId) {
queryKey: queryKeys.admin.orgDetails(orgId),
queryFn: () => fetchOrgDetails(orgId),
enabled: !!orgId,
- staleTime: 0,
- gcTime: 1000 * 60 * 5,
- refetchOnMount: 'always',
+ ...ADMIN_QUERY_CONFIG,
};
});
}
@@ -224,9 +209,7 @@ export function useAdminOrgBilling(getOrgId) {
queryKey: queryKeys.admin.orgBilling(orgId),
queryFn: () => fetchOrgBilling(orgId),
enabled: !!orgId,
- staleTime: 0,
- gcTime: 1000 * 60 * 5,
- refetchOnMount: 'always',
+ ...ADMIN_QUERY_CONFIG,
};
});
}
@@ -245,9 +228,7 @@ export function useAdminBillingLedger(getParams) {
return {
queryKey: queryKeys.admin.billingLedger(queryParams),
queryFn: () => fetchBillingLedger(queryParams),
- staleTime: 0,
- gcTime: 1000 * 60 * 5,
- refetchOnMount: 'always',
+ ...ADMIN_QUERY_CONFIG,
};
});
}
@@ -265,9 +246,7 @@ export function useAdminBillingStuckStates(getParams) {
return {
queryKey: queryKeys.admin.billingStuckStates(queryParams),
queryFn: () => fetchBillingStuckStates(queryParams),
- staleTime: 0,
- gcTime: 1000 * 60 * 5,
- refetchOnMount: 'always',
+ ...ADMIN_QUERY_CONFIG,
};
});
}
@@ -288,9 +267,7 @@ export function useAdminOrgBillingReconcile(orgId, getParams) {
queryKey: queryKeys.admin.orgBillingReconcile(orgId, queryParams),
queryFn: () => fetchOrgBillingReconcile(orgId, queryParams),
enabled: !!orgId,
- staleTime: 0,
- gcTime: 1000 * 60 * 5,
- refetchOnMount: 'always',
+ ...ADMIN_QUERY_CONFIG,
};
});
}
@@ -302,9 +279,7 @@ export function useAdminDatabaseTables() {
return useQuery(() => ({
queryKey: queryKeys.admin.databaseTables,
queryFn: () => adminFetch('database/tables'),
- staleTime: 0,
- gcTime: 1000 * 60 * 5,
- refetchOnMount: 'always',
+ ...ADMIN_QUERY_CONFIG,
}));
}
@@ -359,9 +334,7 @@ export function useAdminTableRows(getParams) {
return adminFetch(`database/tables/${tableName}/rows?${searchParams}`);
},
enabled: !!tableName,
- staleTime: 0,
- gcTime: 1000 * 60 * 5,
- refetchOnMount: 'always',
+ ...ADMIN_QUERY_CONFIG,
};
});
}
diff --git a/packages/web/src/primitives/useNotifications.js b/packages/web/src/primitives/useNotifications.js
index 2d157a5c4..6f0d6e238 100644
--- a/packages/web/src/primitives/useNotifications.js
+++ b/packages/web/src/primitives/useNotifications.js
@@ -115,8 +115,8 @@ export function useNotifications(userId, options = {}) {
// Force close so onclose handler runs and triggers reconnection
try {
if (ws && ws.readyState !== WebSocket.CLOSED) ws.close();
- } catch (_e) {
- // ignore
+ } catch (closeErr) {
+ console.warn('Failed to close WebSocket after error:', closeErr.message);
}
};
}
diff --git a/packages/web/src/primitives/useOnlineStatus.js b/packages/web/src/primitives/useOnlineStatus.js
index 554de510e..8f42361ef 100644
--- a/packages/web/src/primitives/useOnlineStatus.js
+++ b/packages/web/src/primitives/useOnlineStatus.js
@@ -40,7 +40,8 @@ export default function useOnlineStatus() {
} finally {
clearTimeout(timeoutId);
}
- } catch {
+ } catch (err) {
+ console.warn('Connectivity check failed:', err.message);
return false;
}
}
diff --git a/packages/web/src/primitives/useProject/checklists/handlers/rob2.js b/packages/web/src/primitives/useProject/checklists/handlers/rob2.js
new file mode 100644
index 000000000..f4e85b8e4
--- /dev/null
+++ b/packages/web/src/primitives/useProject/checklists/handlers/rob2.js
@@ -0,0 +1,312 @@
+/**
+ * ROB-2 checklist type handler
+ */
+
+import * as Y from 'yjs';
+import { ChecklistHandler, yTextToString } from './base.js';
+
+export class ROB2Handler extends ChecklistHandler {
+ /**
+ * Extract answer structure from ROB-2 checklist template
+ * @param {Object} template - The checklist template from createChecklistOfType
+ * @returns {Object} Extracted answers data structure
+ */
+ extractAnswersFromTemplate(template) {
+ const answersData = {};
+ // ROB-2: Extract preliminary and all domain data
+ const rob2Keys = [
+ 'preliminary',
+ 'domain1',
+ 'domain2a',
+ 'domain2b',
+ 'domain3',
+ 'domain4',
+ 'domain5',
+ 'overall',
+ ];
+ rob2Keys.forEach(key => {
+ if (template[key] !== undefined) {
+ answersData[key] = template[key];
+ }
+ });
+ return answersData;
+ }
+
+ /**
+ * Create Y.Map structure for ROB-2 answers
+ * @param {Object} answersData - The extracted answers data
+ * @returns {Y.Map} The answers Y.Map
+ */
+ createAnswersYMap(answersData) {
+ const answersYMap = new Y.Map();
+
+ // ROB-2: Store each section/domain as nested Y.Maps
+ Object.entries(answersData).forEach(([key, value]) => {
+ const sectionYMap = new Y.Map();
+
+ if (key.startsWith('domain')) {
+ // Domain keys have nested 'answers' object with individual questions
+ sectionYMap.set('judgement', value.judgement ?? null);
+ if (value.direction !== undefined) {
+ sectionYMap.set('direction', value.direction ?? null);
+ }
+
+ // Store each question as a nested Y.Map for concurrent edits
+ if (value.answers) {
+ const answersNestedYMap = new Y.Map();
+ Object.entries(value.answers).forEach(([qKey, qValue]) => {
+ const questionYMap = new Y.Map();
+ questionYMap.set('answer', qValue.answer ?? null);
+ questionYMap.set('comment', new Y.Text());
+ answersNestedYMap.set(qKey, questionYMap);
+ });
+ sectionYMap.set('answers', answersNestedYMap);
+ }
+ } else if (key === 'overall') {
+ // Overall section has judgement and direction but no nested answers
+ sectionYMap.set('judgement', value.judgement ?? null);
+ sectionYMap.set('direction', value.direction ?? null);
+ } else if (key === 'preliminary') {
+ // Preliminary section: multiple fields including free text
+ sectionYMap.set('studyDesign', value.studyDesign ?? null);
+ sectionYMap.set('experimental', new Y.Text());
+ sectionYMap.set('comparator', new Y.Text());
+ sectionYMap.set('numericalResult', new Y.Text());
+ sectionYMap.set('aim', value.aim ?? null);
+ sectionYMap.set('deviationsToAddress', value.deviationsToAddress ?? []);
+ sectionYMap.set('sources', value.sources ?? {});
+ } else {
+ // Other sections: store each field
+ Object.entries(value).forEach(([fieldKey, fieldValue]) => {
+ sectionYMap.set(fieldKey, fieldValue);
+ });
+ }
+
+ answersYMap.set(key, sectionYMap);
+ });
+
+ return answersYMap;
+ }
+
+ /**
+ * Serialize ROB-2 answers Y.Map to plain object
+ * @param {Y.Map} answersMap - The answers Y.Map
+ * @returns {Object} Plain object with answers
+ */
+ serializeAnswers(answersMap) {
+ const answers = {};
+ for (const [key, sectionYMap] of answersMap.entries()) {
+ if (!(sectionYMap instanceof Y.Map)) {
+ answers[key] = sectionYMap;
+ continue;
+ }
+
+ if (key.startsWith('domain')) {
+ const sectionData = {
+ judgement: sectionYMap.get('judgement') ?? null,
+ answers: {},
+ };
+ const direction = sectionYMap.get('direction');
+ if (direction !== undefined) {
+ sectionData.direction = direction;
+ }
+
+ // Reconstruct nested answers
+ const answersNestedYMap = sectionYMap.get('answers');
+ if (answersNestedYMap instanceof Y.Map) {
+ for (const [qKey, questionYMap] of answersNestedYMap.entries()) {
+ if (questionYMap instanceof Y.Map) {
+ const commentValue = questionYMap.get('comment');
+ sectionData.answers[qKey] = {
+ answer: questionYMap.get('answer') ?? null,
+ comment: yTextToString(commentValue),
+ };
+ } else {
+ sectionData.answers[qKey] = questionYMap;
+ }
+ }
+ }
+ answers[key] = sectionData;
+ } else if (key === 'overall') {
+ // Overall section has judgement and direction but no nested answers
+ const sectionData = {
+ judgement: sectionYMap.get('judgement') ?? null,
+ };
+ const direction = sectionYMap.get('direction');
+ if (direction !== undefined) {
+ sectionData.direction = direction;
+ }
+ answers[key] = sectionData;
+ } else if (key === 'preliminary') {
+ // Preliminary section: convert Y.Text fields to strings
+ const sectionData = {
+ studyDesign: sectionYMap.get('studyDesign') ?? null,
+ experimental: yTextToString(sectionYMap.get('experimental')),
+ comparator: yTextToString(sectionYMap.get('comparator')),
+ numericalResult: yTextToString(sectionYMap.get('numericalResult')),
+ aim: sectionYMap.get('aim') ?? null,
+ deviationsToAddress: sectionYMap.get('deviationsToAddress') ?? [],
+ sources: sectionYMap.get('sources') ?? {},
+ };
+ answers[key] = sectionData;
+ } else {
+ // Other sections: convert Y.Map to plain object
+ const sectionData = {};
+ for (const [fieldKey, fieldValue] of sectionYMap.entries()) {
+ if (fieldValue instanceof Y.Text) {
+ sectionData[fieldKey] = fieldValue.toString();
+ } else {
+ sectionData[fieldKey] = fieldValue;
+ }
+ }
+ answers[key] = sectionData;
+ }
+ }
+ return answers;
+ }
+
+ /**
+ * Set a Y.Text field value, preserving the Y.Text object if it exists
+ * @param {Y.Map} map - The Y.Map containing the field
+ * @param {string} fieldKey - The field key
+ * @param {string|null} value - The string value to set
+ */
+ setYTextField(map, fieldKey, value) {
+ const str = value ?? '';
+ const existing = map.get(fieldKey);
+ if (existing instanceof Y.Text) {
+ existing.delete(0, existing.length);
+ existing.insert(0, str);
+ } else {
+ const newText = new Y.Text();
+ newText.insert(0, str);
+ map.set(fieldKey, newText);
+ }
+ }
+
+ /**
+ * Update a single answer/section in ROB-2 checklist
+ * @param {Y.Map} answersMap - The answers Y.Map
+ * @param {string} key - The section key (e.g., 'domain1', 'preliminary')
+ * @param {Object} data - The answer data
+ */
+ updateAnswer(answersMap, key, data) {
+ let sectionYMap = answersMap.get(key);
+
+ // Create section Y.Map if it doesn't exist
+ if (!sectionYMap || !(sectionYMap instanceof Y.Map)) {
+ sectionYMap = new Y.Map();
+ answersMap.set(key, sectionYMap);
+ }
+
+ if (key.startsWith('domain') || key === 'overall') {
+ // Update judgement and direction at section level
+ if (data.judgement !== undefined) {
+ sectionYMap.set('judgement', data.judgement);
+ }
+ if (data.direction !== undefined) {
+ sectionYMap.set('direction', data.direction);
+ }
+
+ // Update individual questions in answers
+ if (data.answers) {
+ let answersNestedYMap = sectionYMap.get('answers');
+ if (!answersNestedYMap || !(answersNestedYMap instanceof Y.Map)) {
+ answersNestedYMap = new Y.Map();
+ sectionYMap.set('answers', answersNestedYMap);
+ }
+
+ Object.entries(data.answers).forEach(([qKey, qValue]) => {
+ let questionYMap = answersNestedYMap.get(qKey);
+ if (!questionYMap || !(questionYMap instanceof Y.Map)) {
+ questionYMap = new Y.Map();
+ answersNestedYMap.set(qKey, questionYMap);
+ }
+ if (qValue.answer !== undefined) questionYMap.set('answer', qValue.answer);
+ if (qValue.comment !== undefined)
+ this.setYTextField(questionYMap, 'comment', qValue.comment);
+ });
+ }
+ } else if (key === 'preliminary') {
+ // Preliminary section: update various fields
+ if (data.studyDesign !== undefined) sectionYMap.set('studyDesign', data.studyDesign);
+ if (data.aim !== undefined) sectionYMap.set('aim', data.aim);
+ if (data.deviationsToAddress !== undefined)
+ sectionYMap.set('deviationsToAddress', data.deviationsToAddress);
+ if (data.sources !== undefined) sectionYMap.set('sources', data.sources);
+ // Free text fields
+ if (data.experimental !== undefined)
+ this.setYTextField(sectionYMap, 'experimental', data.experimental);
+ if (data.comparator !== undefined)
+ this.setYTextField(sectionYMap, 'comparator', data.comparator);
+ if (data.numericalResult !== undefined)
+ this.setYTextField(sectionYMap, 'numericalResult', data.numericalResult);
+ } else {
+ // Other sections: update individual fields
+ Object.entries(data).forEach(([fieldKey, fieldValue]) => {
+ sectionYMap.set(fieldKey, fieldValue);
+ });
+ }
+ }
+
+ /**
+ * Get type-specific text getter function for ROB-2
+ * @param {Function} getYDoc - Function that returns the Y.Doc
+ * @returns {Function} getRob2Text function
+ */
+ getTextGetter(getYDoc) {
+ return (studyId, checklistId, sectionKey, fieldKey, questionKey = null) => {
+ const ydoc = getYDoc();
+ if (!ydoc) return null;
+
+ const studiesMap = ydoc.getMap('reviews');
+ const studyYMap = studiesMap.get(studyId);
+ if (!studyYMap) return null;
+
+ const checklistsMap = studyYMap.get('checklists');
+ if (!checklistsMap) return null;
+
+ const checklistYMap = checklistsMap.get(checklistId);
+ if (!checklistYMap) return null;
+
+ const checklistType = checklistYMap.get('type');
+ if (checklistType !== 'ROB2') return null;
+
+ const answersMap = checklistYMap.get('answers');
+ if (!answersMap) return null;
+
+ const sectionYMap = answersMap.get(sectionKey);
+ if (!sectionYMap || !(sectionYMap instanceof Y.Map)) return null;
+
+ // Handle domain questions
+ if (sectionKey.startsWith('domain') && questionKey) {
+ const answersNestedYMap = sectionYMap.get('answers');
+ if (!answersNestedYMap || !(answersNestedYMap instanceof Y.Map)) return null;
+
+ const questionYMap = answersNestedYMap.get(questionKey);
+ if (!questionYMap || !(questionYMap instanceof Y.Map)) return null;
+
+ const text = questionYMap.get(fieldKey);
+ if (text instanceof Y.Text) {
+ return text;
+ }
+
+ // Create Y.Text if it doesn't exist
+ const newText = new Y.Text();
+ questionYMap.set(fieldKey, newText);
+ return newText;
+ }
+
+ // Handle section-level fields (preliminary, overall)
+ const text = sectionYMap.get(fieldKey);
+ if (text instanceof Y.Text) {
+ return text;
+ }
+
+ // Create Y.Text if it doesn't exist
+ const newText = new Y.Text();
+ sectionYMap.set(fieldKey, newText);
+ return newText;
+ };
+ }
+}
diff --git a/packages/web/src/primitives/useProject/checklists/index.js b/packages/web/src/primitives/useProject/checklists/index.js
index 832e89329..9e5f17e3a 100644
--- a/packages/web/src/primitives/useProject/checklists/index.js
+++ b/packages/web/src/primitives/useProject/checklists/index.js
@@ -9,6 +9,7 @@ import { CHECKLIST_STATUS } from '@/constants/checklist-status.js';
import { createCommonOperations } from './common.js';
import { AMSTAR2Handler } from './handlers/amstar2.js';
import { ROBINSIHandler } from './handlers/robins-i.js';
+import { ROB2Handler } from './handlers/rob2.js';
/**
* Creates checklist operations
@@ -24,11 +25,13 @@ export function createChecklistOperations(_projectId, getYDoc, _isSynced) {
// Initialize type-specific handlers
const amstar2Handler = new AMSTAR2Handler();
const robinsIHandler = new ROBINSIHandler();
+ const rob2Handler = new ROB2Handler();
// Handler registry
const handlers = {
[CHECKLIST_TYPES.AMSTAR2]: amstar2Handler,
[CHECKLIST_TYPES.ROBINS_I]: robinsIHandler,
+ [CHECKLIST_TYPES.ROB2]: rob2Handler,
};
/**
@@ -237,6 +240,21 @@ export function createChecklistOperations(_projectId, getYDoc, _isSynced) {
return textGetter(studyId, checklistId, sectionKey, fieldKey, questionKey);
}
+ /**
+ * Get a Y.Text reference for a ROB-2 free-text field
+ * @param {string} studyId - The study ID
+ * @param {string} checklistId - The checklist ID
+ * @param {string} sectionKey - The section key
+ * @param {string} fieldKey - The field key
+ * @param {string} [questionKey] - Optional question key
+ * @returns {Y.Text|null} The Y.Text reference or null
+ */
+ function getRob2Text(studyId, checklistId, sectionKey, fieldKey, questionKey = null) {
+ const textGetter = rob2Handler.getTextGetter(getYDoc);
+ if (!textGetter) return null;
+ return textGetter(studyId, checklistId, sectionKey, fieldKey, questionKey);
+ }
+
return {
createChecklist,
updateChecklist: commonOps.updateChecklist,
@@ -246,5 +264,6 @@ export function createChecklistOperations(_projectId, getYDoc, _isSynced) {
updateChecklistAnswer,
getQuestionNote,
getRobinsText,
+ getRob2Text,
};
}
diff --git a/packages/web/src/primitives/useProject/index.js b/packages/web/src/primitives/useProject/index.js
index d9a763405..4a9ccb776 100644
--- a/packages/web/src/primitives/useProject/index.js
+++ b/packages/web/src/primitives/useProject/index.js
@@ -199,6 +199,7 @@ export function useProject(projectId) {
updateChecklistAnswer: connectionEntry.checklistOps.updateChecklistAnswer,
getQuestionNote: connectionEntry.checklistOps.getQuestionNote,
getRobinsText: connectionEntry.checklistOps.getRobinsText,
+ getRob2Text: connectionEntry.checklistOps.getRob2Text,
// PDF operations
addPdfToStudy: connectionEntry.pdfOps.addPdfToStudy,
removePdfFromStudy: connectionEntry.pdfOps.removePdfFromStudy,
@@ -345,6 +346,7 @@ export function useProject(projectId) {
connectionEntry?.checklistOps?.updateChecklistAnswer(...args),
getQuestionNote: (...args) => connectionEntry?.checklistOps?.getQuestionNote(...args),
getRobinsText: (...args) => connectionEntry?.checklistOps?.getRobinsText(...args),
+ getRob2Text: (...args) => connectionEntry?.checklistOps?.getRob2Text(...args),
// PDF operations
addPdfToStudy: (...args) => connectionEntry?.pdfOps?.addPdfToStudy(...args),
diff --git a/packages/web/src/stores/adminStore.js b/packages/web/src/stores/adminStore.js
index e6eab44ef..58cdb619e 100644
--- a/packages/web/src/stores/adminStore.js
+++ b/packages/web/src/stores/adminStore.js
@@ -22,7 +22,8 @@ async function checkAdminStatus() {
const data = await apiFetch.get('/api/auth/get-session', { toastMessage: false });
// Check if user has admin role
setIsAdmin(data?.user?.role === 'admin');
- } catch {
+ } catch (err) {
+ console.warn('Failed to check admin status:', err.message);
setIsAdmin(false);
} finally {
setIsAdminChecked(true);
diff --git a/packages/web/src/stores/projectActionsStore/pdfs.js b/packages/web/src/stores/projectActionsStore/pdfs.js
index f3dbf4347..c9abd22ac 100644
--- a/packages/web/src/stores/projectActionsStore/pdfs.js
+++ b/packages/web/src/stores/projectActionsStore/pdfs.js
@@ -35,8 +35,14 @@ export function createPdfActions(
try {
const [extractedTitle, extractedDoi] = await Promise.all([
- extractPdfTitle(arrayBuffer.slice(0)).catch(() => null),
- extractPdfDoi(arrayBuffer.slice(0)).catch(() => null),
+ extractPdfTitle(arrayBuffer.slice(0)).catch(err => {
+ console.warn('PDF title extraction failed:', err.message);
+ return null;
+ }),
+ extractPdfDoi(arrayBuffer.slice(0)).catch(err => {
+ console.warn('PDF DOI extraction failed:', err.message);
+ return null;
+ }),
]);
if (extractedTitle) metadata.title = extractedTitle;
@@ -155,8 +161,8 @@ export function createPdfActions(
let arrayBuffer = null;
try {
arrayBuffer = await file.arrayBuffer();
- } catch {
- // Ignore cache if arrayBuffer conversion fails
+ } catch (err) {
+ console.warn('Failed to convert file to ArrayBuffer:', err.message);
}
cachePdf(projectId, studyId, uploadResult.fileName, arrayBuffer).catch(err =>
console.warn('Failed to cache PDF:', err),
diff --git a/packages/web/src/stores/projectStore.js b/packages/web/src/stores/projectStore.js
index bd6c02ce4..93e931050 100644
--- a/packages/web/src/stores/projectStore.js
+++ b/packages/web/src/stores/projectStore.js
@@ -21,7 +21,8 @@ function loadPersistedStats() {
try {
const stored = localStorage.getItem(PROJECT_STATS_KEY);
return stored ? JSON.parse(stored) : {};
- } catch {
+ } catch (err) {
+ console.warn('Failed to load project stats from localStorage:', err.message);
return {};
}
}
@@ -32,8 +33,8 @@ function loadPersistedStats() {
function persistStats(stats) {
try {
localStorage.setItem(PROJECT_STATS_KEY, JSON.stringify(stats));
- } catch {
- // Ignore storage errors (quota exceeded, etc.)
+ } catch (err) {
+ console.warn('Failed to persist project stats to localStorage:', err.message);
}
}