@@ -70,13 +85,11 @@ export function SignallingQuestion(props) {
{/* Comment field (optional) */}
{props.showComment && (
)}
@@ -88,7 +101,7 @@ export function SignallingQuestion(props) {
* Response legend component showing what each abbreviation means
*/
export function ResponseLegend() {
- const commonResponses = ['Y', 'PY', 'PN', 'N', 'NI', 'NA', 'WN', 'SN', 'SY', 'WY'];
+ const commonResponses = ['Y', 'PY', 'PN', 'N', 'NI', 'WN', 'SN', 'SY', 'WY'];
return (
diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/__tests__/robins-scoring.test.js b/packages/web/src/components/checklist/ROBINSIChecklist/__tests__/robins-scoring.test.js
new file mode 100644
index 000000000..1dfa1e7d1
--- /dev/null
+++ b/packages/web/src/components/checklist/ROBINSIChecklist/__tests__/robins-scoring.test.js
@@ -0,0 +1,974 @@
+import { describe, it, expect } from 'vitest';
+import {
+ scoreRobinsDomain,
+ getEffectiveDomainJudgement,
+ scoreAllDomains,
+ mapOverallJudgementToDisplay,
+ JUDGEMENTS,
+} from '../scoring/robins-scoring.js';
+
+// Helper to create answer objects
+const ans = answer => ({ answer, comment: '' });
+const answers = obj => Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, ans(v)]));
+
+describe('scoreRobinsDomain', () => {
+ describe('Domain 1A (ITT - Confounding)', () => {
+ it('returns null for incomplete answers (missing Q1)', () => {
+ const result = scoreRobinsDomain('domain1a', {});
+ expect(result.judgement).toBeNull();
+ expect(result.isComplete).toBe(false);
+ });
+
+ it('returns null when Q1 answered but Q3 missing (Y/PY path)', () => {
+ const result = scoreRobinsDomain('domain1a', answers({ d1a_1: 'Y' }));
+ expect(result.judgement).toBeNull();
+ expect(result.isComplete).toBe(false);
+ });
+
+ it('Path: Q1=Y/PY -> Q3a=N/PN/NI -> Q2a=Y/PY -> NC2=N/PN -> LOW_EX', () => {
+ const result = scoreRobinsDomain(
+ 'domain1a',
+ answers({ d1a_1: 'Y', d1a_2: 'Y', d1a_3: 'N', d1a_4: 'N' }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW_EXCEPT_CONFOUNDING);
+ expect(result.ruleId).toBe('D1A.R1');
+ });
+
+ it('Path: Q1=Y/PY -> Q3a=N/PN/NI -> Q2a=Y/PY -> NC2=Y/PY -> MOD', () => {
+ const result = scoreRobinsDomain(
+ 'domain1a',
+ answers({ d1a_1: 'PY', d1a_2: 'PY', d1a_3: 'PN', d1a_4: 'Y' }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.MODERATE);
+ expect(result.ruleId).toBe('D1A.R2');
+ });
+
+ it('Path: Q1=Y/PY -> Q3a=N/PN/NI -> Q2a=WN -> NC2=N/PN -> LOW_EX', () => {
+ const result = scoreRobinsDomain(
+ 'domain1a',
+ answers({ d1a_1: 'Y', d1a_2: 'WN', d1a_3: 'N', d1a_4: 'N' }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW_EXCEPT_CONFOUNDING);
+ expect(result.ruleId).toBe('D1A.R1');
+ });
+
+ it('Path: Q1=Y/PY -> Q3a=N/PN/NI -> Q2a=SN/NI -> SER (terminal)', () => {
+ const result = scoreRobinsDomain(
+ 'domain1a',
+ answers({ d1a_1: 'Y', d1a_2: 'SN', d1a_3: 'N' }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.SERIOUS);
+ expect(result.ruleId).toBe('D1A.R3');
+ });
+
+ it('Path: Q1=Y/PY -> Q3a=Y/PY -> NC3=N/PN -> SER', () => {
+ const result = scoreRobinsDomain(
+ 'domain1a',
+ answers({ d1a_1: 'PY', d1a_3: 'Y', d1a_4: 'PN' }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.SERIOUS);
+ expect(result.ruleId).toBe('D1A.R5');
+ });
+
+ it('Path: Q1=Y/PY -> Q3a=Y/PY -> NC3=Y/PY -> CRIT', () => {
+ const result = scoreRobinsDomain('domain1a', answers({ d1a_1: 'Y', d1a_3: 'Y', d1a_4: 'Y' }));
+ expect(result.judgement).toBe(JUDGEMENTS.CRITICAL);
+ expect(result.ruleId).toBe('D1A.R4');
+ });
+
+ it('Path: Q1=WN -> Q3b=N/PN/NI -> Q2b=Y/PY/WN -> NC2=N/PN -> LOW_EX', () => {
+ const result = scoreRobinsDomain(
+ 'domain1a',
+ answers({ d1a_1: 'WN', d1a_2: 'WN', d1a_3: 'N', d1a_4: 'N' }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW_EXCEPT_CONFOUNDING);
+ expect(result.ruleId).toBe('D1A.R1');
+ });
+
+ it('Path: Q1=WN -> Q3b=Y/PY -> NC4=N/PN -> SER', () => {
+ const result = scoreRobinsDomain(
+ 'domain1a',
+ answers({ d1a_1: 'WN', d1a_3: 'Y', d1a_4: 'N' }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.SERIOUS);
+ expect(result.ruleId).toBe('D1A.R6');
+ });
+
+ it('Path: Q1=WN -> Q3b=Y/PY -> NC4=Y/PY -> CRIT', () => {
+ const result = scoreRobinsDomain(
+ 'domain1a',
+ answers({ d1a_1: 'WN', d1a_3: 'Y', d1a_4: 'Y' }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.CRITICAL);
+ expect(result.ruleId).toBe('D1A.R7');
+ });
+
+ it('Path: Q1=SN/NI -> NC1=N/PN -> SER', () => {
+ const result = scoreRobinsDomain('domain1a', answers({ d1a_1: 'SN', d1a_4: 'PN' }));
+ expect(result.judgement).toBe(JUDGEMENTS.SERIOUS);
+ expect(result.ruleId).toBe('D1A.R8');
+ });
+
+ it('Path: Q1=SN/NI -> NC1=Y/PY -> CRIT', () => {
+ const result = scoreRobinsDomain('domain1a', answers({ d1a_1: 'NI', d1a_4: 'Y' }));
+ expect(result.judgement).toBe(JUDGEMENTS.CRITICAL);
+ expect(result.ruleId).toBe('D1A.R9');
+ });
+ });
+
+ describe('Domain 1B (Per-Protocol - Confounding)', () => {
+ it('returns null for incomplete answers (missing Q1)', () => {
+ const result = scoreRobinsDomain('domain1b', {});
+ expect(result.judgement).toBeNull();
+ expect(result.isComplete).toBe(false);
+ });
+
+ it('Path: Q1=Y/PY -> Q2=Y/PY -> Q3a=Y/PY -> NC1=N/PN -> LOW', () => {
+ const result = scoreRobinsDomain(
+ 'domain1b',
+ answers({
+ d1b_1: 'Y',
+ d1b_2: 'Y',
+ d1b_3: 'PY',
+ d1b_5: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW);
+ expect(result.ruleId).toBe('D1B.R1');
+ });
+
+ it('Path: Q1=Y/PY -> Q2=Y/PY -> Q3a=Y/PY -> NC1=Y/PY -> MOD', () => {
+ const result = scoreRobinsDomain(
+ 'domain1b',
+ answers({
+ d1b_1: 'PY',
+ d1b_2: 'Y',
+ d1b_3: 'Y',
+ d1b_5: 'Y',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.MODERATE);
+ expect(result.ruleId).toBe('D1B.R2');
+ });
+
+ it('Path: Q1=Y/PY -> Q2=Y/PY -> Q3a=WN -> NC2=N/PN -> LOW_EX', () => {
+ const result = scoreRobinsDomain(
+ 'domain1b',
+ answers({
+ d1b_1: 'Y',
+ d1b_2: 'Y',
+ d1b_3: 'WN',
+ d1b_5: 'PN',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW_EXCEPT_CONFOUNDING);
+ expect(result.ruleId).toBe('D1B.R3');
+ });
+
+ it('Path: Q1=Y/PY -> Q2=WN -> Q3b=Y/PY/WN -> NC2=N/PN -> LOW_EX', () => {
+ const result = scoreRobinsDomain(
+ 'domain1b',
+ answers({
+ d1b_1: 'PY',
+ d1b_2: 'WN',
+ d1b_3: 'WN',
+ d1b_5: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW_EXCEPT_CONFOUNDING);
+ expect(result.ruleId).toBe('D1B.R3');
+ });
+
+ it('Path: Q1=Y/PY -> Q2=SN/NI -> SER (terminal)', () => {
+ const result = scoreRobinsDomain(
+ 'domain1b',
+ answers({
+ d1b_1: 'Y',
+ d1b_2: 'SN',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.SERIOUS);
+ expect(result.ruleId).toBe('D1B.R5');
+ });
+
+ it('Path: Q1=N/PN/NI -> Q4=Y/PY -> CRIT (terminal)', () => {
+ const result = scoreRobinsDomain(
+ 'domain1b',
+ answers({
+ d1b_1: 'N',
+ d1b_4: 'Y',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.CRITICAL);
+ expect(result.ruleId).toBe('D1B.R6');
+ });
+
+ it('Path: Q1=N/PN/NI -> Q4=N/PN/NI -> NC3=N/PN -> SER', () => {
+ const result = scoreRobinsDomain(
+ 'domain1b',
+ answers({
+ d1b_1: 'PN',
+ d1b_4: 'PN',
+ d1b_5: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.SERIOUS);
+ expect(result.ruleId).toBe('D1B.R8');
+ });
+
+ it('Path: Q1=N/PN/NI -> Q4=N/PN/NI -> NC3=Y/PY -> CRIT', () => {
+ const result = scoreRobinsDomain(
+ 'domain1b',
+ answers({
+ d1b_1: 'N',
+ d1b_4: 'NI',
+ d1b_5: 'Y',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.CRITICAL);
+ expect(result.ruleId).toBe('D1B.R7');
+ });
+ });
+
+ describe('Domain 2 (Classification of Interventions)', () => {
+ it('returns null for incomplete answers (missing Q1)', () => {
+ const result = scoreRobinsDomain('domain2', {});
+ expect(result.judgement).toBeNull();
+ expect(result.isComplete).toBe(false);
+ });
+
+ it('Path: A1=Y/PY -> C1=N/PN -> E1=N/PN -> LOW', () => {
+ const result = scoreRobinsDomain(
+ 'domain2',
+ answers({
+ d2_1: 'Y',
+ d2_4: 'N',
+ d2_5: 'PN',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW);
+ expect(result.ruleId).toBe('D2.R1');
+ });
+
+ it('Path: A1=Y/PY -> C1=N/PN -> E1=Y/PY/NI -> MOD', () => {
+ const result = scoreRobinsDomain(
+ 'domain2',
+ answers({
+ d2_1: 'PY',
+ d2_4: 'PN',
+ d2_5: 'Y',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.MODERATE);
+ expect(result.ruleId).toBe('D2.R2');
+ });
+
+ it('Path: A1=Y/PY -> C1=WY/NI -> E2=N/PN -> MOD', () => {
+ const result = scoreRobinsDomain(
+ 'domain2',
+ answers({
+ d2_1: 'Y',
+ d2_4: 'WY',
+ d2_5: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.MODERATE);
+ expect(result.ruleId).toBe('D2.R3');
+ });
+
+ it('Path: A1=Y/PY -> C1=SY -> E3=N/PN -> SER', () => {
+ const result = scoreRobinsDomain(
+ 'domain2',
+ answers({
+ d2_1: 'PY',
+ d2_4: 'SY',
+ d2_5: 'PN',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.SERIOUS);
+ expect(result.ruleId).toBe('D2.R4');
+ });
+
+ it('Path: A1=Y/PY -> C1=SY -> E3=Y/PY/NI -> CRIT', () => {
+ const result = scoreRobinsDomain(
+ 'domain2',
+ answers({
+ d2_1: 'Y',
+ d2_4: 'SY',
+ d2_5: 'Y',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.CRITICAL);
+ expect(result.ruleId).toBe('D2.R4');
+ });
+
+ it('Path: A1=N/PN/NI -> A2=Y/PY -> C1=N/PN -> E1=N/PN -> LOW', () => {
+ const result = scoreRobinsDomain(
+ 'domain2',
+ answers({
+ d2_1: 'N',
+ d2_2: 'Y',
+ d2_4: 'PN',
+ d2_5: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW);
+ expect(result.ruleId).toBe('D2.R5');
+ });
+
+ it('Path: A1=N/PN/NI -> A2=N/PN/NI -> A3=WY/NI -> C2=N/PN -> E2=N/PN -> MOD', () => {
+ const result = scoreRobinsDomain(
+ 'domain2',
+ answers({
+ d2_1: 'PN',
+ d2_2: 'PN',
+ d2_3: 'WY',
+ d2_4: 'N',
+ d2_5: 'PN',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.MODERATE);
+ expect(result.ruleId).toBe('D2.R6');
+ });
+
+ it('treats SY like WY for 2.3 (A3=SY takes the C2 path)', () => {
+ const result = scoreRobinsDomain(
+ 'domain2',
+ answers({
+ d2_1: 'PN',
+ d2_2: 'PN',
+ d2_3: 'SY',
+ d2_4: 'N',
+ d2_5: 'PN',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.MODERATE);
+ expect(result.ruleId).toBe('D2.R6');
+ });
+
+ it('Path: A1=N/PN/NI -> A2=N/PN/NI -> A3=N/PN -> C3=SY/WY/NI -> CRIT (terminal)', () => {
+ const result = scoreRobinsDomain(
+ 'domain2',
+ answers({
+ d2_1: 'N',
+ d2_2: 'NI',
+ d2_3: 'N',
+ d2_4: 'WY',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.CRITICAL);
+ expect(result.ruleId).toBe('D2.R7');
+ });
+ });
+
+ describe('Domain 3 (Selection Bias - Multi-step)', () => {
+ it('returns null when Part A incomplete', () => {
+ const result = scoreRobinsDomain('domain3', answers({ d3_1: 'Y' }));
+ expect(result.judgement).toBeNull();
+ expect(result.isComplete).toBe(false);
+ });
+
+ it('Path: All LOW -> LOW (terminal, no correction questions)', () => {
+ const result = scoreRobinsDomain(
+ 'domain3',
+ answers({
+ d3_1: 'Y',
+ d3_2: 'N',
+ d3_3: 'N',
+ d3_4: 'N',
+ d3_5: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW);
+ expect(result.ruleId).toBe('D3.R1');
+ });
+
+ it('Path: At worst MODERATE -> MOD (terminal, no correction questions)', () => {
+ const result = scoreRobinsDomain(
+ 'domain3',
+ answers({
+ d3_1: 'Y',
+ d3_2: 'Y',
+ d3_3: 'N',
+ d3_4: 'N',
+ d3_5: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.MODERATE);
+ expect(result.ruleId).toBe('D3.R2');
+ });
+
+ it('Path: At least one SERIOUS -> C1=Y/PY -> MOD', () => {
+ const result = scoreRobinsDomain(
+ 'domain3',
+ answers({
+ d3_1: 'SN',
+ d3_2: 'N',
+ d3_3: 'N',
+ d3_4: 'N',
+ d3_5: 'N',
+ d3_6: 'Y',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.MODERATE);
+ expect(result.ruleId).toBe('D3.R3');
+ });
+
+ it('Path: At least one SERIOUS -> C1=N/PN/NI -> C2=Y/PY -> MOD', () => {
+ const result = scoreRobinsDomain(
+ 'domain3',
+ answers({
+ d3_1: 'SN',
+ d3_2: 'N',
+ d3_3: 'N',
+ d3_4: 'N',
+ d3_5: 'N',
+ d3_6: 'N',
+ d3_7: 'Y',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.MODERATE);
+ expect(result.ruleId).toBe('D3.R3');
+ });
+
+ it('Path: At least one SERIOUS -> C1=N/PN/NI -> C2=N/PN/NI -> C3=N/PN/NI -> SER', () => {
+ const result = scoreRobinsDomain(
+ 'domain3',
+ answers({
+ d3_1: 'SN',
+ d3_2: 'N',
+ d3_3: 'N',
+ d3_4: 'N',
+ d3_5: 'N',
+ d3_6: 'PN',
+ d3_7: 'N',
+ d3_8: 'NI',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.SERIOUS);
+ expect(result.ruleId).toBe('D3.R4');
+ });
+
+ it('Path: At least one SERIOUS -> C1=N/PN/NI -> C2=N/PN/NI -> C3=Y/PY -> CRIT', () => {
+ const result = scoreRobinsDomain(
+ 'domain3',
+ answers({
+ d3_1: 'Y',
+ d3_2: 'N',
+ d3_3: 'Y',
+ d3_4: 'Y',
+ d3_5: 'Y',
+ d3_6: 'N',
+ d3_7: 'PN',
+ d3_8: 'Y',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.CRITICAL);
+ expect(result.ruleId).toBe('D3.R5');
+ });
+
+ it('Path: Part A WN/NI -> MOD, Part B N/PN -> LOW -> MOD', () => {
+ const result = scoreRobinsDomain(
+ 'domain3',
+ answers({
+ d3_1: 'WN',
+ d3_2: 'N',
+ d3_3: 'N',
+ d3_4: 'N',
+ d3_5: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.MODERATE);
+ expect(result.ruleId).toBe('D3.R2');
+ });
+
+ it('Path: Part A SN -> SER, Part B N/PN -> LOW -> SER (needs correction)', () => {
+ const result = scoreRobinsDomain(
+ 'domain3',
+ answers({
+ d3_1: 'SN',
+ d3_2: 'N',
+ d3_3: 'N',
+ d3_4: 'N',
+ d3_5: 'N',
+ d3_6: 'N',
+ d3_7: 'N',
+ d3_8: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.SERIOUS);
+ expect(result.ruleId).toBe('D3.R4');
+ });
+ });
+
+ describe('Domain 4 (Missing Data)', () => {
+ it('returns null when 4.1-4.3 incomplete', () => {
+ const result = scoreRobinsDomain('domain4', answers({ d4_1: 'Y' }));
+ expect(result.judgement).toBeNull();
+ expect(result.isComplete).toBe(false);
+ });
+
+ it('Path: All Y/PY complete data -> LOW (terminal)', () => {
+ const result = scoreRobinsDomain(
+ 'domain4',
+ answers({
+ d4_1: 'Y',
+ d4_2: 'PY',
+ d4_3: 'Y',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW);
+ expect(result.ruleId).toBe('D4.R1');
+ });
+
+ it('Path: Missing data -> B=Y/PY/NI -> C=N/PN -> LOW', () => {
+ const result = scoreRobinsDomain(
+ 'domain4',
+ answers({
+ d4_1: 'N',
+ d4_2: 'Y',
+ d4_3: 'Y',
+ d4_4: 'Y',
+ d4_5: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW);
+ expect(result.ruleId).toBe('D4.R2');
+ });
+
+ it('treats NA like NI so scoring does not get stuck (4.4=NA behaves like 4.4=NI)', () => {
+ const result = scoreRobinsDomain(
+ 'domain4',
+ answers({
+ d4_1: 'N',
+ d4_2: 'Y',
+ d4_3: 'Y',
+ d4_4: 'NA',
+ d4_5: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW);
+ expect(result.ruleId).toBe('D4.R2');
+ });
+
+ it('Path: Missing data -> B=Y/PY/NI -> C=Y/PY/NI -> E=Y/PY -> F1=Y/PY -> MOD', () => {
+ const result = scoreRobinsDomain(
+ 'domain4',
+ answers({
+ d4_1: 'N',
+ d4_2: 'Y',
+ d4_3: 'Y',
+ d4_4: 'Y',
+ d4_5: 'Y',
+ d4_6: 'Y',
+ d4_11: 'Y',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.MODERATE);
+ expect(result.ruleId).toBe('D4.R3');
+ });
+
+ it('Path: Missing data -> B=Y/PY/NI -> C=Y/PY/NI -> E=Y/PY -> F1=N/PN -> SER', () => {
+ const result = scoreRobinsDomain(
+ 'domain4',
+ answers({
+ d4_1: 'N',
+ d4_2: 'Y',
+ d4_3: 'Y',
+ d4_4: 'NI',
+ d4_5: 'Y',
+ d4_6: 'PY',
+ d4_11: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.SERIOUS);
+ expect(result.ruleId).toBe('D4.R4');
+ });
+
+ it('Path: Missing data -> B=N/PN -> D=Y/PY -> G=Y/PY -> I=Y/PY -> LOW', () => {
+ const result = scoreRobinsDomain(
+ 'domain4',
+ answers({
+ d4_1: 'PN',
+ d4_2: 'Y',
+ d4_3: 'Y',
+ d4_4: 'N',
+ d4_7: 'Y',
+ d4_8: 'Y',
+ d4_9: 'Y',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW);
+ expect(result.ruleId).toBe('D4.R5');
+ });
+
+ it('Path: Missing data -> B=N/PN -> D=Y/PY -> G=Y/PY -> I=WN/NI -> F2=Y/PY -> MOD', () => {
+ const result = scoreRobinsDomain(
+ 'domain4',
+ answers({
+ d4_1: 'N',
+ d4_2: 'Y',
+ d4_3: 'Y',
+ d4_4: 'N',
+ d4_7: 'Y',
+ d4_8: 'Y',
+ d4_9: 'WN',
+ d4_11: 'Y',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.MODERATE);
+ expect(result.ruleId).toBe('D4.R6');
+ });
+
+ it('Path: Missing data -> B=N/PN -> D=N/PN/NI -> H=Y/PY -> LOW', () => {
+ const result = scoreRobinsDomain(
+ 'domain4',
+ answers({
+ d4_1: 'N',
+ d4_2: 'N',
+ d4_3: 'N',
+ d4_4: 'N',
+ d4_7: 'N',
+ d4_10: 'Y',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW);
+ expect(result.ruleId).toBe('D4.R2');
+ });
+
+ it('Path: Missing data -> B=N/PN -> D=N/PN/NI -> H=SN -> F3=N/PN -> CRIT', () => {
+ const result = scoreRobinsDomain(
+ 'domain4',
+ answers({
+ d4_1: 'N',
+ d4_2: 'N',
+ d4_3: 'N',
+ d4_4: 'N',
+ d4_7: 'NI',
+ d4_10: 'SN',
+ d4_11: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.CRITICAL);
+ expect(result.ruleId).toBe('D4.R8');
+ });
+ });
+
+ describe('Domain 5 (Measurement of Outcome)', () => {
+ it('returns null for incomplete answers (missing Q1)', () => {
+ const result = scoreRobinsDomain('domain5', {});
+ expect(result.judgement).toBeNull();
+ expect(result.isComplete).toBe(false);
+ });
+
+ it('Path: Q1=Y/PY -> SER (terminal, no Q2/Q3 needed)', () => {
+ const result = scoreRobinsDomain(
+ 'domain5',
+ answers({
+ d5_1: 'Y',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.SERIOUS);
+ expect(result.ruleId).toBe('D5.R1');
+ });
+
+ it('Path: Q1=N/PN -> Q2a=N/PN -> LOW (terminal, no Q3 needed)', () => {
+ const result = scoreRobinsDomain(
+ 'domain5',
+ answers({
+ d5_1: 'N',
+ d5_2: 'PN',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW);
+ expect(result.ruleId).toBe('D5.R2');
+ });
+
+ it('Path: Q1=N/PN -> Q2a=Y/PY/NI -> Q3a=N/PN -> LOW', () => {
+ const result = scoreRobinsDomain(
+ 'domain5',
+ answers({
+ d5_1: 'PN',
+ d5_2: 'Y',
+ d5_3: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW);
+ expect(result.ruleId).toBe('D5.R3');
+ });
+
+ it('Path: Q1=N/PN -> Q2a=Y/PY/NI -> Q3a=WY/NI -> MOD', () => {
+ const result = scoreRobinsDomain(
+ 'domain5',
+ answers({
+ d5_1: 'N',
+ d5_2: 'PY',
+ d5_3: 'WY',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.MODERATE);
+ expect(result.ruleId).toBe('D5.R4');
+ });
+
+ it('Path: Q1=N/PN -> Q2a=Y/PY/NI -> Q3a=SY -> SER', () => {
+ const result = scoreRobinsDomain(
+ 'domain5',
+ answers({
+ d5_1: 'PN',
+ d5_2: 'Y',
+ d5_3: 'SY',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.SERIOUS);
+ expect(result.ruleId).toBe('D5.R5');
+ });
+
+ it('Path: Q1=NI -> Q2b=N/PN -> MOD (terminal, no Q3 needed)', () => {
+ const result = scoreRobinsDomain(
+ 'domain5',
+ answers({
+ d5_1: 'NI',
+ d5_2: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.MODERATE);
+ expect(result.ruleId).toBe('D5.R6');
+ });
+
+ it('Path: Q1=NI -> Q2b=Y/PY/NI -> Q3b=WY/N/PN/NI -> MOD', () => {
+ const result = scoreRobinsDomain(
+ 'domain5',
+ answers({
+ d5_1: 'NI',
+ d5_2: 'Y',
+ d5_3: 'WY',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.MODERATE);
+ expect(result.ruleId).toBe('D5.R7');
+ });
+
+ it('Path: Q1=NI -> Q2b=Y/PY/NI -> Q3b=SY -> SER', () => {
+ const result = scoreRobinsDomain(
+ 'domain5',
+ answers({
+ d5_1: 'NI',
+ d5_2: 'PY',
+ d5_3: 'SY',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.SERIOUS);
+ expect(result.ruleId).toBe('D5.R7');
+ });
+ });
+
+ describe('Domain 6 (Selection of Reported Result)', () => {
+ it('returns null for incomplete answers (missing Q1)', () => {
+ const result = scoreRobinsDomain('domain6', {});
+ expect(result.judgement).toBeNull();
+ expect(result.isComplete).toBe(false);
+ });
+
+ it('Path: Q1=Y/PY -> LOW (terminal, no selection questions needed)', () => {
+ const result = scoreRobinsDomain(
+ 'domain6',
+ answers({
+ d6_1: 'Y',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW);
+ expect(result.ruleId).toBe('D6.R1');
+ });
+
+ it('Path: Q1=N/PN/NI -> SEL: All N/PN -> LOW', () => {
+ const result = scoreRobinsDomain(
+ 'domain6',
+ answers({
+ d6_1: 'N',
+ d6_2: 'N',
+ d6_3: 'PN',
+ d6_4: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.LOW);
+ expect(result.ruleId).toBe('D6.R2');
+ });
+
+ it('Path: Q1=N/PN/NI -> SEL: At least one NI, but none Y/PY -> MOD', () => {
+ const result = scoreRobinsDomain(
+ 'domain6',
+ answers({
+ d6_1: 'PN',
+ d6_2: 'N',
+ d6_3: 'NI',
+ d6_4: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.MODERATE);
+ expect(result.ruleId).toBe('D6.R3');
+ });
+
+ it('Path: Q1=N/PN/NI -> SEL: One Y/PY -> SER', () => {
+ const result = scoreRobinsDomain(
+ 'domain6',
+ answers({
+ d6_1: 'N',
+ d6_2: 'Y',
+ d6_3: 'N',
+ d6_4: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.SERIOUS);
+ expect(result.ruleId).toBe('D6.R4');
+ });
+
+ it('Path: Q1=N/PN/NI -> SEL: All NI -> SER', () => {
+ const result = scoreRobinsDomain(
+ 'domain6',
+ answers({
+ d6_1: 'N',
+ d6_2: 'NI',
+ d6_3: 'NI',
+ d6_4: 'NI',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.SERIOUS);
+ expect(result.ruleId).toBe('D6.R4');
+ });
+
+ it('Path: Q1=N/PN/NI -> SEL: Two or more Y/PY -> CRIT', () => {
+ const result = scoreRobinsDomain(
+ 'domain6',
+ answers({
+ d6_1: 'NI',
+ d6_2: 'Y',
+ d6_3: 'PY',
+ d6_4: 'N',
+ }),
+ );
+ expect(result.judgement).toBe(JUDGEMENTS.CRITICAL);
+ expect(result.ruleId).toBe('D6.R5');
+ });
+ });
+});
+
+describe('getEffectiveDomainJudgement', () => {
+ it('returns auto judgement when source is auto', () => {
+ const domainState = { judgementSource: 'auto', judgement: null };
+ const autoScore = { judgement: JUDGEMENTS.MODERATE };
+ expect(getEffectiveDomainJudgement(domainState, autoScore)).toBe(JUDGEMENTS.MODERATE);
+ });
+
+ it('returns manual judgement when source is manual and judgement exists', () => {
+ const domainState = { judgementSource: 'manual', judgement: JUDGEMENTS.SERIOUS };
+ const autoScore = { judgement: JUDGEMENTS.LOW };
+ expect(getEffectiveDomainJudgement(domainState, autoScore)).toBe(JUDGEMENTS.SERIOUS);
+ });
+
+ it('falls back to auto when manual but no judgement set', () => {
+ const domainState = { judgementSource: 'manual', judgement: null };
+ const autoScore = { judgement: JUDGEMENTS.LOW };
+ expect(getEffectiveDomainJudgement(domainState, autoScore)).toBe(JUDGEMENTS.LOW);
+ });
+});
+
+describe('scoreAllDomains', () => {
+ it('returns incomplete when not all domains are scored', () => {
+ const checklistState = {
+ sectionC: { isPerProtocol: false },
+ domain1a: { answers: {} },
+ domain2: { answers: {} },
+ domain3: { answers: {} },
+ domain4: { answers: {} },
+ domain5: { answers: {} },
+ domain6: { answers: {} },
+ };
+ const result = scoreAllDomains(checklistState);
+ expect(result.isComplete).toBe(false);
+ expect(result.overall).toBeNull();
+ });
+
+ it('uses domain1a for ITT (isPerProtocol=false)', () => {
+ const checklistState = {
+ sectionC: { isPerProtocol: false },
+ domain1a: { answers: answers({ d1a_1: 'Y', d1a_2: 'Y', d1a_3: 'N', d1a_4: 'N' }) },
+ domain1b: { answers: {} },
+ domain2: { answers: answers({ d2_1: 'Y', d2_4: 'N', d2_5: 'N' }) },
+ domain3: { answers: answers({ d3_1: 'Y', d3_2: 'N', d3_3: 'N', d3_4: 'N', d3_5: 'N' }) },
+ domain4: { answers: answers({ d4_1: 'Y', d4_2: 'Y', d4_3: 'Y' }) },
+ domain5: { answers: answers({ d5_1: 'N', d5_2: 'N' }) },
+ domain6: { answers: answers({ d6_1: 'Y' }) },
+ };
+ const result = scoreAllDomains(checklistState);
+ expect(result.isComplete).toBe(true);
+ expect(result.domains.domain1a).toBeDefined();
+ expect(result.domains.domain1b).toBeUndefined();
+ });
+
+ it('uses domain1b for per-protocol (isPerProtocol=true)', () => {
+ const checklistState = {
+ sectionC: { isPerProtocol: true },
+ domain1a: { answers: {} },
+ domain1b: { answers: answers({ d1b_1: 'Y', d1b_2: 'Y', d1b_3: 'Y', d1b_5: 'N' }) },
+ domain2: { answers: answers({ d2_1: 'Y', d2_4: 'N', d2_5: 'N' }) },
+ domain3: { answers: answers({ d3_1: 'Y', d3_2: 'N', d3_3: 'N', d3_4: 'N', d3_5: 'N' }) },
+ domain4: { answers: answers({ d4_1: 'Y', d4_2: 'Y', d4_3: 'Y' }) },
+ domain5: { answers: answers({ d5_1: 'N', d5_2: 'N' }) },
+ domain6: { answers: answers({ d6_1: 'Y' }) },
+ };
+ const result = scoreAllDomains(checklistState);
+ expect(result.isComplete).toBe(true);
+ expect(result.domains.domain1a).toBeUndefined();
+ expect(result.domains.domain1b).toBeDefined();
+ });
+
+ it('calculates overall as max severity across domains', () => {
+ const checklistState = {
+ sectionC: { isPerProtocol: false },
+ domain1a: { answers: answers({ d1a_1: 'Y', d1a_2: 'Y', d1a_3: 'N', d1a_4: 'N' }) }, // LOW_EX
+ domain2: { answers: answers({ d2_1: 'Y', d2_4: 'N', d2_5: 'N' }) }, // LOW
+ domain3: { answers: answers({ d3_1: 'Y', d3_2: 'N', d3_3: 'N', d3_4: 'N', d3_5: 'N' }) }, // LOW
+ domain4: { answers: answers({ d4_1: 'Y', d4_2: 'Y', d4_3: 'Y' }) }, // LOW
+ domain5: { answers: answers({ d5_1: 'N', d5_2: 'N' }) }, // LOW
+ domain6: { answers: answers({ d6_1: 'Y' }) }, // LOW
+ };
+ const result = scoreAllDomains(checklistState);
+ expect(result.isComplete).toBe(true);
+ expect(result.overall).toBe(JUDGEMENTS.LOW);
+ });
+
+ it('calculates overall as CRITICAL when any domain is CRITICAL', () => {
+ const checklistState = {
+ sectionC: { isPerProtocol: false },
+ domain1a: { answers: answers({ d1a_1: 'Y', d1a_2: 'Y', d1a_3: 'N', d1a_4: 'N' }) }, // LOW_EX
+ domain2: { answers: answers({ d2_1: 'Y', d2_4: 'N', d2_5: 'N' }) }, // LOW
+ domain3: { answers: answers({ d3_1: 'Y', d3_2: 'N', d3_3: 'N', d3_4: 'N', d3_5: 'N' }) }, // LOW
+ domain4: { answers: answers({ d4_1: 'Y', d4_2: 'Y', d4_3: 'Y' }) }, // LOW
+ domain5: { answers: answers({ d5_1: 'N', d5_2: 'N' }) }, // LOW
+ domain6: { answers: answers({ d6_1: 'N', d6_2: 'Y', d6_3: 'Y', d6_4: 'N' }) }, // CRITICAL
+ };
+ const result = scoreAllDomains(checklistState);
+ expect(result.isComplete).toBe(true);
+ expect(result.overall).toBe(JUDGEMENTS.CRITICAL);
+ });
+});
+
+describe('mapOverallJudgementToDisplay', () => {
+ it('maps Low to display string', () => {
+ expect(mapOverallJudgementToDisplay(JUDGEMENTS.LOW)).toBe(
+ 'Low risk of bias except for concerns about uncontrolled confounding',
+ );
+ });
+
+ it('maps Moderate to display string', () => {
+ expect(mapOverallJudgementToDisplay(JUDGEMENTS.MODERATE)).toBe('Moderate risk');
+ });
+
+ it('maps Serious to display string', () => {
+ expect(mapOverallJudgementToDisplay(JUDGEMENTS.SERIOUS)).toBe('Serious risk');
+ });
+
+ it('maps Critical to display string', () => {
+ expect(mapOverallJudgementToDisplay(JUDGEMENTS.CRITICAL)).toBe('Critical risk');
+ });
+});
diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/checklist-map.js b/packages/web/src/components/checklist/ROBINSIChecklist/checklist-map.js
index a7dc3ab1e..60acc7c07 100644
--- a/packages/web/src/components/checklist/ROBINSIChecklist/checklist-map.js
+++ b/packages/web/src/components/checklist/ROBINSIChecklist/checklist-map.js
@@ -6,11 +6,8 @@ export const RESPONSE_TYPES = {
YN: ['Y', 'N'], // Yes, No
STANDARD: ['Y', 'PY', 'PN', 'N'], // Yes, Probably Yes, Probably No, No
WITH_NI: ['Y', 'PY', 'PN', 'N', 'NI'], // With No Information
- WITH_NA: ['NA', 'Y', 'PY', 'PN', 'N', 'NI'], // With Not Applicable
WEAK_STRONG_NO: ['Y', 'PY', 'WN', 'SN', 'NI'], // With Weak No, Strong No
- WEAK_STRONG_NO_NA: ['NA', 'Y', 'PY', 'WN', 'SN', 'NI'],
WEAK_STRONG_YES: ['SY', 'WY', 'PN', 'N', 'NI'], // Strong Yes, Weak Yes
- WEAK_STRONG_YES_NA: ['NA', 'SY', 'WY', 'PN', 'N', 'NI'],
};
// Human-readable labels for response options
@@ -20,7 +17,6 @@ export const RESPONSE_LABELS = {
PN: 'Probably No',
N: 'No',
NI: 'No Information',
- NA: 'Not Applicable',
WN: 'No, but not substantial',
SN: 'No, and probably substantial',
SY: 'Yes, substantially',
@@ -224,13 +220,13 @@ export const DOMAIN_1A = {
id: 'd1a_2',
number: '1.2',
text: 'If Y/PY/WN to 1.1: Were confounding factors that were controlled for (and for which control was necessary) measured validly and reliably by the variables available in this study?',
- responseType: 'WEAK_STRONG_NO_NA',
+ responseType: 'WEAK_STRONG_NO',
},
d1a_3: {
id: 'd1a_3',
number: '1.3',
text: 'If Y/PY/WN to 1.1: Did the authors control for any post-intervention variables that could have been affected by the intervention?',
- responseType: 'WITH_NA',
+ responseType: 'WITH_NI',
},
d1a_4: {
id: 'd1a_4',
@@ -260,19 +256,19 @@ export const DOMAIN_1B = {
id: 'd1b_2',
number: '1.2',
text: 'If Y/PY to 1.1: Did the authors control for all the important baseline and time-varying confounding factors for which this was necessary?',
- responseType: 'WEAK_STRONG_NO_NA',
+ responseType: 'WEAK_STRONG_NO',
},
d1b_3: {
id: 'd1b_3',
number: '1.3',
text: 'If Y/PY/WN to 1.2: Were confounding factors that were controlled for (and for which control was necessary) measured validly and reliably by the variables available in this study?',
- responseType: 'WEAK_STRONG_NO_NA',
+ responseType: 'WEAK_STRONG_NO',
},
d1b_4: {
id: 'd1b_4',
number: '1.4',
text: 'If N/PN/NI to 1.1: Did the authors control for time-varying factors or other variables measured after the start of intervention?',
- responseType: 'WITH_NA',
+ responseType: 'WITH_NI',
},
d1b_5: {
id: 'd1b_5',
@@ -300,13 +296,13 @@ export const DOMAIN_2 = {
id: 'd2_2',
number: '2.2',
text: 'If N/PN/NI to 2.1: Did all or nearly all outcome events occur after the intervention and comparator strategies could be distinguished?',
- responseType: 'WITH_NA',
+ responseType: 'WITH_NI',
},
d2_3: {
id: 'd2_3',
number: '2.3',
text: 'If N/PN/NI to 2.2: Did the analysis avoid problems arising from intervention strategies that are not distinguishable at the start of follow-up?',
- responseType: 'WEAK_STRONG_YES_NA',
+ responseType: 'WEAK_STRONG_YES',
},
d2_4: {
id: 'd2_4',
@@ -360,13 +356,13 @@ export const DOMAIN_3 = {
id: 'd3_4',
number: '3.4',
text: 'If Y/PY to 3.3: Were the post-intervention variables that influenced selection likely to be associated with intervention?',
- responseType: 'WITH_NA',
+ responseType: 'WITH_NI',
},
d3_5: {
id: 'd3_5',
number: '3.5',
text: 'If Y/PY to 3.4: Were the post-intervention variables that influenced selection likely to be influenced by the outcome or a cause of the outcome?',
- responseType: 'WITH_NA',
+ responseType: 'WITH_NI',
},
},
},
@@ -377,19 +373,19 @@ export const DOMAIN_3 = {
id: 'd3_6',
number: '3.6',
text: 'If SN to 3.1 or Y/PY to 3.5: Is it likely that the analysis corrected for all of the potential selection biases identified above?',
- responseType: 'WITH_NA',
+ responseType: 'WITH_NI',
},
d3_7: {
id: 'd3_7',
number: '3.7',
text: 'If N/PN/NI to 3.6: Did sensitivity analyses demonstrate that the likely impact of the potential selection biases identified above was minimal?',
- responseType: 'WITH_NA',
+ responseType: 'WITH_NI',
},
d3_8: {
id: 'd3_8',
number: '3.8',
text: 'If N/PN/NI to 3.7: Were potential selection biases identified above sufficiently severe that the result should not be included in a quantitative synthesis?',
- responseType: 'WITH_NA',
+ responseType: 'WITH_NI',
},
},
},
@@ -425,51 +421,51 @@ export const DOMAIN_4 = {
id: 'd4_4',
number: '4.4',
text: 'If N/PN/NI to 4.1, 4.2 or 4.3: Is the result based on a complete case analysis?',
- responseType: 'WITH_NA',
+ responseType: 'WITH_NI',
},
d4_5: {
id: 'd4_5',
number: '4.5',
text: 'If Y/PY/NI to 4.4: Was exclusion from the analysis because of missing data (in intervention, confounders or the outcome) likely to be related to the true value of the outcome?',
- responseType: 'WITH_NA',
+ responseType: 'WITH_NI',
},
d4_6: {
id: 'd4_6',
number: '4.6',
text: 'If Y/PY/NI to 4.5: Is the relationship between the outcome and missingness likely to be explained by the variables in the analysis model?',
- responseType: 'WEAK_STRONG_NO_NA',
+ responseType: 'WEAK_STRONG_NO',
},
d4_7: {
id: 'd4_7',
number: '4.7',
text: 'If N/PN to 4.4: Was the analysis based on imputing missing values?',
- responseType: 'WITH_NA',
- note: 'Response options: NA / Y / PY / PN / NI',
+ responseType: 'WITH_NI',
+ note: 'Response options: Y / PY / PN / NI',
},
d4_8: {
id: 'd4_8',
number: '4.8',
text: "If Y/PY to 4.7: Is it reasonable to assume that data were 'missing at random' (MAR) or 'missing completely at random' (MCAR)?",
- responseType: 'WITH_NA',
+ responseType: 'WITH_NI',
},
d4_9: {
id: 'd4_9',
number: '4.9',
text: 'If Y/PY to 4.8: Was imputation performed appropriately?',
- responseType: 'WEAK_STRONG_NO_NA',
+ responseType: 'WEAK_STRONG_NO',
},
d4_10: {
id: 'd4_10',
number: '4.10',
text: 'If N/PN/NI to 4.7: Was an appropriate alternative method used to correct for bias due to missing data?',
- responseType: 'WEAK_STRONG_NO_NA',
+ responseType: 'WEAK_STRONG_NO',
},
d4_11: {
id: 'd4_11',
number: '4.11',
text: 'If PN/N/NI to 4.1, 4.2 or 4.3 AND (Y/PY/NI to 4.5 OR WN/SN/NI to 4.9 OR WN/SN/NI to 4.10): Is there evidence that the result was not biased by missing data?',
- responseType: 'WITH_NA',
- note: 'Response options: NA / Y / PY / PN / N',
+ responseType: 'WITH_NI',
+ note: 'Response options: Y / PY / PN / N / NI',
},
},
hasDirection: true,
@@ -497,7 +493,7 @@ export const DOMAIN_5 = {
id: 'd5_3',
number: '5.3',
text: 'If Y/PY/NI to 5.2: Could assessment of the outcome have been influenced by knowledge of the intervention received?',
- responseType: 'WEAK_STRONG_YES_NA',
+ responseType: 'WEAK_STRONG_YES',
},
},
hasDirection: true,
diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/checklist.js b/packages/web/src/components/checklist/ROBINSIChecklist/checklist.js
index d846590e6..847ef1e44 100644
--- a/packages/web/src/components/checklist/ROBINSIChecklist/checklist.js
+++ b/packages/web/src/components/checklist/ROBINSIChecklist/checklist.js
@@ -4,6 +4,13 @@ import {
getActiveDomainKeys,
getDomainQuestions,
} from './checklist-map.js';
+import {
+ scoreRobinsDomain,
+ getEffectiveDomainJudgement,
+ scoreAllDomains,
+ mapOverallJudgementToDisplay,
+ JUDGEMENTS,
+} from './scoring/robins-scoring.js';
/**
* Creates a new ROBINS-I V2 checklist object with default empty answers.
@@ -112,6 +119,7 @@ export function createChecklist({
// Overall risk of bias
overall: {
judgement: null, // 'Low (except confounding)', 'Moderate', 'Serious', 'Critical'
+ judgementSource: 'auto', // 'auto' | 'manual' - tracks whether judgement is auto-calculated or manually set
direction: null,
},
};
@@ -135,6 +143,7 @@ function createDomainState(domainKey) {
return {
answers,
judgement: null, // 'Low', 'Moderate', 'Serious', 'Critical'
+ judgementSource: 'auto', // 'auto' | 'manual' - tracks whether judgement is auto-calculated or manually set
direction: domain?.hasDirection ? null : undefined,
};
}
@@ -155,7 +164,7 @@ export function shouldStopAssessment(sectionB) {
}
/**
- * Score the overall checklist based on domain judgements
+ * Score the overall checklist based on domain judgements (uses effective judgements from smart scoring)
* @param {Object} state - The complete checklist state
* @returns {string} Overall risk of bias: 'Low', 'Moderate', 'Serious', 'Critical', or 'Incomplete'
*/
@@ -167,81 +176,54 @@ export function scoreChecklist(state) {
return 'Critical';
}
- // Determine which Domain 1 variant to use
- const isPerProtocol = state.sectionC?.isPerProtocol || false;
- const activeDomains = getActiveDomainKeys(isPerProtocol);
-
- const judgements = [];
+ // Use the smart scoring engine to get all effective judgements
+ const { overall, isComplete } = scoreAllDomains(state);
- for (const domainKey of activeDomains) {
- const domain = state[domainKey];
- if (!domain?.judgement) {
- return 'Incomplete';
- }
- judgements.push(domain.judgement);
+ if (!isComplete) {
+ return 'Incomplete';
}
- // Scoring algorithm
- // Critical: At least one domain is Critical
- if (judgements.includes('Critical')) {
- return 'Critical';
- }
+ return overall || 'Incomplete';
+}
- // Serious: At least one domain is Serious
- if (judgements.includes('Serious')) {
- return 'Serious';
+/**
+ * Get detailed scoring information for all domains using the smart scoring engine
+ * @param {Object} state - The complete checklist state
+ * @returns {Object} { domains, overall, isComplete } with auto/effective/source per domain
+ */
+export function getSmartScoring(state) {
+ if (!state || typeof state !== 'object') {
+ return { domains: {}, overall: null, isComplete: false };
}
- // Moderate: Highest domain judgement is Moderate
- if (judgements.includes('Moderate')) {
- return 'Moderate';
+ // Check if assessment was stopped early
+ if (shouldStopAssessment(state.sectionB)) {
+ return {
+ domains: {},
+ overall: 'Critical',
+ isComplete: true,
+ stoppedEarly: true,
+ };
}
- // Low: All domains are Low
- return 'Low';
+ return scoreAllDomains(state);
}
/**
* Get the algorithmic suggestion for a domain's risk of bias based on signalling questions
- * This is a helper/suggestion - final judgement is made by reviewer
+ * Uses the table-driven smart scoring engine for accurate, deterministic results
* @param {string} domainKey - The domain key
* @param {Object} answers - The domain's answers object
* @returns {string|null} Suggested judgement or null if incomplete
*/
export function suggestDomainJudgement(domainKey, answers) {
- if (!answers) return null;
-
- const questionKeys = Object.keys(answers);
- if (questionKeys.length === 0) return null;
-
- // Check if all questions are answered
- const answeredQuestions = questionKeys.filter(k => answers[k]?.answer !== null);
- if (answeredQuestions.length === 0) return null;
-
- // Count different response types
- let hasNo = false;
- let hasProbablyNo = false;
- let hasStrongNo = false;
- let hasWeakNo = false;
- let hasNI = false;
-
- questionKeys.forEach(qKey => {
- const answer = answers[qKey]?.answer;
- if (answer === 'N') hasNo = true;
- if (answer === 'PN') hasProbablyNo = true;
- if (answer === 'SN') hasStrongNo = true;
- if (answer === 'WN') hasWeakNo = true;
- if (answer === 'NI') hasNI = true;
- });
-
- // Simple heuristic (actual algorithms are domain-specific in ROBINS-I guidance)
- if (hasNo || hasStrongNo) return 'Serious';
- if (hasProbablyNo || hasWeakNo) return 'Moderate';
- if (hasNI) return 'Moderate';
-
- return 'Low';
+ const result = scoreRobinsDomain(domainKey, answers);
+ return result.judgement;
}
+// Re-export smart scoring functions for use in components
+export { scoreRobinsDomain, getEffectiveDomainJudgement, mapOverallJudgementToDisplay, JUDGEMENTS };
+
/**
* Get the selected answer for a specific question
* @param {string} domainKey - The domain key
diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-1-a.md b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-1-a.md
new file mode 100644
index 000000000..df1091b33
--- /dev/null
+++ b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-1-a.md
@@ -0,0 +1,81 @@
+```mermaid
+flowchart LR
+ %% --------------------
+ %% Core questions
+ %% --------------------
+ Q1["1.1 Controlled for all the important confounding factors?"]
+ Q3a["1.3 Controlled for any post-intervention variables?"]
+ Q3b["1.3 Controlled for any post-intervention variables?"]
+ Q2a["1.2 Confounding factors measured validly and reliably?"]
+ Q2b["1.2 Confounding factors measured validly and reliably?"]
+ Q2c["1.2 Confounding factors measured validly and reliably?"]
+
+ %% --------------------
+ %% Negative controls
+ %% --------------------
+ NC1["1.4 Negative controls etc suggest serious uncontrolled confounding?"]
+ NC2["1.4 Negative controls etc suggest serious uncontrolled confounding?"]
+ NC3["1.4 Negative controls etc suggest serious uncontrolled confounding?"]
+ NC4["1.4 Negative controls etc suggest serious uncontrolled confounding?"]
+
+ %% --------------------
+ %% Outcomes
+ %% --------------------
+ LOW_EX["LOW RISK OF BIAS\n(except for concerns about uncontrolled confounding)"]
+ MOD["MODERATE RISK OF BIAS"]
+ SER["SERIOUS RISK OF BIAS"]
+ CRIT["CRITICAL RISK OF BIAS"]
+
+ %% --------------------
+ %% From 1.1
+ %% --------------------
+ Q1 -- "Y / PY" --> Q3a
+ Q1 -- "WN" --> Q3b
+ Q1 -- "SN / NI" --> NC1
+
+ %% --------------------
+ %% From 1.3 (top)
+ %% --------------------
+ Q3a -- "N / PN / NI" --> Q2a
+ Q3a -- "Y / PY" --> NC3
+
+ %% --------------------
+ %% From 1.3 (middle)
+ %% --------------------
+ Q3b -- "N / PN / NI" --> Q2b
+ Q3b -- "Y / PY" --> NC4
+
+ %% --------------------
+ %% From 1.2 (top)
+ %% --------------------
+ Q2a -- "Y / PY" --> NC2
+ Q2a -- "WN" --> NC2
+ Q2a -- "SN / NI" --> SER
+
+ %% --------------------
+ %% From 1.2 (middle)
+ %% --------------------
+ Q2b -- "Y / PY / WN" --> NC2
+ Q2b -- "SN / NI" --> SER
+
+ %% --------------------
+ %% From 1.2 (bottom)
+ %% --------------------
+ Q2c -- "Y / PY" --> SER
+ Q2c -- "SN / WN / NI" --> CRIT
+
+ %% --------------------
+ %% Negative controls → outcomes
+ %% --------------------
+ NC1 -- "N / PN" --> SER
+ NC1 -- "Y / PY" --> CRIT
+
+ NC2 -- "N / PN" --> LOW_EX
+ NC2 -- "Y / PY" --> MOD
+
+ NC3 -- "N / PN" --> SER
+ NC3 -- "Y / PY" --> CRIT
+
+ NC4 -- "N / PN" --> SER
+ NC4 -- "Y / PY" --> CRIT
+```
diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-1-b.md b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-1-b.md
new file mode 100644
index 000000000..023258bc3
--- /dev/null
+++ b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-1-b.md
@@ -0,0 +1,71 @@
+```mermaid
+flowchart LR
+ %% --------------------
+ %% Core questions
+ %% --------------------
+ Q1["1.1 Appropriate analysis method?"]
+ Q2["1.2 Controlled for all the important confounding factors?"]
+ Q3a["1.3 Confounding factors measured validly and reliably?"]
+ Q3b["1.3 Confounding factors measured validly and reliably?"]
+ Q4["1.4 Controlled for variables measured after start of intervention?"]
+
+ %% --------------------
+ %% Negative controls
+ %% --------------------
+ NC1["1.5 Negative controls etc suggest serious uncontrolled confounding?"]
+ NC2["1.5 Negative controls etc suggest serious uncontrolled confounding?"]
+ NC3["1.5 Negative controls etc suggest serious uncontrolled confounding?"]
+
+ %% --------------------
+ %% Outcomes
+ %% --------------------
+ LOW["LOW RISK OF BIAS"]
+ LOW_EX["LOW RISK OF BIAS\n(except for concerns about uncontrolled confounding)"]
+ MOD["MODERATE RISK OF BIAS"]
+ SER["SERIOUS RISK OF BIAS"]
+ CRIT["CRITICAL RISK OF BIAS"]
+
+ %% --------------------
+ %% Paths from 1.1
+ %% --------------------
+ Q1 -- "Y / PY" --> Q2
+ Q1 -- "N / PN / NI" --> Q4
+
+ %% --------------------
+ %% Paths from 1.2
+ %% --------------------
+ Q2 -- "Y / PY" --> Q3a
+ Q2 -- "WN" --> Q3b
+ Q2 -- "SN / NI" --> SER
+
+ %% --------------------
+ %% Paths from 1.3 (top)
+ %% --------------------
+ Q3a -- "Y / PY" --> NC1
+ Q3a -- "WN" --> NC2
+ Q3a -- "SN / NI" --> SER
+
+ %% --------------------
+ %% Paths from 1.3 (middle)
+ %% --------------------
+ Q3b -- "Y / PY / WN" --> NC2
+ Q3b -- "SN / NI" --> SER
+
+ %% --------------------
+ %% Paths from 1.4
+ %% --------------------
+ Q4 -- "N / PN / NI" --> NC3
+ Q4 -- "Y / PY" --> CRIT
+
+ %% --------------------
+ %% Negative controls to outcomes
+ %% --------------------
+ NC1 -- "N / PN" --> LOW
+ NC1 -- "Y / PY" --> MOD
+
+ NC2 -- "N / PN" --> LOW_EX
+ NC2 -- "Y / PY" --> SER
+
+ NC3 -- "N / PN" --> SER
+ NC3 -- "Y / PY" --> CRIT
+```
diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-2.md b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-2.md
new file mode 100644
index 000000000..11d90f624
--- /dev/null
+++ b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-2.md
@@ -0,0 +1,72 @@
+```mermaid
+flowchart LR
+ %% --------------------
+ %% Start
+ %% --------------------
+ A1["2.1 Intervention distinguishable at start of follow-up?"]
+
+ %% --------------------
+ %% Early pathway
+ %% --------------------
+ A2["2.2 Almost all outcome events after strategies distinguishable?"]
+ A3["2.3 Appropriate analysis?"]
+
+ %% --------------------
+ %% Classification influenced by outcome
+ %% --------------------
+ C1["2.4 Classification of intervention influenced by outcome?"]
+ C2["2.4 Classification of intervention influenced by outcome?"]
+ C3["2.4 Classification of intervention influenced by outcome?"]
+
+ %% --------------------
+ %% Further errors
+ %% --------------------
+ E1["2.5 Further classification errors likely?"]
+ E2["2.5 Further classification errors likely?"]
+ E3["2.5 Further classification errors likely?"]
+
+ %% --------------------
+ %% Outcomes
+ %% --------------------
+ LOW["LOW RISK OF BIAS"]
+ MOD["MODERATE RISK OF BIAS"]
+ SER["SERIOUS RISK OF BIAS"]
+ CRIT["CRITICAL RISK OF BIAS"]
+
+ %% --------------------
+ %% Connections
+ %% --------------------
+ A1 -- "Y / PY" --> C1
+ A1 -- "N / PN / NI" --> A2
+
+ A2 -- "Y / PY" --> C1
+ A2 -- "N / PN / NI" --> A3
+
+ A3 -- "SY / WY / NI" --> C2
+ A3 -- "N / PN" --> C3
+
+ %% --------------------
+ %% Classification → errors
+ %% --------------------
+ C1 -- "N / PN" --> E1
+ C1 -- "WY / NI" --> E2
+ C1 -- "SY" --> E3
+
+ C2 -- "N / PN" --> E2
+ C2 -- "SY" --> E3
+
+ C3 -- "N / PN" --> E3
+ C3 -- "SY / WY / NI" --> CRIT
+
+ %% --------------------
+ %% Error nodes → outcomes
+ %% --------------------
+ E1 -- "N / PN" --> LOW
+ E1 -- "Y / PY / NI" --> MOD
+
+ E2 -- "N / PN" --> MOD
+ E2 -- "Y / PY / NI" --> SER
+
+ E3 -- "N / PN" --> SER
+ E3 -- "Y / PY / NI" --> CRIT
+```
diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-3.md b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-3.md
new file mode 100644
index 000000000..406968649
--- /dev/null
+++ b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-3.md
@@ -0,0 +1,76 @@
+```mermaid
+flowchart LR
+ %% --------------------
+ %% Section A
+ %% --------------------
+ subgraph A["A. Follow-up and early outcomes"]
+ A1["3.1 Participants followed from start of intervention?"]
+
+ A2["3.2 Early outcome events excluded?"]
+
+ A1 -- "Y / PY" --> A2
+ A1 -- "WN / NI" --> A_MOD["MODERATE"]
+ A1 -- "SN" --> A_SER["SERIOUS"]
+
+ A2 -- "N / PN / NI" --> A_LOW["LOW"]
+ A2 -- "Y / PY" --> A_MOD
+ end
+
+ %% --------------------
+ %% Section B
+ %% --------------------
+ subgraph B["B. Selection bias"]
+ B1["3.3 Selection based on characteristics after start?"]
+ B2["3.4 Selection variables associated with intervention?"]
+ B3["3.5 Selection variables influenced by outcome?"]
+
+ B1 -- "N / PN" --> B_LOW1["LOW"]
+ B1 -- "Y / PY" --> B2
+ B1 -- "NI" --> B_MOD1["MODERATE"]
+
+ B2 -- "N / PN" --> B_LOW2["LOW"]
+ B2 -- "Y / PY" --> B3
+ B2 -- "NI" --> B_MOD2["MODERATE"]
+
+ B3 -- "N / PN / NI" --> B_MOD3["MODERATE"]
+ B3 -- "Y / PY" --> B_SER["SERIOUS"]
+ end
+
+ %% --------------------
+ %% Combine A and B
+ %% --------------------
+ subgraph AB["Across A and B"]
+ AB_LOW["All LOW"]
+ AB_MOD["At worst MODERATE"]
+ AB_SER["At least one SERIOUS"]
+ end
+
+ A_LOW --> AB_LOW
+ B_LOW1 --> AB_LOW
+ B_LOW2 --> AB_LOW
+
+ A_MOD --> AB_MOD
+ B_MOD1 --> AB_MOD
+ B_MOD2 --> AB_MOD
+ B_MOD3 --> AB_MOD
+
+ A_SER --> AB_SER
+ B_SER --> AB_SER
+
+ %% --------------------
+ %% Final adjustments
+ %% --------------------
+ AB_LOW --> LOW_RISK["LOW RISK OF BIAS"]
+ AB_MOD --> MOD_RISK["MODERATE RISK OF BIAS"]
+
+ AB_SER --> C1["3.6 Analysis corrected for selection biases?"]
+
+ C1 -- "Y / PY" --> MOD_RISK
+ C1 -- "N / PN / NI" --> C2["3.7 Sensitivity analyses demonstrate minimal impact?"]
+
+ C2 -- "Y / PY" --> MOD_RISK
+ C2 -- "N / PN / NI" --> C3["3.8 Selection biases severe?"]
+
+ C3 -- "N / PN / NI" --> SER_RISK["SERIOUS RISK OF BIAS"]
+ C3 -- "Y / PY" --> CRIT_RISK["CRITICAL RISK OF BIAS"]
+```
diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-4.md b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-4.md
new file mode 100644
index 000000000..985ea70af
--- /dev/null
+++ b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-4.md
@@ -0,0 +1,45 @@
+````mermaid
+flowchart TD
+ A[4.1–4.3 Complete data for all participants?]
+
+ A -->|All Y/PY| LOW1[LOW RISK OF BIAS]
+ A -->|Any N/PN/NI| B[4.4 Complete-case analysis?]
+
+ %% Complete-case path
+ B -->|Y/PY/NI| C[4.5 Exclusion related to true outcome?]
+ B -->|N/PN| D[4.7 Analysis based on imputation?]
+
+ C -->|N/PN| LOW2[LOW RISK OF BIAS]
+ C -->|Y/PY/NI| E[4.6 Outcome–missingness relationship explained by model?]
+
+ E -->|Y/PY| F1[4.11 Evidence result not biased?]
+ E -->|WN/NI| F2[4.11 Evidence result not biased?]
+ E -->|SN| F3[4.11 Evidence result not biased?]
+
+ %% Imputation path
+ D -->|Y/PY| G[4.8 MAR/MCAR reasonable?]
+ D -->|N/PN/NI| H[4.10 Alternative appropriate method?]
+
+ G -->|Y/PY| I[4.9 Appropriate imputation?]
+ G -->|N/PN/NI| F2
+
+ I -->|Y/PY| LOW3[LOW RISK OF BIAS]
+ I -->|WN/NI| F2
+ I -->|SN| F3
+
+ %% Alternative method path
+ H -->|Y/PY| LOW4[LOW RISK OF BIAS]
+ H -->|WN/NI| F2
+ H -->|SN| F3
+
+ %% Final evidence checks
+ F1 -->|Y/PY| MOD1[MODERATE RISK OF BIAS]
+ F1 -->|N/PN| SER1[SERIOUS RISK OF BIAS]
+
+ F2 -->|Y/PY| MOD2[MODERATE RISK OF BIAS]
+ F2 -->|N/PN| SER2[SERIOUS RISK OF BIAS]
+
+ F3 -->|Y/PY| SER3[SERIOUS RISK OF BIAS]
+ F3 -->|N/PN| CRIT[CRITICAL RISK OF BIAS]
+ ```
+````
diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-5.md b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-5.md
new file mode 100644
index 000000000..692487ebc
--- /dev/null
+++ b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-5.md
@@ -0,0 +1,52 @@
+```mermaid
+flowchart LR
+ %% --------------------
+ %% Core questions
+ %% --------------------
+ Q1["5.1 Measurement of outcome differs by intervention?"]
+
+ Q2a["5.2 Outcome assessors aware of intervention received?"]
+ Q2b["5.2 Outcome assessors aware of intervention received?"]
+
+ Q3a["5.3 Assessment could be influenced by knowledge of intervention?"]
+ Q3b["5.3 Assessment could be influenced by knowledge of intervention?"]
+
+ %% --------------------
+ %% Outcomes
+ %% --------------------
+ LOW["LOW RISK OF BIAS"]
+ MOD["MODERATE RISK OF BIAS"]
+ SER["SERIOUS RISK OF BIAS"]
+
+ %% --------------------
+ %% From 5.1
+ %% --------------------
+ Q1 -- "N / PN" --> Q2a
+ Q1 -- "NI" --> Q2b
+ Q1 -- "Y / PY" --> SER
+
+ %% --------------------
+ %% From 5.2 (top)
+ %% --------------------
+ Q2a -- "N / PN" --> LOW
+ Q2a -- "Y / PY / NI" --> Q3a
+
+ %% --------------------
+ %% From 5.2 (middle)
+ %% --------------------
+ Q2b -- "N / PN" --> MOD
+ Q2b -- "Y / PY / NI" --> Q3b
+
+ %% --------------------
+ %% From 5.3 (top)
+ %% --------------------
+ Q3a -- "N / PN" --> LOW
+ Q3a -- "WY / NI" --> MOD
+ Q3a -- "SY" --> SER
+
+ %% --------------------
+ %% From 5.3 (middle)
+ %% --------------------
+ Q3b -- "WY / N / PN / NI" --> MOD
+ Q3b -- "SY" --> SER
+```
diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-6.md b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-6.md
new file mode 100644
index 000000000..34bfae066
--- /dev/null
+++ b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/decision-diagrams/domain-6.md
@@ -0,0 +1,38 @@
+```mermaid
+flowchart LR
+ %% --------------------
+ %% Core questions
+ %% --------------------
+ Q1["6.1 Result reported according to analysis plan?"]
+
+ Q2["6.2 Multiple outcome measurements?"]
+ Q3["6.3 Multiple analyses of the data?"]
+ Q4["6.4 Multiple subgroups?"]
+
+ %% --------------------
+ %% Aggregated decision node
+ %% --------------------
+ SEL["Result selected from:\n6.2 / 6.3 / 6.4"]
+
+ %% --------------------
+ %% Outcomes
+ %% --------------------
+ LOW["LOW RISK OF BIAS"]
+ MOD["MODERATE RISK OF BIAS"]
+ SER["SERIOUS RISK OF BIAS"]
+ CRIT["CRITICAL RISK OF BIAS"]
+
+ %% --------------------
+ %% From 6.1
+ %% --------------------
+ Q1 -- "Y / PY" --> LOW
+ Q1 -- "N / PN / NI" --> SEL
+
+ %% --------------------
+ %% From selection set (6.2–6.4)
+ %% --------------------
+ SEL -- "All N / PN" --> LOW
+ SEL -- "At least one NI,\nbut none Y / PY" --> MOD
+ SEL -- "One Y / PY,\nor all NI" --> SER
+ SEL -- "Two or more Y / PY" --> CRIT
+```
diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/scoring/robins-scoring.js b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/robins-scoring.js
new file mode 100644
index 000000000..32db5e031
--- /dev/null
+++ b/packages/web/src/components/checklist/ROBINSIChecklist/scoring/robins-scoring.js
@@ -0,0 +1,1114 @@
+/**
+ * ROBINS-I V2 Smart Scoring Engine
+ *
+ * Implements deterministic, table-driven scoring for all ROBINS-I domains
+ * based on the official decision tables.
+ */
+
+// Helper: check if answer matches any value in a set
+const inSet = (answer, ...values) => values.includes(answer);
+
+// Normalization: treat NA as NI for scoring to avoid "stuck" branches
+// (The mermaid decision diagrams generally model NI but omit NA.)
+const normalizeAnswer = answer => (answer === 'NA' ? 'NI' : answer);
+
+// Helper: check if answer is Yes or Probably Yes
+const isYesPY = answer => inSet(answer, 'Y', 'PY');
+
+// Helper: check if answer is No or Probably No
+const isNoPPN = answer => inSet(answer, 'N', 'PN');
+
+// Helper: check if answer is No, Probably No, or No Information
+const isNoPPNNI = answer => inSet(answer, 'N', 'PN', 'NI');
+
+// Canonical judgement values matching ROB_JUDGEMENTS in checklist-map.js
+const JUDGEMENTS = {
+ LOW: 'Low',
+ LOW_EXCEPT_CONFOUNDING: 'Low (except for concerns about uncontrolled confounding)',
+ MODERATE: 'Moderate',
+ SERIOUS: 'Serious',
+ CRITICAL: 'Critical',
+};
+
+/**
+ * Score Domain 1A (Bias due to confounding - ITT effect)
+ *
+ * Questions: d1a_1, d1a_2, d1a_3, d1a_4
+ * Flow from domain-1-a.md mermaid diagram
+ */
+function scoreDomain1A(answers) {
+ const q1 = normalizeAnswer(answers.d1a_1?.answer); // 1.1 Controlled for all the important confounding factors?
+ const q2 = normalizeAnswer(answers.d1a_2?.answer); // 1.2 Confounding factors measured validly and reliably?
+ const q3 = normalizeAnswer(answers.d1a_3?.answer); // 1.3 Controlled for any post-intervention variables?
+ const q4 = normalizeAnswer(answers.d1a_4?.answer); // 1.4 Negative controls etc suggest serious uncontrolled confounding?
+
+ // Must have Q1 to start
+ if (q1 === null || q1 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // Path: Q1 -> if SN/NI -> NC1 -> outcomes
+ if (inSet(q1, 'SN', 'NI')) {
+ if (q4 === null || q4 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q4)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1A.R8' };
+ }
+ if (isYesPY(q4)) {
+ return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D1A.R9' };
+ }
+ }
+
+ // Path: Q1 -> if Y/PY -> Q3a
+ if (isYesPY(q1)) {
+ if (q3 === null || q3 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // Q3a -> if Y/PY -> NC3
+ if (isYesPY(q3)) {
+ if (q4 === null || q4 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q4)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1A.R5' };
+ }
+ if (isYesPY(q4)) {
+ return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D1A.R4' };
+ }
+ }
+
+ // Q3a -> if N/PN/NI -> Q2a
+ if (isNoPPNNI(q3)) {
+ if (q2 === null || q2 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // Q2a -> if SN/NI -> SER (terminal, no NC needed)
+ if (inSet(q2, 'SN', 'NI')) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1A.R3' };
+ }
+
+ // Q2a -> if Y/PY or WN -> NC2
+ if (isYesPY(q2) || q2 === 'WN') {
+ if (q4 === null || q4 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q4)) {
+ return {
+ judgement: JUDGEMENTS.LOW_EXCEPT_CONFOUNDING,
+ isComplete: true,
+ ruleId: 'D1A.R1',
+ };
+ }
+ if (isYesPY(q4)) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D1A.R2' };
+ }
+ }
+ }
+ }
+
+ // Path: Q1 -> if WN -> Q3b
+ if (q1 === 'WN') {
+ if (q3 === null || q3 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // Q3b -> if Y/PY -> NC4
+ if (isYesPY(q3)) {
+ if (q4 === null || q4 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q4)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1A.R6' };
+ }
+ if (isYesPY(q4)) {
+ return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D1A.R7' };
+ }
+ }
+
+ // Q3b -> if N/PN/NI -> Q2b
+ if (isNoPPNNI(q3)) {
+ if (q2 === null || q2 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // Q2b -> if SN/NI -> SER (terminal, no NC needed)
+ if (inSet(q2, 'SN', 'NI')) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1A.R7' };
+ }
+
+ // Q2b -> if Y/PY/WN -> NC2
+ if (isYesPY(q2) || q2 === 'WN') {
+ if (q4 === null || q4 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q4)) {
+ return {
+ judgement: JUDGEMENTS.LOW_EXCEPT_CONFOUNDING,
+ isComplete: true,
+ ruleId: 'D1A.R1',
+ };
+ }
+ if (isYesPY(q4)) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D1A.R2' };
+ }
+ }
+ }
+ }
+
+ // Incomplete - need more answers
+ return { judgement: null, isComplete: false, ruleId: null };
+}
+
+/**
+ * Score Domain 1B (Bias due to confounding - Per-Protocol effect)
+ *
+ * Questions: d1b_1, d1b_2, d1b_3, d1b_4, d1b_5
+ * Flow from domain-1-b.md mermaid diagram
+ */
+function scoreDomain1B(answers) {
+ const q1 = normalizeAnswer(answers.d1b_1?.answer); // 1.1 Appropriate analysis method?
+ const q2 = normalizeAnswer(answers.d1b_2?.answer); // 1.2 Controlled for all the important confounding factors?
+ const q3 = normalizeAnswer(answers.d1b_3?.answer); // 1.3 Confounding factors measured validly and reliably?
+ const q4 = normalizeAnswer(answers.d1b_4?.answer); // 1.4 Controlled for variables measured after start of intervention?
+ const q5 = normalizeAnswer(answers.d1b_5?.answer); // 1.5 Negative controls etc suggest serious uncontrolled confounding?
+
+ // Must have Q1 to start
+ if (q1 === null || q1 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // Path: Q1 -> if N/PN/NI -> Q4
+ if (isNoPPNNI(q1)) {
+ if (q4 === null || q4 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // Q4 -> if Y/PY -> CRIT (terminal, no NC needed)
+ if (isYesPY(q4)) {
+ return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D1B.R6' };
+ }
+
+ // Q4 -> if N/PN/NI -> NC3
+ if (isNoPPNNI(q4)) {
+ if (q5 === null || q5 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q5)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R8' };
+ }
+ if (isYesPY(q5)) {
+ return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D1B.R7' };
+ }
+ }
+ }
+
+ // Path: Q1 -> if Y/PY -> Q2
+ if (isYesPY(q1)) {
+ if (q2 === null || q2 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // Q2 -> if SN/NI -> SER (terminal, no Q3 needed)
+ if (inSet(q2, 'SN', 'NI')) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R5' };
+ }
+
+ // Q2 -> if Y/PY -> Q3a
+ if (isYesPY(q2)) {
+ if (q3 === null || q3 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // Q3a -> if SN/NI -> SER (terminal, no NC needed)
+ if (inSet(q3, 'SN', 'NI')) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R5' };
+ }
+
+ // Q3a -> if Y/PY -> NC1
+ if (isYesPY(q3)) {
+ if (q5 === null || q5 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q5)) {
+ return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D1B.R1' };
+ }
+ if (isYesPY(q5)) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D1B.R2' };
+ }
+ }
+
+ // Q3a -> if WN -> NC2
+ if (q3 === 'WN') {
+ if (q5 === null || q5 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q5)) {
+ return {
+ judgement: JUDGEMENTS.LOW_EXCEPT_CONFOUNDING,
+ isComplete: true,
+ ruleId: 'D1B.R3',
+ };
+ }
+ if (isYesPY(q5)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R4' };
+ }
+ }
+ }
+
+ // Q2 -> if WN -> Q3b
+ if (q2 === 'WN') {
+ if (q3 === null || q3 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // Q3b -> if SN/NI -> SER (terminal, no NC needed)
+ if (inSet(q3, 'SN', 'NI')) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R5' };
+ }
+
+ // Q3b -> if Y/PY/WN -> NC2
+ if (isYesPY(q3) || q3 === 'WN') {
+ if (q5 === null || q5 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q5)) {
+ return {
+ judgement: JUDGEMENTS.LOW_EXCEPT_CONFOUNDING,
+ isComplete: true,
+ ruleId: 'D1B.R3',
+ };
+ }
+ if (isYesPY(q5)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D1B.R4' };
+ }
+ }
+ }
+ }
+
+ // Incomplete - need more answers
+ return { judgement: null, isComplete: false, ruleId: null };
+}
+
+/**
+ * Score Domain 2 (Bias in classification of interventions)
+ *
+ * Questions: d2_1, d2_2, d2_3, d2_4, d2_5
+ * Flow from domain-2.md mermaid diagram
+ */
+function scoreDomain2(answers) {
+ const q1 = normalizeAnswer(answers.d2_1?.answer); // 2.1 Intervention distinguishable at start of follow-up?
+ const q2 = normalizeAnswer(answers.d2_2?.answer); // 2.2 Almost all outcome events after strategies distinguishable?
+ const q3 = normalizeAnswer(answers.d2_3?.answer); // 2.3 Appropriate analysis?
+ const q4 = normalizeAnswer(answers.d2_4?.answer); // 2.4 Classification of intervention influenced by outcome?
+ const q5 = normalizeAnswer(answers.d2_5?.answer); // 2.5 Further classification errors likely?
+
+ // Must have A1 (q1) to start
+ if (q1 === null || q1 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // Path: A1 -> if Y/PY -> C1
+ if (isYesPY(q1)) {
+ if (q4 === null || q4 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // C1 -> if SY -> E3
+ if (q4 === 'SY') {
+ if (q5 === null || q5 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q5)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R4' };
+ }
+ if (inSet(q5, 'Y', 'PY', 'NI')) {
+ return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D2.R4' };
+ }
+ }
+
+ // C1 -> if WY/NI -> E2
+ if (inSet(q4, 'WY', 'NI')) {
+ if (q5 === null || q5 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q5)) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D2.R3' };
+ }
+ if (inSet(q5, 'Y', 'PY', 'NI')) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R3' };
+ }
+ }
+
+ // C1 -> if N/PN -> E1
+ if (isNoPPN(q4)) {
+ if (q5 === null || q5 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q5)) {
+ return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D2.R1' };
+ }
+ if (inSet(q5, 'Y', 'PY', 'NI')) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D2.R2' };
+ }
+ }
+ }
+
+ // Path: A1 -> if N/PN/NI -> A2
+ if (isNoPPNNI(q1)) {
+ if (q2 === null || q2 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // A2 -> if Y/PY -> C1 (same logic as above)
+ if (isYesPY(q2)) {
+ if (q4 === null || q4 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ if (q4 === 'SY') {
+ if (q5 === null || q5 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q5)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R4' };
+ }
+ if (inSet(q5, 'Y', 'PY', 'NI')) {
+ return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D2.R4' };
+ }
+ }
+
+ if (inSet(q4, 'WY', 'NI')) {
+ if (q5 === null || q5 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q5)) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D2.R3' };
+ }
+ if (inSet(q5, 'Y', 'PY', 'NI')) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R3' };
+ }
+ }
+
+ if (isNoPPN(q4)) {
+ if (q5 === null || q5 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q5)) {
+ return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D2.R5' };
+ }
+ if (inSet(q5, 'Y', 'PY', 'NI')) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D2.R2' };
+ }
+ }
+ }
+
+ // A2 -> if N/PN/NI -> A3
+ if (isNoPPNNI(q2)) {
+ if (q3 === null || q3 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // A3 -> if N/PN -> C3
+ if (isNoPPN(q3)) {
+ if (q4 === null || q4 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ // C3 -> if SY/WY/NI -> CRIT (terminal, no E needed)
+ if (inSet(q4, 'SY', 'WY', 'NI')) {
+ return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D2.R7' };
+ }
+ // C3 -> if N/PN -> E3
+ if (isNoPPN(q4)) {
+ if (q5 === null || q5 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q5)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R7' };
+ }
+ if (inSet(q5, 'Y', 'PY', 'NI')) {
+ return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D2.R7' };
+ }
+ }
+ }
+
+ // A3 -> if SY/WY/NI -> C2
+ if (inSet(q3, 'SY', 'WY', 'NI')) {
+ if (q4 === null || q4 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ // C2 -> if SY -> E3
+ if (q4 === 'SY') {
+ if (q5 === null || q5 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q5)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R6' };
+ }
+ if (inSet(q5, 'Y', 'PY', 'NI')) {
+ return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D2.R6' };
+ }
+ }
+ // C2 -> if N/PN -> E2
+ if (isNoPPN(q4)) {
+ if (q5 === null || q5 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isNoPPN(q5)) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D2.R6' };
+ }
+ if (inSet(q5, 'Y', 'PY', 'NI')) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D2.R6' };
+ }
+ }
+ }
+ }
+ }
+
+ // Incomplete - need more answers
+ return { judgement: null, isComplete: false, ruleId: null };
+}
+
+/**
+ * Score Domain 3 Part A (Selection bias - prevalent user bias and immortal time)
+ *
+ * Questions: d3_1, d3_2
+ * Flow from domain-3.md mermaid diagram Section A
+ */
+function scoreDomain3PartA(answers) {
+ const q1 = normalizeAnswer(answers.d3_1?.answer); // 3.1 Participants followed from start of intervention?
+ const q2 = normalizeAnswer(answers.d3_2?.answer); // 3.2 Early outcome events excluded?
+
+ if (q1 === null || q1 === undefined) {
+ return { result: null, isComplete: false };
+ }
+
+ // A1 -> if SN -> A_SER (Serious)
+ if (q1 === 'SN') {
+ return { result: 'Serious', isComplete: true };
+ }
+
+ // A1 -> if WN/NI -> A_MOD (Moderate)
+ if (inSet(q1, 'WN', 'NI')) {
+ return { result: 'Moderate', isComplete: true };
+ }
+
+ // A1 -> if Y/PY -> A2
+ if (isYesPY(q1)) {
+ if (q2 === null || q2 === undefined) {
+ return { result: null, isComplete: false };
+ }
+ // A2 -> if N/PN/NI -> A_LOW (Low)
+ if (isNoPPNNI(q2)) {
+ return { result: 'Low', isComplete: true };
+ }
+ // A2 -> if Y/PY -> A_MOD (Moderate)
+ if (isYesPY(q2)) {
+ return { result: 'Moderate', isComplete: true };
+ }
+ }
+
+ return { result: null, isComplete: q2 !== null && q2 !== undefined };
+}
+
+/**
+ * Score Domain 3 Part B (Selection bias - other types)
+ *
+ * Questions: d3_3, d3_4, d3_5
+ * Flow from domain-3.md mermaid diagram Section B
+ */
+function scoreDomain3PartB(answers) {
+ const q3 = normalizeAnswer(answers.d3_3?.answer); // 3.3 Selection based on characteristics after start?
+ const q4 = normalizeAnswer(answers.d3_4?.answer); // 3.4 Selection variables associated with intervention?
+ const q5 = normalizeAnswer(answers.d3_5?.answer); // 3.5 Selection variables influenced by outcome?
+
+ if (q3 === null || q3 === undefined) {
+ return { result: null, isComplete: false };
+ }
+
+ // B1 -> if N/PN -> B_LOW1 (Low)
+ if (isNoPPN(q3)) {
+ return { result: 'Low', isComplete: true };
+ }
+
+ // B1 -> if NI -> B_MOD1 (Moderate)
+ if (q3 === 'NI') {
+ return { result: 'Moderate', isComplete: true };
+ }
+
+ // B1 -> if Y/PY -> B2
+ if (isYesPY(q3)) {
+ if (q4 === null || q4 === undefined) {
+ return { result: null, isComplete: false };
+ }
+
+ // B2 -> if N/PN -> B_LOW2 (Low)
+ if (isNoPPN(q4)) {
+ return { result: 'Low', isComplete: true };
+ }
+
+ // B2 -> if NI -> B_MOD2 (Moderate)
+ if (q4 === 'NI') {
+ return { result: 'Moderate', isComplete: true };
+ }
+
+ // B2 -> if Y/PY -> B3
+ if (isYesPY(q4)) {
+ if (q5 === null || q5 === undefined) {
+ return { result: null, isComplete: false };
+ }
+ // B3 -> if N/PN/NI -> B_MOD3 (Moderate)
+ if (isNoPPNNI(q5)) {
+ return { result: 'Moderate', isComplete: true };
+ }
+ // B3 -> if Y/PY -> B_SER (Serious)
+ if (isYesPY(q5)) {
+ return { result: 'Serious', isComplete: true };
+ }
+ }
+ }
+
+ const allAnswered = [q3, q4, q5].every(a => a !== null && a !== undefined);
+ return { result: null, isComplete: allAnswered };
+}
+
+/**
+ * Score Domain 3 Final (combines Part A + Part B with correction questions)
+ *
+ * Questions: d3_6, d3_7, d3_8
+ * Flow from domain-3.md mermaid diagram - combines Section A and B, then applies corrections
+ */
+function scoreDomain3(answers) {
+ const partA = scoreDomain3PartA(answers);
+ const partB = scoreDomain3PartB(answers);
+
+ // If either part is incomplete, domain is incomplete
+ if (!partA.isComplete || !partB.isComplete) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ const q6 = normalizeAnswer(answers.d3_6?.answer); // 3.6 Analysis corrected for selection biases?
+ const q7 = normalizeAnswer(answers.d3_7?.answer); // 3.7 Sensitivity analyses demonstrate minimal impact?
+ const q8 = normalizeAnswer(answers.d3_8?.answer); // 3.8 Selection biases severe?
+
+ // Determine combined result: All LOW, At worst MODERATE, or At least one SERIOUS
+ const rankMap = { Low: 0, Moderate: 1, Serious: 2 };
+ const aRank = rankMap[partA.result] ?? 0;
+ const bRank = rankMap[partB.result] ?? 0;
+ const worstRank = Math.max(aRank, bRank);
+
+ // All LOW -> LOW_RISK (terminal, no correction questions needed)
+ if (partA.result === 'Low' && partB.result === 'Low') {
+ return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D3.R1' };
+ }
+
+ // At worst MODERATE -> MOD_RISK (terminal, no correction questions needed)
+ if (worstRank <= 1) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D3.R2' };
+ }
+
+ // At least one SERIOUS -> need correction/sensitivity questions
+ if (worstRank >= 2) {
+ // C1 (3.6) -> if Y/PY -> MOD_RISK
+ if (isYesPY(q6)) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D3.R3' };
+ }
+
+ // C1 -> if N/PN/NI -> C2 (3.7)
+ if (isNoPPNNI(q6)) {
+ // C2 -> if Y/PY -> MOD_RISK
+ if (isYesPY(q7)) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D3.R3' };
+ }
+
+ // C2 -> if N/PN/NI -> C3 (3.8)
+ if (isNoPPNNI(q7)) {
+ if (q8 === null || q8 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ // C3 -> if N/PN/NI -> SER_RISK
+ if (isNoPPNNI(q8)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D3.R4' };
+ }
+ // C3 -> if Y/PY -> CRIT_RISK
+ if (isYesPY(q8)) {
+ return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D3.R5' };
+ }
+ }
+ }
+ }
+
+ // Need more answers for final determination
+ return { judgement: null, isComplete: false, ruleId: null };
+}
+
+/**
+ * Score Domain 4 (Bias due to missing data)
+ *
+ * Questions: d4_1 through d4_11
+ * Flow from domain-4.md mermaid diagram
+ */
+function scoreDomain4(answers) {
+ const q1 = normalizeAnswer(answers.d4_1?.answer); // 4.1 Complete data on intervention
+ const q2 = normalizeAnswer(answers.d4_2?.answer); // 4.2 Complete data on outcome
+ const q3 = normalizeAnswer(answers.d4_3?.answer); // 4.3 Complete data on confounders
+ const q4 = normalizeAnswer(answers.d4_4?.answer); // 4.4 Complete-case analysis?
+ const q5 = normalizeAnswer(answers.d4_5?.answer); // 4.5 Exclusion related to true outcome?
+ const q6 = normalizeAnswer(answers.d4_6?.answer); // 4.6 Outcome–missingness relationship explained by model?
+ const q7 = normalizeAnswer(answers.d4_7?.answer); // 4.7 Analysis based on imputation?
+ const q8 = normalizeAnswer(answers.d4_8?.answer); // 4.8 MAR/MCAR reasonable?
+ const q9 = normalizeAnswer(answers.d4_9?.answer); // 4.9 Appropriate imputation?
+ const q10 = normalizeAnswer(answers.d4_10?.answer); // 4.10 Alternative appropriate method?
+ const q11 = normalizeAnswer(answers.d4_11?.answer); // 4.11 Evidence result not biased?
+
+ // A: Check 4.1-4.3 complete data
+ const completeDataAnswered = [q1, q2, q3].every(a => a !== null && a !== undefined);
+ if (!completeDataAnswered) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ const allCompleteData = [q1, q2, q3].every(a => isYesPY(a));
+
+ // A -> if All Y/PY -> LOW1 (terminal)
+ if (allCompleteData) {
+ return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D4.R1' };
+ }
+
+ // A -> if Any N/PN/NI -> B (4.4)
+ if ([q1, q2, q3].some(a => isNoPPNNI(a))) {
+ if (q4 === null || q4 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // B -> if Y/PY/NI -> C (4.5) - complete-case path
+ if (isYesPY(q4) || q4 === 'NI') {
+ if (q5 === null || q5 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // C -> if N/PN -> LOW2 (terminal)
+ if (isNoPPN(q5)) {
+ return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D4.R2' };
+ }
+
+ // C -> if Y/PY/NI -> E (4.6)
+ if (isYesPY(q5) || q5 === 'NI') {
+ if (q6 === null || q6 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // E -> if Y/PY -> F1 (4.11)
+ if (isYesPY(q6)) {
+ if (q11 === null || q11 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isYesPY(q11)) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D4.R3' };
+ }
+ if (isNoPPN(q11)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R4' };
+ }
+ }
+
+ // E -> if WN/NI -> F2 (4.11)
+ if (inSet(q6, 'WN', 'NI')) {
+ if (q11 === null || q11 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isYesPY(q11)) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D4.R6' };
+ }
+ if (isNoPPN(q11)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' };
+ }
+ }
+
+ // E -> if SN -> F3 (4.11)
+ if (q6 === 'SN') {
+ if (q11 === null || q11 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isYesPY(q11)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' };
+ }
+ if (isNoPPN(q11)) {
+ return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D4.R8' };
+ }
+ }
+ }
+ }
+
+ // B -> if N/PN -> D (4.7) - imputation/alternative method path
+ if (isNoPPN(q4)) {
+ if (q7 === null || q7 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // D -> if Y/PY -> G (4.8) - imputation path
+ if (isYesPY(q7)) {
+ if (q8 === null || q8 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // G -> if Y/PY -> I (4.9)
+ if (isYesPY(q8)) {
+ if (q9 === null || q9 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ // I -> if Y/PY -> LOW3 (terminal)
+ if (isYesPY(q9)) {
+ return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D4.R5' };
+ }
+ // I -> if WN/NI -> F2 (4.11)
+ if (inSet(q9, 'WN', 'NI')) {
+ if (q11 === null || q11 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isYesPY(q11)) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D4.R6' };
+ }
+ if (isNoPPN(q11)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' };
+ }
+ }
+ // I -> if SN -> F3 (4.11)
+ if (q9 === 'SN') {
+ if (q11 === null || q11 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isYesPY(q11)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' };
+ }
+ if (isNoPPN(q11)) {
+ return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D4.R8' };
+ }
+ }
+ }
+
+ // G -> if N/PN/NI -> F2 (4.11)
+ if (isNoPPNNI(q8)) {
+ if (q11 === null || q11 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isYesPY(q11)) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D4.R6' };
+ }
+ if (isNoPPN(q11)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' };
+ }
+ }
+ }
+
+ // D -> if N/PN/NI -> H (4.10) - alternative method path
+ if (isNoPPNNI(q7)) {
+ if (q10 === null || q10 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ // H -> if Y/PY -> LOW4 (terminal)
+ if (isYesPY(q10)) {
+ return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D4.R2' };
+ }
+ // H -> if WN/NI -> F2 (4.11)
+ if (inSet(q10, 'WN', 'NI')) {
+ if (q11 === null || q11 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isYesPY(q11)) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D4.R6' };
+ }
+ if (isNoPPN(q11)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' };
+ }
+ }
+ // H -> if SN -> F3 (4.11)
+ if (q10 === 'SN') {
+ if (q11 === null || q11 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ if (isYesPY(q11)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D4.R7' };
+ }
+ if (isNoPPN(q11)) {
+ return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D4.R8' };
+ }
+ }
+ }
+ }
+ }
+
+ // Incomplete - need more answers
+ return { judgement: null, isComplete: false, ruleId: null };
+}
+
+/**
+ * Score Domain 5 (Bias in measurement of the outcome)
+ *
+ * Questions: d5_1, d5_2, d5_3
+ * Flow from domain-5.md mermaid diagram
+ */
+function scoreDomain5(answers) {
+ const q1 = normalizeAnswer(answers.d5_1?.answer); // 5.1 Measurement of outcome differs by intervention?
+ const q2 = normalizeAnswer(answers.d5_2?.answer); // 5.2 Outcome assessors aware of intervention received?
+ const q3 = normalizeAnswer(answers.d5_3?.answer); // 5.3 Assessment could be influenced by knowledge of intervention?
+
+ // Must have Q1 to start
+ if (q1 === null || q1 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // Q1 -> if Y/PY -> SER (terminal, no Q2/Q3 needed)
+ if (isYesPY(q1)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D5.R1' };
+ }
+
+ // Q1 -> if N/PN -> Q2a
+ if (isNoPPN(q1)) {
+ if (q2 === null || q2 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // Q2a -> if N/PN -> LOW (terminal, no Q3 needed)
+ if (isNoPPN(q2)) {
+ return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D5.R2' };
+ }
+
+ // Q2a -> if Y/PY/NI -> Q3a
+ if (inSet(q2, 'Y', 'PY', 'NI')) {
+ if (q3 === null || q3 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ // Q3a -> if N/PN -> LOW
+ if (isNoPPN(q3)) {
+ return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D5.R3' };
+ }
+ // Q3a -> if WY/NI -> MOD
+ if (inSet(q3, 'WY', 'NI')) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D5.R4' };
+ }
+ // Q3a -> if SY -> SER
+ if (q3 === 'SY') {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D5.R5' };
+ }
+ }
+ }
+
+ // Q1 -> if NI -> Q2b
+ if (q1 === 'NI') {
+ if (q2 === null || q2 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // Q2b -> if N/PN -> MOD (terminal, no Q3 needed)
+ if (isNoPPN(q2)) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D5.R6' };
+ }
+
+ // Q2b -> if Y/PY/NI -> Q3b
+ if (inSet(q2, 'Y', 'PY', 'NI')) {
+ if (q3 === null || q3 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+ // Q3b -> if WY/N/PN/NI -> MOD
+ if (inSet(q3, 'WY', 'N', 'PN', 'NI')) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D5.R7' };
+ }
+ // Q3b -> if SY -> SER
+ if (q3 === 'SY') {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D5.R7' };
+ }
+ }
+ }
+
+ // Incomplete - need more answers
+ return { judgement: null, isComplete: false, ruleId: null };
+}
+
+/**
+ * Score Domain 6 (Bias in selection of the reported result)
+ *
+ * Questions: d6_1, d6_2, d6_3, d6_4
+ * Flow from domain-6.md mermaid diagram
+ */
+function scoreDomain6(answers) {
+ const q1 = normalizeAnswer(answers.d6_1?.answer); // 6.1 Result reported according to analysis plan?
+ const q2 = normalizeAnswer(answers.d6_2?.answer); // 6.2 Multiple outcome measurements?
+ const q3 = normalizeAnswer(answers.d6_3?.answer); // 6.3 Multiple analyses of the data?
+ const q4 = normalizeAnswer(answers.d6_4?.answer); // 6.4 Multiple subgroups?
+
+ // Must have Q1 to start
+ if (q1 === null || q1 === undefined) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // Q1 -> if Y/PY -> LOW (terminal, no selection questions needed)
+ if (isYesPY(q1)) {
+ return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D6.R1' };
+ }
+
+ // Q1 -> if N/PN/NI -> SEL (aggregated from 6.2-6.4)
+ if (isNoPPNNI(q1)) {
+ // Check if selection questions are answered
+ const selectionQuestions = [q2, q3, q4];
+ const allSelectionAnswered = selectionQuestions.every(a => a !== null && a !== undefined);
+
+ if (!allSelectionAnswered) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ // Count Y/PY and NI among selection questions
+ const yesCount = selectionQuestions.filter(a => isYesPY(a)).length;
+ const hasNI = selectionQuestions.some(a => a === 'NI');
+ const allNI = selectionQuestions.every(a => a === 'NI');
+ const allNPN = selectionQuestions.every(a => isNoPPN(a));
+
+ // SEL -> if All N/PN -> LOW
+ if (allNPN) {
+ return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D6.R2' };
+ }
+
+ // SEL -> if At least one NI, but none Y/PY -> MOD
+ // (This means: hasNI is true, yesCount is 0, but not all NI)
+ if (yesCount === 0 && hasNI && !allNI) {
+ return { judgement: JUDGEMENTS.MODERATE, isComplete: true, ruleId: 'D6.R3' };
+ }
+
+ // SEL -> if One Y/PY, or all NI -> SER
+ if (yesCount === 1 || (yesCount === 0 && allNI)) {
+ return { judgement: JUDGEMENTS.SERIOUS, isComplete: true, ruleId: 'D6.R4' };
+ }
+
+ // SEL -> if Two or more Y/PY -> CRIT
+ if (yesCount >= 2) {
+ return { judgement: JUDGEMENTS.CRITICAL, isComplete: true, ruleId: 'D6.R5' };
+ }
+ }
+
+ // Incomplete - need more answers
+ return { judgement: null, isComplete: false, ruleId: null };
+}
+
+/**
+ * Main entry point: score a ROBINS-I domain
+ *
+ * @param {string} domainKey - e.g., 'domain1a', 'domain1b', 'domain2', etc.
+ * @param {Object} answers - The domain's answers object { questionKey: { answer, comment } }
+ * @param {Object} options - Additional options
+ * @param {boolean} options.isPerProtocol - Whether this is per-protocol analysis (for domain1)
+ * @returns {Object} { judgement, isComplete, ruleId }
+ */
+export function scoreRobinsDomain(domainKey, answers, _options = {}) {
+ if (!answers) {
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+
+ switch (domainKey) {
+ case 'domain1a':
+ return scoreDomain1A(answers);
+ case 'domain1b':
+ return scoreDomain1B(answers);
+ case 'domain2':
+ return scoreDomain2(answers);
+ case 'domain3':
+ return scoreDomain3(answers);
+ case 'domain4':
+ return scoreDomain4(answers);
+ case 'domain5':
+ return scoreDomain5(answers);
+ case 'domain6':
+ return scoreDomain6(answers);
+ default:
+ return { judgement: null, isComplete: false, ruleId: null };
+ }
+}
+
+/**
+ * Get effective domain judgement (respects manual override)
+ *
+ * @param {Object} domainState - The domain state { answers, judgement, judgementSource, direction }
+ * @param {Object} autoScore - Result from scoreRobinsDomain
+ * @returns {string|null} The effective judgement
+ */
+export function getEffectiveDomainJudgement(domainState, autoScore) {
+ if (domainState?.judgementSource === 'manual' && domainState?.judgement) {
+ return domainState.judgement;
+ }
+ return autoScore?.judgement || null;
+}
+
+/**
+ * Score all active domains and return a summary
+ *
+ * @param {Object} checklistState - Full checklist state
+ * @returns {Object} { domains: { [key]: { auto, effective, source } }, overall }
+ */
+export function scoreAllDomains(checklistState) {
+ if (!checklistState) {
+ return { domains: {}, overall: null };
+ }
+
+ const isPerProtocol = checklistState.sectionC?.isPerProtocol || false;
+ const activeDomainKeys =
+ isPerProtocol ?
+ ['domain1b', 'domain2', 'domain3', 'domain4', 'domain5', 'domain6']
+ : ['domain1a', 'domain2', 'domain3', 'domain4', 'domain5', 'domain6'];
+
+ const domains = {};
+ const effectiveJudgements = [];
+
+ for (const domainKey of activeDomainKeys) {
+ const domainState = checklistState[domainKey];
+ const auto = scoreRobinsDomain(domainKey, domainState?.answers, { isPerProtocol });
+ const effective = getEffectiveDomainJudgement(domainState, auto);
+ const source = domainState?.judgementSource || 'auto';
+
+ domains[domainKey] = {
+ auto,
+ effective,
+ source,
+ isOverridden: source === 'manual' && effective !== auto.judgement,
+ };
+
+ if (effective) {
+ effectiveJudgements.push(effective);
+ }
+ }
+
+ // Calculate overall from effective judgements
+ let overall = null;
+ if (effectiveJudgements.length === activeDomainKeys.length) {
+ if (effectiveJudgements.includes(JUDGEMENTS.CRITICAL)) {
+ overall = JUDGEMENTS.CRITICAL;
+ } else if (effectiveJudgements.includes(JUDGEMENTS.SERIOUS)) {
+ overall = JUDGEMENTS.SERIOUS;
+ } else if (effectiveJudgements.includes(JUDGEMENTS.MODERATE)) {
+ overall = JUDGEMENTS.MODERATE;
+ } else {
+ overall = JUDGEMENTS.LOW;
+ }
+ }
+
+ return { domains, overall, isComplete: effectiveJudgements.length === activeDomainKeys.length };
+}
+
+/**
+ * Map internal overall judgement to the OVERALL_ROB_JUDGEMENTS display strings
+ */
+export function mapOverallJudgementToDisplay(judgement) {
+ switch (judgement) {
+ case JUDGEMENTS.LOW:
+ case JUDGEMENTS.LOW_EXCEPT_CONFOUNDING:
+ return 'Low risk of bias except for concerns about uncontrolled confounding';
+ case JUDGEMENTS.MODERATE:
+ return 'Moderate risk';
+ case JUDGEMENTS.SERIOUS:
+ return 'Serious risk';
+ case JUDGEMENTS.CRITICAL:
+ return 'Critical risk';
+ default:
+ return null;
+ }
+}
+
+export { JUDGEMENTS };
diff --git a/packages/web/src/components/checklist/pdf/pdfScrollHandler.js b/packages/web/src/components/checklist/pdf/pdfScrollHandler.js
index 0a1fa6a1d..7a3dd6275 100644
--- a/packages/web/src/components/checklist/pdf/pdfScrollHandler.js
+++ b/packages/web/src/components/checklist/pdf/pdfScrollHandler.js
@@ -30,6 +30,7 @@ export function createPdfScrollHandler(document) {
let lastMousePosition = null; // { x, y } for button-triggered zoom
let isWheelZooming = false; // Track if we're in an active wheel zoom gesture
let scrollAdjustRafId = null; // RAF ID for scroll adjustment to prevent multiple queued adjustments
+ let zoomAdjustToken = 0; // Token to coalesce rapid zoom adjustments (latest wins)
// Handle scroll to update current page indicator
function handleScroll() {
@@ -119,27 +120,14 @@ export function createPdfScrollHandler(document) {
if (newScale === oldScale) return;
- // For wheel zoom, calculate scroll adjustment immediately based on scale ratio
- // This avoids waiting for DOM updates and prevents jank
- const scaleRatio = newScale / oldScale;
- const pointY = cursorPoint.y;
- const scrollTop = scrollContainerRef.scrollTop;
-
- // Calculate the point's position in content space before zoom
- const pointInContent = scrollTop + pointY;
-
- // After zoom, the same point in content space will be at a different scroll position
- // We want to keep the cursor at the same screen position, so:
- // newScrollTop + pointY = pointInContent * scaleRatio
- // newScrollTop = pointInContent * scaleRatio - pointY
- const newScrollTop = pointInContent * scaleRatio - pointY;
+ // Store zoom origin before changing scale (same as button zoom)
+ zoomOriginPoint = cursorPoint;
+ zoomOriginScale = oldScale;
+ zoomAdjustToken += 1; // Increment token to invalidate any pending adjustments
- // Update scale
+ // Update scale - the createEffect will handle scroll adjustment
setScale(newScale);
- // Immediately adjust scroll to prevent jank
- scrollContainerRef.scrollTop = Math.max(0, newScrollTop);
-
// Clear wheel zoom flag after a short delay (when gesture ends)
clearTimeout(handleWheel.zoomEndTimeout);
handleWheel.zoomEndTimeout = setTimeout(() => {
@@ -285,6 +273,7 @@ export function createPdfScrollHandler(document) {
// Store zoom origin before changing scale
zoomOriginPoint = zoomPoint;
zoomOriginScale = oldScale;
+ zoomAdjustToken += 1; // Increment token to invalidate any pending adjustments
// Calculate new scale
const newScale = Math.min(Math.max(oldScale + scaleDelta, 0.5), 3.0);
@@ -292,15 +281,10 @@ export function createPdfScrollHandler(document) {
}
// Adjust scroll position after scale change to maintain zoom origin
- // Only runs for button-triggered zoom (not wheel zoom, which handles it immediately)
+ // Works for both button-triggered zoom and pinch zoom
createEffect(() => {
const currentScale = scale();
- // Skip if we're in a wheel zoom gesture (handled immediately in handleWheel)
- if (isWheelZooming) {
- return;
- }
-
// Only adjust scroll if we have a stored zoom origin
if (zoomOriginPoint === null || zoomOriginScale === null || !scrollContainerRef) {
return;
@@ -311,89 +295,187 @@ export function createPdfScrollHandler(document) {
return;
}
+ // Capture the current token to check if we're still the latest adjustment
+ const currentToken = zoomAdjustToken;
+ const pointX = zoomOriginPoint.x;
+ const pointY = zoomOriginPoint.y;
+
+ // Find which page contains the zoom point
+ let targetPage = null;
+ let targetPageNum = null;
+ let pointOffsetFromPageTop = 0;
+ let pointOffsetFromPageLeft = 0;
+
+ // Calculate the point's position in the scroll container's coordinate space
+ const pointInContainer = {
+ x: scrollContainerRef.scrollLeft + pointX,
+ y: scrollContainerRef.scrollTop + pointY,
+ };
+
+ // Find the page that contains this point
+ pageRefs.forEach((pageEl, pageNum) => {
+ if (!pageEl || targetPage) return;
+
+ const pageTop = pageEl.offsetTop;
+ const pageBottom = pageTop + pageEl.offsetHeight;
+ const pageLeft = pageEl.offsetLeft;
+ const pageRight = pageLeft + pageEl.offsetWidth;
+
+ // Check if point is within this page's bounds (both vertical and horizontal)
+ if (
+ pointInContainer.y >= pageTop &&
+ pointInContainer.y <= pageBottom &&
+ pointInContainer.x >= pageLeft &&
+ pointInContainer.x <= pageRight
+ ) {
+ targetPage = pageEl;
+ targetPageNum = pageNum;
+ pointOffsetFromPageTop = pointInContainer.y - pageTop;
+ pointOffsetFromPageLeft = pointInContainer.x - pageLeft;
+ }
+ });
+
+ // Prioritize rendering the target page if we found it
+ if (targetPageNum !== null && rendererCallbacks && rendererCallbacks.schedulePageRender) {
+ rendererCallbacks.schedulePageRender(targetPageNum);
+ }
+
// Cancel any pending scroll adjustment
if (scrollAdjustRafId !== null) {
cancelAnimationFrame(scrollAdjustRafId);
scrollAdjustRafId = null;
}
- // Wait for next frame to ensure DOM has updated with new page sizes
- scrollAdjustRafId = requestAnimationFrame(() => {
- scrollAdjustRafId = null;
-
- if (!scrollContainerRef || zoomOriginPoint === null) return;
-
- const pointX = zoomOriginPoint.x;
- const pointY = zoomOriginPoint.y;
-
- // Find which page contains the zoom point
- let targetPage = null;
- let targetPageNum = null;
- let pointOffsetFromPageTop = 0;
-
- // Calculate the point's position in the scroll container's coordinate space
- const pointInContainer = {
- x: pointX,
- y: scrollContainerRef.scrollTop + pointY,
- };
-
- // Find the page that contains this point
- pageRefs.forEach((pageEl, pageNum) => {
- if (!pageEl || targetPage) return;
-
- const pageTop = pageEl.offsetTop;
- const pageBottom = pageTop + pageEl.offsetHeight;
-
- // Check if point is within this page's vertical bounds
- if (pointInContainer.y >= pageTop && pointInContainer.y <= pageBottom) {
- targetPage = pageEl;
- targetPageNum = pageNum;
- pointOffsetFromPageTop = pointInContainer.y - pageTop;
+ if (!targetPage || targetPageNum === null) {
+ // Fallback: use viewport center calculation
+ const oldScrollHeight = scrollContainerRef.scrollHeight;
+ const oldScrollWidth = scrollContainerRef.scrollWidth;
+ const scrollRatio = oldScrollHeight > 0 ? scrollContainerRef.scrollTop / oldScrollHeight : 0;
+ const scrollLeftRatio =
+ oldScrollWidth > 0 ? scrollContainerRef.scrollLeft / oldScrollWidth : 0;
+
+ // Poll for layout update with bounded timeout
+ let pollStartTime = Date.now();
+ const maxPollTime = 400; // Max 400ms to wait for layout
+
+ function pollForLayoutUpdate() {
+ // Check if we're still the latest adjustment
+ if (currentToken !== zoomAdjustToken) {
+ return; // Stale adjustment, abort
}
- });
- if (!targetPage || targetPageNum === null) {
- // Fallback: use viewport center calculation
- const oldScrollHeight = scrollContainerRef.scrollHeight;
- const scrollRatio =
- oldScrollHeight > 0 ? scrollContainerRef.scrollTop / oldScrollHeight : 0;
+ if (!scrollContainerRef) return;
+
+ const newScrollHeight = scrollContainerRef.scrollHeight;
+ const newScrollWidth = scrollContainerRef.scrollWidth;
+ if (newScrollHeight > 0 && newScrollHeight !== oldScrollHeight) {
+ // Layout has updated
+ scrollContainerRef.scrollTop = scrollRatio * newScrollHeight;
+
+ // Adjust horizontal scroll if container has horizontal overflow
+ if (
+ newScrollWidth > 0 &&
+ newScrollWidth !== oldScrollWidth &&
+ scrollContainerRef.scrollWidth > scrollContainerRef.clientWidth
+ ) {
+ scrollContainerRef.scrollLeft = scrollLeftRatio * newScrollWidth;
+ }
- // Wait for pages to re-render, then adjust scroll
- setTimeout(() => {
- if (!scrollContainerRef) return;
- const newScrollHeight = scrollContainerRef.scrollHeight;
+ zoomOriginPoint = null;
+ zoomOriginScale = null;
+ } else if (Date.now() - pollStartTime < maxPollTime) {
+ // Keep polling
+ scrollAdjustRafId = requestAnimationFrame(pollForLayoutUpdate);
+ } else {
+ // Timeout - apply adjustment anyway
if (newScrollHeight > 0) {
scrollContainerRef.scrollTop = scrollRatio * newScrollHeight;
}
+ if (
+ newScrollWidth > 0 &&
+ scrollContainerRef.scrollWidth > scrollContainerRef.clientWidth
+ ) {
+ scrollContainerRef.scrollLeft = scrollLeftRatio * newScrollWidth;
+ }
zoomOriginPoint = null;
zoomOriginScale = null;
- }, 50);
- return;
+ }
+ }
+
+ scrollAdjustRafId = requestAnimationFrame(pollForLayoutUpdate);
+ return;
+ }
+
+ // Calculate the ratio of the point's position within the page (both vertical and horizontal)
+ const oldPageHeight = targetPage.offsetHeight;
+ const oldPageWidth = targetPage.offsetWidth;
+ const pointRatio = oldPageHeight > 0 ? pointOffsetFromPageTop / oldPageHeight : 0;
+ const pointRatioX = oldPageWidth > 0 ? pointOffsetFromPageLeft / oldPageWidth : 0;
+
+ // Poll for page dimensions update with bounded timeout
+ let pollStartTime = Date.now();
+ const maxPollTime = 400; // Max 400ms to wait for layout
+
+ function pollForPageHeightUpdate() {
+ // Check if we're still the latest adjustment
+ if (currentToken !== zoomAdjustToken) {
+ return; // Stale adjustment, abort
}
- // Calculate the ratio of the point's position within the page
- const oldPageHeight = targetPage.offsetHeight;
- const pointRatio = oldPageHeight > 0 ? pointOffsetFromPageTop / oldPageHeight : 0;
+ if (!scrollContainerRef || !targetPage) return;
- // Wait a bit more for pages to re-render with new scale
- // Use a small timeout to ensure canvas rendering has updated page heights
- setTimeout(() => {
- if (!scrollContainerRef || !targetPage) return;
+ const newPageHeight = targetPage.offsetHeight;
+ const newPageWidth = targetPage.offsetWidth;
- const newPageHeight = targetPage.offsetHeight;
+ if (newPageHeight !== oldPageHeight && newPageHeight > 0) {
+ // Page dimensions have updated - apply scroll adjustment
const newPointOffsetFromPageTop = pointRatio * newPageHeight;
const newPageTop = targetPage.offsetTop;
- const newPointInContainer = newPageTop + newPointOffsetFromPageTop;
+ const newPointInContainerY = newPageTop + newPointOffsetFromPageTop;
- // Adjust scroll to keep the point at the same screen position
- const newScrollTop = newPointInContainer - pointY;
+ // Adjust vertical scroll to keep the point at the same screen position
+ const newScrollTop = newPointInContainerY - pointY;
scrollContainerRef.scrollTop = Math.max(0, newScrollTop);
+ // Adjust horizontal scroll if container has horizontal overflow
+ if (scrollContainerRef.scrollWidth > scrollContainerRef.clientWidth) {
+ const newPointOffsetFromPageLeft = pointRatioX * newPageWidth;
+ const newPageLeft = targetPage.offsetLeft;
+ const newPointInContainerX = newPageLeft + newPointOffsetFromPageLeft;
+ const newScrollLeft = newPointInContainerX - pointX;
+ scrollContainerRef.scrollLeft = Math.max(0, newScrollLeft);
+ }
+
// Clear zoom origin tracking
zoomOriginPoint = null;
zoomOriginScale = null;
- }, 50);
- });
+ } else if (Date.now() - pollStartTime < maxPollTime) {
+ // Keep polling
+ scrollAdjustRafId = requestAnimationFrame(pollForPageHeightUpdate);
+ } else {
+ // Timeout - apply adjustment with current dimensions anyway
+ if (newPageHeight > 0) {
+ const newPointOffsetFromPageTop = pointRatio * newPageHeight;
+ const newPageTop = targetPage.offsetTop;
+ const newPointInContainerY = newPageTop + newPointOffsetFromPageTop;
+ const newScrollTop = newPointInContainerY - pointY;
+ scrollContainerRef.scrollTop = Math.max(0, newScrollTop);
+
+ // Adjust horizontal scroll if container has horizontal overflow
+ if (scrollContainerRef.scrollWidth > scrollContainerRef.clientWidth && newPageWidth > 0) {
+ const newPointOffsetFromPageLeft = pointRatioX * newPageWidth;
+ const newPageLeft = targetPage.offsetLeft;
+ const newPointInContainerX = newPageLeft + newPointOffsetFromPageLeft;
+ const newScrollLeft = newPointInContainerX - pointX;
+ scrollContainerRef.scrollLeft = Math.max(0, newScrollLeft);
+ }
+ }
+ zoomOriginPoint = null;
+ zoomOriginScale = null;
+ }
+ }
+
+ scrollAdjustRafId = requestAnimationFrame(pollForPageHeightUpdate);
});
function cleanup() {
diff --git a/packages/web/src/components/mock/MockIndex.jsx b/packages/web/src/components/mock/MockIndex.jsx
new file mode 100644
index 000000000..c2764da2e
--- /dev/null
+++ b/packages/web/src/components/mock/MockIndex.jsx
@@ -0,0 +1,38 @@
+import { A } from '@solidjs/router';
+
+/**
+ * Mock Index - Landing page for visual-only mockups
+ * These are temporary wireframes with no data/logic
+ */
+export default function MockIndex() {
+ return (
+
+
+
Mock Pages
+
+ These are visual-only wireframes with no data loading, Yjs logic, or backend integration.
+ They are temporary and will not reach production.
+
+
+
+
+
+ );
+}
diff --git a/packages/web/src/components/mock/RobinsReconcileSectionBQuestionMock.jsx b/packages/web/src/components/mock/RobinsReconcileSectionBQuestionMock.jsx
new file mode 100644
index 000000000..048bc45b1
--- /dev/null
+++ b/packages/web/src/components/mock/RobinsReconcileSectionBQuestionMock.jsx
@@ -0,0 +1,214 @@
+import { createSignal, For } from 'solid-js';
+import { A } from '@solidjs/router';
+import {
+ SECTION_B,
+ RESPONSE_LABELS,
+} from '@/components/checklist/ROBINSIChecklist/checklist-map.js';
+import { AiOutlineArrowLeft, AiOutlineArrowRight } from 'solid-icons/ai';
+
+/**
+ * Mock component for ROBINS-I Section B question reconciliation
+ * Visual-only wireframe with no data/logic - comments always visible
+ */
+export default function RobinsReconcileSectionBQuestionMock() {
+ // Mock data - hardcoded for visual purposes
+ const questionKey = 'b1';
+ const question = SECTION_B[questionKey];
+ const responseOptions = ['Y', 'PY', 'PN', 'N'];
+
+ // Mock reviewer answers
+ const reviewer1Answer = 'Y';
+ const reviewer2Answer = 'PY';
+ const reviewer1Comment =
+ 'Authors used propensity score matching to control for age, sex, and baseline severity.';
+ const reviewer2Comment =
+ 'Propensity score matching was used, but some important confounders may have been omitted (e.g., socioeconomic status).';
+
+ // Local state for final answer (visual only)
+ const [finalAnswer, setFinalAnswer] = createSignal(reviewer1Answer);
+ const [finalComment, setFinalComment] = createSignal('');
+
+ const isAgreement = reviewer1Answer === reviewer2Answer;
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
ROBINS-I Reconcile (Mock)
+
Visual wireframe only - no data/logic
+
+
+
+ {/* Question Card */}
+
+ {/* Question Header */}
+
+
+ {questionKey.toUpperCase()}. {question.text}
+
+
+
+ {isAgreement ? 'Reviewers Agree' : 'Requires Reconciliation'}
+
+
+
+
+ {/* Three Column Layout */}
+
+ {/* Reviewer 1 Panel */}
+
+
+
Reviewer 1
+
+
+ {/* Response Options */}
+
+
+ {option => (
+
+ {option}
+ ({RESPONSE_LABELS[option]})
+
+ )}
+
+
+
+ {/* Comment - Always Visible */}
+
+
+
+ {/* Reviewer 2 Panel */}
+
+
+
Reviewer 2
+
+
+ {/* Response Options */}
+
+
+ {option => (
+
+ {option}
+ ({RESPONSE_LABELS[option]})
+
+ )}
+
+
+
+ {/* Comment - Always Visible */}
+
+
+
+ {/* Final Panel */}
+
+
+
Final Answer
+
+
+ {/* Response Options - Interactive */}
+
+
+ {option => (
+
+ setFinalAnswer(option)}
+ class='hidden'
+ />
+ {option}
+ ({RESPONSE_LABELS[option]})
+
+ )}
+
+
+
+ {/* Comment - Always Visible, Editable */}
+
+ Final Comment
+
+
+
+
+
+ {/* Navigation Footer */}
+
+
+
+ Previous
+
+
+
Question 1 of 3
+
+
+ Next
+
+
+
+
+ {/* Action Buttons */}
+
+
+ Cancel
+
+
+ Finalize Reconciliation
+
+
+
+
+ );
+}
diff --git a/packages/web/src/primitives/__tests__/useProject.test.js b/packages/web/src/primitives/__tests__/useProject.test.js
index ad85adcde..e3f7be8a6 100644
--- a/packages/web/src/primitives/__tests__/useProject.test.js
+++ b/packages/web/src/primitives/__tests__/useProject.test.js
@@ -444,6 +444,37 @@ describe('useProject - Checklist Operations', () => {
});
});
+ it('should persist ROBINS-I manual judgementSource when updating overall section', async () => {
+ await new Promise(resolveTest => {
+ createRoot(async dispose => {
+ cleanup = dispose;
+ const project = useProject('local-test');
+
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ const studyId = project.createStudy('Test Study');
+ const checklistId = project.createChecklist(studyId, 'ROBINS_I');
+
+ project.updateChecklistAnswer(studyId, checklistId, 'overall', {
+ judgement: 'Moderate risk',
+ judgementSource: 'manual',
+ direction: null,
+ });
+
+ // Wait for Y.js update event to trigger sync
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ const checklistData = project.getChecklistData(studyId, checklistId);
+ expect(checklistData).toBeDefined();
+ expect(checklistData.type).toBe('ROBINS_I');
+ expect(checklistData.answers?.overall?.judgementSource).toBe('manual');
+ expect(checklistData.answers?.overall?.judgement).toBe('Moderate risk');
+
+ resolveTest();
+ });
+ });
+ });
+
it('should get checklist data with answers', async () => {
createRoot(async dispose => {
cleanup = dispose;
diff --git a/packages/web/src/primitives/useProject/checklists.js b/packages/web/src/primitives/useProject/checklists.js
deleted file mode 100644
index c4b3d1adb..000000000
--- a/packages/web/src/primitives/useProject/checklists.js
+++ /dev/null
@@ -1,538 +0,0 @@
-/**
- * Checklist operations for useProject
- */
-
-import * as Y from 'yjs';
-import { createChecklistOfType, CHECKLIST_TYPES } from '@/checklist-registry';
-import { CHECKLIST_STATUS } from '@/constants/checklist-status.js';
-
-/**
- * Creates checklist operations
- * @param {string} projectId - The project ID
- * @param {Function} getYDoc - Function that returns the Y.Doc
- * @param {Function} isSynced - Function that returns sync status
- * @returns {Object} Checklist operations
- */
-export function createChecklistOperations(projectId, getYDoc, _isSynced) {
- /**
- * Create a checklist in a study
- * @param {string} studyId - The study ID
- * @param {string} type - Checklist type (default: 'AMSTAR2')
- * @param {string|null} assignedTo - User ID to assign to
- * @returns {string|null} The checklist ID or null if failed
- */
- function createChecklist(studyId, type = 'AMSTAR2', assignedTo = null) {
- const ydoc = getYDoc();
- if (!ydoc) return null;
-
- const studiesMap = ydoc.getMap('reviews');
- const studyYMap = studiesMap.get(studyId);
-
- if (!studyYMap) return null;
-
- let checklistsMap = studyYMap.get('checklists');
- if (!checklistsMap) {
- checklistsMap = new Y.Map();
- studyYMap.set('checklists', checklistsMap);
- }
-
- const checklistId = crypto.randomUUID();
- const now = Date.now();
-
- // Get the default answers structure for this checklist type using the registry
- let answersData = {};
- const checklistTemplate = createChecklistOfType(type, {
- id: checklistId,
- name: `${type} Checklist`,
- createdAt: now,
- });
-
- // Extract answers based on checklist type
- if (type === CHECKLIST_TYPES.AMSTAR2) {
- // AMSTAR2: Extract question answers (q1, q2, etc.)
- Object.entries(checklistTemplate).forEach(([key, value]) => {
- if (/^q\d+[a-z]*$/i.test(key)) {
- answersData[key] = value;
- }
- });
- } else if (type === CHECKLIST_TYPES.ROBINS_I) {
- // ROBINS-I: Extract all domain and section data
- const robinsKeys = [
- 'planning',
- 'sectionA',
- 'sectionB',
- 'sectionC',
- 'sectionD',
- 'confoundingEvaluation',
- 'domain1a',
- 'domain1b',
- 'domain2',
- 'domain3',
- 'domain4',
- 'domain5',
- 'domain6',
- 'overall',
- ];
- robinsKeys.forEach(key => {
- if (checklistTemplate[key] !== undefined) {
- answersData[key] = checklistTemplate[key];
- }
- });
- }
-
- const checklistYMap = new Y.Map();
- checklistYMap.set('type', type);
- checklistYMap.set('title', `${type} Checklist`);
- checklistYMap.set('assignedTo', assignedTo);
- checklistYMap.set('status', CHECKLIST_STATUS.PENDING);
- checklistYMap.set('createdAt', now);
- checklistYMap.set('updatedAt', now);
-
- // Store answers as a Y.Map
- const answersYMap = new Y.Map();
- // Add answersYMap to checklistYMap early so it's part of the document structure
- // This prevents Yjs errors when accessing the map
- checklistYMap.set('answers', answersYMap);
-
- if (type === CHECKLIST_TYPES.AMSTAR2) {
- // AMSTAR2: Store each question as a nested Y.Map with answers, critical, and note
- // Note: q9 and q11 are multi-part questions (q9a/q9b, q11a/q11b) but get one note each
- // at the parent level (q9, q11)
- const multiPartParents = ['q9', 'q11'];
- const subQuestionPattern = /^(q9|q11)[a-z]$/;
-
- // Track which keys we've added to avoid checking with .has() before map is in document
- const addedKeys = new Set();
-
- Object.entries(answersData).forEach(([questionKey, questionData]) => {
- const questionYMap = new Y.Map();
- questionYMap.set('answers', questionData.answers);
- questionYMap.set('critical', questionData.critical);
-
- // Add note for non-sub-questions
- // Sub-questions (q9a, q9b, q11a, q11b) don't get notes - the parent does
- if (!subQuestionPattern.test(questionKey)) {
- questionYMap.set('note', new Y.Text());
- }
-
- answersYMap.set(questionKey, questionYMap);
- addedKeys.add(questionKey);
- });
-
- // Add note entries for multi-part parent questions (q9, q11)
- // These don't have answer data but need a note
- multiPartParents.forEach(parentKey => {
- if (!addedKeys.has(parentKey)) {
- const parentYMap = new Y.Map();
- parentYMap.set('note', new Y.Text());
- answersYMap.set(parentKey, parentYMap);
- }
- });
- } else if (type === CHECKLIST_TYPES.ROBINS_I) {
- // ROBINS-I: Store each section/domain as nested Y.Maps to support concurrent edits
- Object.entries(answersData).forEach(([key, value]) => {
- const sectionYMap = new Y.Map();
-
- // Domain keys have nested 'answers' object with individual questions
- if (key.startsWith('domain') || key === 'overall') {
- // Store judgement and direction at section level
- 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', qValue.comment ?? '');
- answersNestedYMap.set(qKey, questionYMap);
- });
- sectionYMap.set('answers', answersNestedYMap);
- }
- } else if (key === 'sectionB') {
- // Section B has individual questions (b1, b2, b3) and stopAssessment
- Object.entries(value).forEach(([subKey, subValue]) => {
- if (typeof subValue === 'object' && subValue !== null) {
- const questionYMap = new Y.Map();
- questionYMap.set('answer', subValue.answer ?? null);
- questionYMap.set('comment', subValue.comment ?? '');
- sectionYMap.set(subKey, questionYMap);
- } else {
- sectionYMap.set(subKey, subValue);
- }
- });
- } else if (key === 'confoundingEvaluation') {
- // Confounding evaluation has arrays - store as JSON for now
- sectionYMap.set('predefined', value.predefined ?? []);
- sectionYMap.set('additional', value.additional ?? []);
- } else if (key === 'sectionD') {
- // Section D has sources object and otherSpecify
- sectionYMap.set('sources', value.sources ?? {});
- sectionYMap.set('otherSpecify', value.otherSpecify ?? '');
- } else {
- // Other sections (planning, sectionA, sectionC): store each field
- Object.entries(value).forEach(([fieldKey, fieldValue]) => {
- sectionYMap.set(fieldKey, fieldValue);
- });
- }
-
- answersYMap.set(key, sectionYMap);
- });
- } else {
- // Other types: Store data directly (will be serialized as JSON)
- Object.entries(answersData).forEach(([key, value]) => {
- answersYMap.set(key, value);
- });
- }
-
- checklistsMap.set(checklistId, checklistYMap);
-
- // Update study's updatedAt
- studyYMap.set('updatedAt', now);
-
- return checklistId;
- }
-
- /**
- * Update a checklist
- * @param {string} studyId - The study ID
- * @param {string} checklistId - The checklist ID
- * @param {Object} updates - Fields to update
- */
- function updateChecklist(studyId, checklistId, updates) {
- const ydoc = getYDoc();
- if (!ydoc) return;
-
- const studiesMap = ydoc.getMap('reviews');
- const studyYMap = studiesMap.get(studyId);
- if (!studyYMap) return;
-
- const checklistsMap = studyYMap.get('checklists');
- if (!checklistsMap) return;
-
- const checklistYMap = checklistsMap.get(checklistId);
- if (!checklistYMap) return;
-
- if (updates.title !== undefined) checklistYMap.set('title', updates.title);
- if (updates.assignedTo !== undefined) checklistYMap.set('assignedTo', updates.assignedTo);
- if (updates.status !== undefined) checklistYMap.set('status', updates.status);
- checklistYMap.set('updatedAt', Date.now());
- }
-
- /**
- * Delete a checklist
- * @param {string} studyId - The study ID
- * @param {string} checklistId - The checklist ID
- */
- function deleteChecklist(studyId, checklistId) {
- const ydoc = getYDoc();
- if (!ydoc) return;
-
- const studiesMap = ydoc.getMap('reviews');
- const studyYMap = studiesMap.get(studyId);
- if (!studyYMap) return;
-
- const checklistsMap = studyYMap.get('checklists');
- if (!checklistsMap) return;
-
- checklistsMap.delete(checklistId);
- studyYMap.set('updatedAt', Date.now());
- }
-
- /**
- * Get a specific checklist's Y.Map for answer updates
- * @param {string} studyId - The study ID
- * @param {string} checklistId - The checklist ID
- * @returns {Y.Map|null} The answers Y.Map or null
- */
- function getChecklistAnswersMap(studyId, checklistId) {
- 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;
-
- return checklistYMap.get('answers');
- }
-
- /**
- * Get full checklist data including answers in plain object format
- * @param {string} studyId - The study ID
- * @param {string} checklistId - The checklist ID
- * @returns {Object|null} The checklist data or null
- */
- function getChecklistData(studyId, checklistId) {
- 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 data = checklistYMap.toJSON ? checklistYMap.toJSON() : {};
- const checklistType = checklistYMap.get('type');
-
- // Convert answers Y.Map to plain object with question keys at top level
- const answers = {};
- const answersMap = checklistYMap.get('answers');
- if (answersMap && typeof answersMap.entries === 'function') {
- for (const [key, sectionYMap] of answersMap.entries()) {
- // ROBINS-I: Reconstruct nested structure from Y.Maps
- if (checklistType === 'ROBINS_I' && sectionYMap instanceof Y.Map) {
- if (key.startsWith('domain')) {
- const sectionData = {
- judgement: sectionYMap.get('judgement') ?? null,
- answers: {}, // Always initialize answers for domains
- };
- 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) {
- sectionData.answers[qKey] = {
- answer: questionYMap.get('answer') ?? null,
- comment: questionYMap.get('comment') ?? '',
- };
- } 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 === 'sectionB') {
- const sectionData = {};
- for (const [subKey, subValue] of sectionYMap.entries()) {
- if (subValue instanceof Y.Map) {
- sectionData[subKey] = {
- answer: subValue.get('answer') ?? null,
- comment: subValue.get('comment') ?? '',
- };
- } else {
- sectionData[subKey] = subValue;
- }
- }
- answers[key] = sectionData;
- } else {
- // Other sections: convert Y.Map to plain object
- answers[key] = sectionYMap.toJSON ? sectionYMap.toJSON() : sectionYMap;
- }
- } else {
- // AMSTAR2 and other types
- const sectionData = sectionYMap.toJSON ? sectionYMap.toJSON() : sectionYMap;
- answers[key] = sectionData;
- }
- }
- }
-
- return {
- ...data,
- answers,
- };
- }
-
- /**
- * Update a single answer/section in a checklist
- * @param {string} studyId - The study ID
- * @param {string} checklistId - The checklist ID
- * @param {string} key - The answer key (e.g., 'q1' for AMSTAR2, 'domain1a' for ROBINS-I)
- * @param {Object} data - The answer data (structure depends on checklist type)
- */
- function updateChecklistAnswer(studyId, checklistId, key, data) {
- const ydoc = getYDoc();
- if (!ydoc) return;
-
- const studiesMap = ydoc.getMap('reviews');
- const studyYMap = studiesMap.get(studyId);
- if (!studyYMap) return;
-
- const checklistsMap = studyYMap.get('checklists');
- if (!checklistsMap) return;
-
- const checklistYMap = checklistsMap.get(checklistId);
- if (!checklistYMap) return;
-
- let answersMap = checklistYMap.get('answers');
- if (!answersMap) {
- answersMap = new Y.Map();
- checklistYMap.set('answers', answersMap);
- }
-
- const checklistType = checklistYMap.get('type');
-
- // AMSTAR2: Store as nested Y.Map with answers and critical (preserving existing note)
- if (checklistType === 'AMSTAR2' && data.answers !== undefined) {
- let questionYMap = answersMap.get(key);
- if (!questionYMap || !(questionYMap instanceof Y.Map)) {
- questionYMap = new Y.Map();
- answersMap.set(key, questionYMap);
- }
- questionYMap.set('answers', data.answers);
- questionYMap.set('critical', data.critical);
- // Note: Y.Text note is preserved - we don't overwrite it here
- // If no note exists yet, create one (for existing checklists without notes)
- if (!questionYMap.get('note')) {
- questionYMap.set('note', new Y.Text());
- }
- }
- // ROBINS-I: Update nested Y.Maps granularly for concurrent edit support
- else if (checklistType === 'ROBINS_I') {
- 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);
- }
-
- // Domain keys have nested 'answers' object with individual questions
- 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) questionYMap.set('comment', qValue.comment);
- });
- }
- } else if (key === 'sectionB') {
- // Section B: update individual questions or stopAssessment
- Object.entries(data).forEach(([subKey, subValue]) => {
- if (typeof subValue === 'object' && subValue !== null) {
- let questionYMap = sectionYMap.get(subKey);
- if (!questionYMap || !(questionYMap instanceof Y.Map)) {
- questionYMap = new Y.Map();
- sectionYMap.set(subKey, questionYMap);
- }
- if (subValue.answer !== undefined) questionYMap.set('answer', subValue.answer);
- if (subValue.comment !== undefined) questionYMap.set('comment', subValue.comment);
- } else {
- sectionYMap.set(subKey, subValue);
- }
- });
- } else if (key === 'confoundingEvaluation') {
- // Confounding evaluation: update arrays
- if (data.predefined !== undefined) sectionYMap.set('predefined', data.predefined);
- if (data.additional !== undefined) sectionYMap.set('additional', data.additional);
- } else if (key === 'sectionD') {
- // Section D: update sources or otherSpecify
- if (data.sources !== undefined) sectionYMap.set('sources', data.sources);
- if (data.otherSpecify !== undefined) sectionYMap.set('otherSpecify', data.otherSpecify);
- } else {
- // Other sections: update individual fields
- Object.entries(data).forEach(([fieldKey, fieldValue]) => {
- sectionYMap.set(fieldKey, fieldValue);
- });
- }
- }
- // Other types: Store data directly
- else {
- answersMap.set(key, data);
- }
-
- // Auto-transition status from 'pending' to 'in-progress' on first edit
- const currentStatus = checklistYMap.get('status');
- if (currentStatus === CHECKLIST_STATUS.PENDING) {
- checklistYMap.set('status', CHECKLIST_STATUS.IN_PROGRESS);
- }
-
- checklistYMap.set('updatedAt', Date.now());
- }
-
- /**
- * Get a Y.Text reference for a question's note (for direct binding)
- * @param {string} studyId - The study ID
- * @param {string} checklistId - The checklist ID
- * @param {string} questionKey - The question key (e.g., 'q1', 'q9' for multi-part)
- * @returns {Y.Text|null} The Y.Text reference or null
- */
- function getQuestionNote(studyId, checklistId, questionKey) {
- 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 answersMap = checklistYMap.get('answers');
- if (!answersMap) return null;
-
- const questionYMap = answersMap.get(questionKey);
- if (!questionYMap || !(questionYMap instanceof Y.Map)) return null;
-
- const note = questionYMap.get('note');
- // Return existing note, or create one if missing (for existing checklists)
- if (note instanceof Y.Text) {
- return note;
- }
-
- // Create note if it doesn't exist (backward compatibility)
- const newNote = new Y.Text();
- questionYMap.set('note', newNote);
- return newNote;
- }
-
- return {
- createChecklist,
- updateChecklist,
- deleteChecklist,
- getChecklistAnswersMap,
- getChecklistData,
- updateChecklistAnswer,
- getQuestionNote,
- };
-}
diff --git a/packages/web/src/primitives/useProject/checklists/common.js b/packages/web/src/primitives/useProject/checklists/common.js
new file mode 100644
index 000000000..cceb18a67
--- /dev/null
+++ b/packages/web/src/primitives/useProject/checklists/common.js
@@ -0,0 +1,112 @@
+/**
+ * Common checklist operations shared across all checklist types
+ */
+
+import * as Y from 'yjs';
+
+/**
+ * Create shared checklist operations that work with any checklist type
+ * @param {Function} getYDoc - Function that returns the Y.Doc
+ * @returns {Object} Common operations
+ */
+export function createCommonOperations(getYDoc) {
+ /**
+ * Update a checklist's metadata
+ * @param {string} studyId - The study ID
+ * @param {string} checklistId - The checklist ID
+ * @param {Object} updates - Fields to update (title, assignedTo, status)
+ */
+ function updateChecklist(studyId, checklistId, updates) {
+ const ydoc = getYDoc();
+ if (!ydoc) return;
+
+ const studiesMap = ydoc.getMap('reviews');
+ const studyYMap = studiesMap.get(studyId);
+ if (!studyYMap) return;
+
+ const checklistsMap = studyYMap.get('checklists');
+ if (!checklistsMap) return;
+
+ const checklistYMap = checklistsMap.get(checklistId);
+ if (!checklistYMap) return;
+
+ if (updates.title !== undefined) checklistYMap.set('title', updates.title);
+ if (updates.assignedTo !== undefined) checklistYMap.set('assignedTo', updates.assignedTo);
+ if (updates.status !== undefined) checklistYMap.set('status', updates.status);
+ checklistYMap.set('updatedAt', Date.now());
+ }
+
+ /**
+ * Delete a checklist
+ * @param {string} studyId - The study ID
+ * @param {string} checklistId - The checklist ID
+ */
+ function deleteChecklist(studyId, checklistId) {
+ const ydoc = getYDoc();
+ if (!ydoc) return;
+
+ const studiesMap = ydoc.getMap('reviews');
+ const studyYMap = studiesMap.get(studyId);
+ if (!studyYMap) return;
+
+ const checklistsMap = studyYMap.get('checklists');
+ if (!checklistsMap) return;
+
+ checklistsMap.delete(checklistId);
+ studyYMap.set('updatedAt', Date.now());
+ }
+
+ /**
+ * Get a specific checklist's Y.Map for answer updates
+ * @param {string} studyId - The study ID
+ * @param {string} checklistId - The checklist ID
+ * @returns {Y.Map|null} The answers Y.Map or null
+ */
+ function getChecklistAnswersMap(studyId, checklistId) {
+ 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;
+
+ return checklistYMap.get('answers');
+ }
+
+ /**
+ * Get a checklist's Y.Map and type
+ * @param {string} studyId - The study ID
+ * @param {string} checklistId - The checklist ID
+ * @returns {{checklistYMap: Y.Map, checklistType: string}|null} The checklist Y.Map and type or null
+ */
+ function getChecklistYMap(studyId, checklistId) {
+ 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');
+ return { checklistYMap, checklistType };
+ }
+
+ return {
+ updateChecklist,
+ deleteChecklist,
+ getChecklistAnswersMap,
+ getChecklistYMap,
+ };
+}
diff --git a/packages/web/src/primitives/useProject/checklists/handlers/amstar2.js b/packages/web/src/primitives/useProject/checklists/handlers/amstar2.js
new file mode 100644
index 000000000..e781b075b
--- /dev/null
+++ b/packages/web/src/primitives/useProject/checklists/handlers/amstar2.js
@@ -0,0 +1,146 @@
+/**
+ * AMSTAR2 checklist type handler
+ */
+
+import * as Y from 'yjs';
+import { ChecklistHandler } from './base.js';
+
+export class AMSTAR2Handler extends ChecklistHandler {
+ /**
+ * Extract answer structure from AMSTAR2 checklist template
+ * @param {Object} template - The checklist template from createChecklistOfType
+ * @returns {Object} Extracted answers data structure
+ */
+ extractAnswersFromTemplate(template) {
+ const answersData = {};
+ // AMSTAR2: Extract question answers (q1, q2, etc.)
+ Object.entries(template).forEach(([key, value]) => {
+ if (/^q\d+[a-z]*$/i.test(key)) {
+ answersData[key] = value;
+ }
+ });
+ return answersData;
+ }
+
+ /**
+ * Create Y.Map structure for AMSTAR2 answers
+ * @param {Object} answersData - The extracted answers data
+ * @returns {Y.Map} The answers Y.Map
+ */
+ createAnswersYMap(answersData) {
+ const answersYMap = new Y.Map();
+
+ // AMSTAR2: Store each question as a nested Y.Map with answers, critical, and note
+ // Note: q9 and q11 are multi-part questions (q9a/q9b, q11a/q11b) but get one note each
+ // at the parent level (q9, q11)
+ const multiPartParents = ['q9', 'q11'];
+ const subQuestionPattern = /^(q9|q11)[a-z]$/;
+
+ // Track which keys we've added to avoid checking with .has() before map is in document
+ const addedKeys = new Set();
+
+ Object.entries(answersData).forEach(([questionKey, questionData]) => {
+ const questionYMap = new Y.Map();
+ questionYMap.set('answers', questionData.answers);
+ questionYMap.set('critical', questionData.critical);
+
+ // Add note for non-sub-questions
+ // Sub-questions (q9a, q9b, q11a, q11b) don't get notes - the parent does
+ if (!subQuestionPattern.test(questionKey)) {
+ questionYMap.set('note', new Y.Text());
+ }
+
+ answersYMap.set(questionKey, questionYMap);
+ addedKeys.add(questionKey);
+ });
+
+ // Add note entries for multi-part parent questions (q9, q11)
+ // These don't have answer data but need a note
+ multiPartParents.forEach(parentKey => {
+ if (!addedKeys.has(parentKey)) {
+ const parentYMap = new Y.Map();
+ parentYMap.set('note', new Y.Text());
+ answersYMap.set(parentKey, parentYMap);
+ }
+ });
+
+ return answersYMap;
+ }
+
+ /**
+ * Serialize AMSTAR2 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()) {
+ // AMSTAR2: Simple toJSON conversion
+ const sectionData = sectionYMap.toJSON ? sectionYMap.toJSON() : sectionYMap;
+ answers[key] = sectionData;
+ }
+ return answers;
+ }
+
+ /**
+ * Update a single answer in AMSTAR2 checklist
+ * @param {Y.Map} answersMap - The answers Y.Map
+ * @param {string} key - The question key (e.g., 'q1')
+ * @param {Object} data - The answer data { answers, critical }
+ */
+ updateAnswer(answersMap, key, data) {
+ if (data.answers !== undefined) {
+ let questionYMap = answersMap.get(key);
+ if (!questionYMap || !(questionYMap instanceof Y.Map)) {
+ questionYMap = new Y.Map();
+ answersMap.set(key, questionYMap);
+ }
+ questionYMap.set('answers', data.answers);
+ questionYMap.set('critical', data.critical);
+ // Note: Y.Text note is preserved - we don't overwrite it here
+ // If no note exists yet, create one (for existing checklists without notes)
+ if (!questionYMap.get('note')) {
+ questionYMap.set('note', new Y.Text());
+ }
+ }
+ }
+
+ /**
+ * Get type-specific text getter function for AMSTAR2
+ * @param {Function} getYDoc - Function that returns the Y.Doc
+ * @returns {Function} getQuestionNote function
+ */
+ getTextGetter(getYDoc) {
+ return (studyId, checklistId, questionKey) => {
+ 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 answersMap = checklistYMap.get('answers');
+ if (!answersMap) return null;
+
+ const questionYMap = answersMap.get(questionKey);
+ if (!questionYMap || !(questionYMap instanceof Y.Map)) return null;
+
+ const note = questionYMap.get('note');
+ // Return existing note, or create one if missing (for existing checklists)
+ if (note instanceof Y.Text) {
+ return note;
+ }
+
+ // Create note if it doesn't exist (backward compatibility)
+ const newNote = new Y.Text();
+ questionYMap.set('note', newNote);
+ return newNote;
+ };
+ }
+}
diff --git a/packages/web/src/primitives/useProject/checklists/handlers/base.js b/packages/web/src/primitives/useProject/checklists/handlers/base.js
new file mode 100644
index 000000000..87154566a
--- /dev/null
+++ b/packages/web/src/primitives/useProject/checklists/handlers/base.js
@@ -0,0 +1,70 @@
+/**
+ * Base handler interface for checklist type-specific operations
+ *
+ * All checklist type handlers must implement these methods.
+ */
+
+import * as Y from 'yjs';
+
+/**
+ * Base handler class that defines the interface for checklist type handlers
+ */
+export class ChecklistHandler {
+ /**
+ * Extract answer structure from checklist template
+ * @param {Object} template - The checklist template from createChecklistOfType
+ * @returns {Object} Extracted answers data structure
+ */
+ extractAnswersFromTemplate(template) {
+ throw new Error('extractAnswersFromTemplate must be implemented by subclass');
+ }
+
+ /**
+ * Create Y.Map structure for answers from extracted data
+ * @param {Object} answersData - The extracted answers data
+ * @returns {Y.Map} The answers Y.Map
+ */
+ createAnswersYMap(answersData) {
+ throw new Error('createAnswersYMap must be implemented by subclass');
+ }
+
+ /**
+ * Serialize answers Y.Map to plain object
+ * @param {Y.Map} answersMap - The answers Y.Map
+ * @returns {Object} Plain object with answers
+ */
+ serializeAnswers(answersMap) {
+ throw new Error('serializeAnswers must be implemented by subclass');
+ }
+
+ /**
+ * Update a single answer/section in the answers Y.Map
+ * @param {Y.Map} answersMap - The answers Y.Map
+ * @param {string} key - The answer key (e.g., 'q1' for AMSTAR2, 'domain1a' for ROBINS-I)
+ * @param {Object} data - The answer data
+ */
+ updateAnswer(answersMap, key, data) {
+ throw new Error('updateAnswer must be implemented by subclass');
+ }
+
+ /**
+ * Get type-specific text getter function
+ * @param {Function} getYDoc - Function that returns the Y.Doc
+ * @returns {Function|null} Text getter function or null if not applicable
+ */
+ getTextGetter(getYDoc) {
+ return null; // Optional - defaults to null
+ }
+}
+
+/**
+ * Helper to convert Y.Text to string safely
+ * @param {*} value - Value that might be Y.Text
+ * @returns {string} String representation
+ */
+export function yTextToString(value) {
+ if (value instanceof Y.Text) {
+ return value.toString();
+ }
+ return value ?? '';
+}
diff --git a/packages/web/src/primitives/useProject/checklists/handlers/robins-i.js b/packages/web/src/primitives/useProject/checklists/handlers/robins-i.js
new file mode 100644
index 000000000..83387fa96
--- /dev/null
+++ b/packages/web/src/primitives/useProject/checklists/handlers/robins-i.js
@@ -0,0 +1,378 @@
+/**
+ * ROBINS-I checklist type handler
+ */
+
+import * as Y from 'yjs';
+import { ChecklistHandler, yTextToString } from './base.js';
+
+export class ROBINSIHandler extends ChecklistHandler {
+ /**
+ * Extract answer structure from ROBINS-I checklist template
+ * @param {Object} template - The checklist template from createChecklistOfType
+ * @returns {Object} Extracted answers data structure
+ */
+ extractAnswersFromTemplate(template) {
+ const answersData = {};
+ // ROBINS-I: Extract all domain and section data
+ const robinsKeys = [
+ 'planning',
+ 'sectionA',
+ 'sectionB',
+ 'sectionC',
+ 'sectionD',
+ 'confoundingEvaluation',
+ 'domain1a',
+ 'domain1b',
+ 'domain2',
+ 'domain3',
+ 'domain4',
+ 'domain5',
+ 'domain6',
+ 'overall',
+ ];
+ robinsKeys.forEach(key => {
+ if (template[key] !== undefined) {
+ answersData[key] = template[key];
+ }
+ });
+ return answersData;
+ }
+
+ /**
+ * Create Y.Map structure for ROBINS-I answers
+ * @param {Object} answersData - The extracted answers data
+ * @returns {Y.Map} The answers Y.Map
+ */
+ createAnswersYMap(answersData) {
+ const answersYMap = new Y.Map();
+
+ // ROBINS-I: Store each section/domain as nested Y.Maps to support concurrent edits
+ Object.entries(answersData).forEach(([key, value]) => {
+ const sectionYMap = new Y.Map();
+
+ // Domain keys have nested 'answers' object with individual questions
+ if (key.startsWith('domain') || key === 'overall') {
+ // Store judgement and direction at section level
+ sectionYMap.set('judgement', value.judgement ?? null);
+ sectionYMap.set('judgementSource', value.judgementSource ?? 'auto');
+ 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 === 'sectionB') {
+ // Section B has individual questions (b1, b2, b3) and stopAssessment
+ Object.entries(value).forEach(([subKey, subValue]) => {
+ if (typeof subValue === 'object' && subValue !== null) {
+ const questionYMap = new Y.Map();
+ questionYMap.set('answer', subValue.answer ?? null);
+ questionYMap.set('comment', new Y.Text());
+ sectionYMap.set(subKey, questionYMap);
+ } else {
+ sectionYMap.set(subKey, subValue);
+ }
+ });
+ } else if (key === 'confoundingEvaluation') {
+ // Confounding evaluation has arrays - store as JSON for now
+ sectionYMap.set('predefined', value.predefined ?? []);
+ sectionYMap.set('additional', value.additional ?? []);
+ } else if (key === 'sectionD') {
+ // Section D has sources object and otherSpecify
+ sectionYMap.set('sources', value.sources ?? {});
+ sectionYMap.set('otherSpecify', new Y.Text());
+ } else if (key === 'planning') {
+ // Planning section: confoundingFactors is free text
+ sectionYMap.set('confoundingFactors', new Y.Text());
+ } else if (key === 'sectionA') {
+ // Section A: numericalResult, furtherDetails, outcome are free text
+ sectionYMap.set('numericalResult', new Y.Text());
+ sectionYMap.set('furtherDetails', new Y.Text());
+ sectionYMap.set('outcome', new Y.Text());
+ } else if (key === 'sectionC') {
+ // Section C: participants, interventionStrategy, comparatorStrategy are free text
+ sectionYMap.set('participants', new Y.Text());
+ sectionYMap.set('interventionStrategy', new Y.Text());
+ sectionYMap.set('comparatorStrategy', new Y.Text());
+ sectionYMap.set('isPerProtocol', value.isPerProtocol ?? false);
+ } else {
+ // Other sections: store each field
+ Object.entries(value).forEach(([fieldKey, fieldValue]) => {
+ sectionYMap.set(fieldKey, fieldValue);
+ });
+ }
+
+ answersYMap.set(key, sectionYMap);
+ });
+
+ return answersYMap;
+ }
+
+ /**
+ * Serialize ROBINS-I 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,
+ judgementSource: sectionYMap.get('judgementSource') ?? 'auto',
+ answers: {}, // Always initialize answers for domains
+ };
+ 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,
+ judgementSource: sectionYMap.get('judgementSource') ?? 'auto',
+ };
+ const direction = sectionYMap.get('direction');
+ if (direction !== undefined) {
+ sectionData.direction = direction;
+ }
+ answers[key] = sectionData;
+ } else if (key === 'sectionB') {
+ const sectionData = {};
+ for (const [subKey, subValue] of sectionYMap.entries()) {
+ if (subValue instanceof Y.Map) {
+ const commentValue = subValue.get('comment');
+ sectionData[subKey] = {
+ answer: subValue.get('answer') ?? null,
+ comment: yTextToString(commentValue),
+ };
+ } else {
+ sectionData[subKey] = subValue;
+ }
+ }
+ answers[key] = sectionData;
+ } else {
+ // Other sections (planning, sectionA, sectionC, sectionD): convert Y.Map to plain object
+ // Convert Y.Text fields to strings
+ 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} questionYMap - The question Y.Map
+ * @param {string} fieldKey - The field key (e.g., 'comment')
+ * @param {string|null} value - The string value to set (null becomes empty string)
+ */
+ setYTextField(questionYMap, fieldKey, value) {
+ const commentStr = value ?? '';
+ const existing = questionYMap.get(fieldKey);
+ if (existing instanceof Y.Text) {
+ // Replace contents of existing Y.Text to preserve object identity
+ existing.delete(0, existing.length);
+ existing.insert(0, commentStr);
+ } else {
+ // Create new Y.Text if it doesn't exist or was overwritten with a string
+ const newText = new Y.Text();
+ newText.insert(0, commentStr);
+ questionYMap.set(fieldKey, newText);
+ }
+ }
+
+ /**
+ * Update a single answer/section in ROBINS-I checklist
+ * @param {Y.Map} answersMap - The answers Y.Map
+ * @param {string} key - The section key (e.g., 'domain1a', 'sectionB')
+ * @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);
+ }
+
+ // Domain keys have nested 'answers' object with individual questions
+ if (key.startsWith('domain') || key === 'overall') {
+ // Update judgement and direction at section level
+ if (data.judgement !== undefined) {
+ sectionYMap.set('judgement', data.judgement);
+ }
+ if (data.judgementSource !== undefined) {
+ sectionYMap.set('judgementSource', data.judgementSource);
+ }
+ 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 === 'sectionB') {
+ // Section B: update individual questions or stopAssessment
+ Object.entries(data).forEach(([subKey, subValue]) => {
+ if (typeof subValue === 'object' && subValue !== null) {
+ let questionYMap = sectionYMap.get(subKey);
+ if (!questionYMap || !(questionYMap instanceof Y.Map)) {
+ questionYMap = new Y.Map();
+ sectionYMap.set(subKey, questionYMap);
+ }
+ if (subValue.answer !== undefined) questionYMap.set('answer', subValue.answer);
+ if (subValue.comment !== undefined)
+ this.setYTextField(questionYMap, 'comment', subValue.comment);
+ } else {
+ sectionYMap.set(subKey, subValue);
+ }
+ });
+ } else if (key === 'confoundingEvaluation') {
+ // Confounding evaluation: update arrays
+ if (data.predefined !== undefined) sectionYMap.set('predefined', data.predefined);
+ if (data.additional !== undefined) sectionYMap.set('additional', data.additional);
+ } else if (key === 'sectionD') {
+ // Section D: update sources or otherSpecify
+ if (data.sources !== undefined) sectionYMap.set('sources', data.sources);
+ if (data.otherSpecify !== undefined) sectionYMap.set('otherSpecify', data.otherSpecify);
+ } else {
+ // Other sections: update individual fields
+ Object.entries(data).forEach(([fieldKey, fieldValue]) => {
+ sectionYMap.set(fieldKey, fieldValue);
+ });
+ }
+ }
+
+ /**
+ * Get type-specific text getter function for ROBINS-I
+ * @param {Function} getYDoc - Function that returns the Y.Doc
+ * @returns {Function} getRobinsText 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 !== 'ROBINS_I') 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 (domain1a, domain1b, etc.)
+ 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 sectionB questions (b1, b2, b3)
+ if (sectionKey === 'sectionB' && questionKey) {
+ const questionYMap = sectionYMap.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 (planning, sectionA, sectionC, sectionD)
+ 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/handlers/robins-i.test.js b/packages/web/src/primitives/useProject/checklists/handlers/robins-i.test.js
new file mode 100644
index 000000000..025c8f125
--- /dev/null
+++ b/packages/web/src/primitives/useProject/checklists/handlers/robins-i.test.js
@@ -0,0 +1,66 @@
+/**
+ * Tests for ROBINSIHandler - comment field Y.Text preservation
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import * as Y from 'yjs';
+import { ROBINSIHandler } from './robins-i.js';
+
+describe('ROBINSIHandler - comment field Y.Text handling', () => {
+ let handler;
+ let doc;
+
+ beforeEach(() => {
+ handler = new ROBINSIHandler();
+ doc = new Y.Doc();
+ });
+
+ describe('setYTextField helper method', () => {
+ it('should create Y.Text when field does not exist', () => {
+ const questionYMap = doc.getMap('question');
+ handler.setYTextField(questionYMap, 'comment', 'New comment');
+
+ const comment = questionYMap.get('comment');
+ expect(comment).toBeInstanceOf(Y.Text);
+ expect(comment.toString()).toBe('New comment');
+ });
+
+ it('should update existing Y.Text in place, preserving object identity', () => {
+ const questionYMap = doc.getMap('question');
+ const existingText = new Y.Text();
+ existingText.insert(0, 'Original comment');
+ questionYMap.set('comment', existingText);
+
+ handler.setYTextField(questionYMap, 'comment', 'Updated comment');
+
+ const comment = questionYMap.get('comment');
+ expect(comment).toBe(existingText);
+ expect(comment.toString()).toBe('Updated comment');
+ });
+
+ it('should upgrade legacy string to Y.Text', () => {
+ const questionYMap = doc.getMap('question');
+ questionYMap.set('comment', 'Legacy string comment');
+
+ handler.setYTextField(questionYMap, 'comment', 'New comment');
+
+ const comment = questionYMap.get('comment');
+ expect(comment).toBeInstanceOf(Y.Text);
+ expect(comment.toString()).toBe('New comment');
+ });
+
+ it('should handle null by converting to empty string', () => {
+ const questionYMap = doc.getMap('question');
+ const existingText = new Y.Text();
+ existingText.insert(0, 'Existing text');
+ questionYMap.set('comment', existingText);
+
+ handler.setYTextField(questionYMap, 'comment', null);
+
+ const comment = questionYMap.get('comment');
+ expect(comment).toBeInstanceOf(Y.Text);
+ expect(comment.toString()).toBe('');
+ expect(comment).toBe(existingText);
+ });
+ });
+});
diff --git a/packages/web/src/primitives/useProject/checklists/index.js b/packages/web/src/primitives/useProject/checklists/index.js
new file mode 100644
index 000000000..c01b8a28d
--- /dev/null
+++ b/packages/web/src/primitives/useProject/checklists/index.js
@@ -0,0 +1,234 @@
+/**
+ * Checklist operations for useProject
+ * Main coordinator that delegates to type-specific handlers
+ */
+
+import * as Y from 'yjs';
+import { createChecklistOfType, CHECKLIST_TYPES } from '@/checklist-registry';
+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';
+
+/**
+ * Creates checklist operations
+ * @param {string} projectId - The project ID
+ * @param {Function} getYDoc - Function that returns the Y.Doc
+ * @param {Function} isSynced - Function that returns sync status
+ * @returns {Object} Checklist operations
+ */
+export function createChecklistOperations(_projectId, getYDoc, _isSynced) {
+ // Initialize common operations
+ const commonOps = createCommonOperations(getYDoc);
+
+ // Initialize type-specific handlers
+ const amstar2Handler = new AMSTAR2Handler();
+ const robinsIHandler = new ROBINSIHandler();
+
+ // Handler registry
+ const handlers = {
+ [CHECKLIST_TYPES.AMSTAR2]: amstar2Handler,
+ [CHECKLIST_TYPES.ROBINS_I]: robinsIHandler,
+ };
+
+ /**
+ * Get handler for a checklist type
+ * @param {string} type - The checklist type
+ * @returns {ChecklistHandler|null} The handler or null
+ */
+ function getHandler(type) {
+ return handlers[type] || null;
+ }
+
+ /**
+ * Create a checklist in a study
+ * @param {string} studyId - The study ID
+ * @param {string} type - Checklist type (default: 'AMSTAR2')
+ * @param {string|null} assignedTo - User ID to assign to
+ * @returns {string|null} The checklist ID or null if failed
+ */
+ function createChecklist(studyId, type = 'AMSTAR2', assignedTo = null) {
+ const ydoc = getYDoc();
+ if (!ydoc) return null;
+
+ const studiesMap = ydoc.getMap('reviews');
+ const studyYMap = studiesMap.get(studyId);
+
+ if (!studyYMap) return null;
+
+ let checklistsMap = studyYMap.get('checklists');
+ if (!checklistsMap) {
+ checklistsMap = new Y.Map();
+ studyYMap.set('checklists', checklistsMap);
+ }
+
+ const checklistId = crypto.randomUUID();
+ const now = Date.now();
+
+ // Get the default answers structure for this checklist type using the registry
+ const checklistTemplate = createChecklistOfType(type, {
+ id: checklistId,
+ name: `${type} Checklist`,
+ createdAt: now,
+ });
+
+ // Get handler for this type
+ const handler = getHandler(type);
+ if (!handler) {
+ // Fallback for unknown types: store data directly
+ const checklistYMap = new Y.Map();
+ checklistYMap.set('type', type);
+ checklistYMap.set('title', `${type} Checklist`);
+ checklistYMap.set('assignedTo', assignedTo);
+ checklistYMap.set('status', CHECKLIST_STATUS.PENDING);
+ checklistYMap.set('createdAt', now);
+ checklistYMap.set('updatedAt', now);
+
+ const answersYMap = new Y.Map();
+ Object.entries(checklistTemplate).forEach(([key, value]) => {
+ answersYMap.set(key, value);
+ });
+ checklistYMap.set('answers', answersYMap);
+ checklistsMap.set(checklistId, checklistYMap);
+ studyYMap.set('updatedAt', now);
+ return checklistId;
+ }
+
+ // Extract answers using handler
+ const answersData = handler.extractAnswersFromTemplate(checklistTemplate);
+
+ // Create checklist Y.Map
+ const checklistYMap = new Y.Map();
+ checklistYMap.set('type', type);
+ checklistYMap.set('title', `${type} Checklist`);
+ checklistYMap.set('assignedTo', assignedTo);
+ checklistYMap.set('status', CHECKLIST_STATUS.PENDING);
+ checklistYMap.set('createdAt', now);
+ checklistYMap.set('updatedAt', now);
+
+ // Create answers Y.Map using handler
+ const answersYMap = handler.createAnswersYMap(answersData);
+ checklistYMap.set('answers', answersYMap);
+
+ checklistsMap.set(checklistId, checklistYMap);
+
+ // Update study's updatedAt
+ studyYMap.set('updatedAt', now);
+
+ return checklistId;
+ }
+
+ /**
+ * Get full checklist data including answers in plain object format
+ * @param {string} studyId - The study ID
+ * @param {string} checklistId - The checklist ID
+ * @returns {Object|null} The checklist data or null
+ */
+ function getChecklistData(studyId, checklistId) {
+ const result = commonOps.getChecklistYMap(studyId, checklistId);
+ if (!result) return null;
+
+ const { checklistYMap, checklistType } = result;
+ const data = checklistYMap.toJSON ? checklistYMap.toJSON() : {};
+
+ // Get handler for this type
+ const handler = getHandler(checklistType);
+ const answersMap = checklistYMap.get('answers');
+
+ let answers = {};
+ if (answersMap && typeof answersMap.entries === 'function') {
+ if (handler) {
+ // Use handler to serialize
+ answers = handler.serializeAnswers(answersMap);
+ } else {
+ // Fallback for unknown types: simple toJSON
+ for (const [key, sectionYMap] of answersMap.entries()) {
+ const sectionData = sectionYMap.toJSON ? sectionYMap.toJSON() : sectionYMap;
+ answers[key] = sectionData;
+ }
+ }
+ }
+
+ return {
+ ...data,
+ answers,
+ };
+ }
+
+ /**
+ * Update a single answer/section in a checklist
+ * @param {string} studyId - The study ID
+ * @param {string} checklistId - The checklist ID
+ * @param {string} key - The answer key (e.g., 'q1' for AMSTAR2, 'domain1a' for ROBINS-I)
+ * @param {Object} data - The answer data (structure depends on checklist type)
+ */
+ function updateChecklistAnswer(studyId, checklistId, key, data) {
+ const result = commonOps.getChecklistYMap(studyId, checklistId);
+ if (!result) return;
+
+ const { checklistYMap, checklistType } = result;
+
+ let answersMap = checklistYMap.get('answers');
+ if (!answersMap) {
+ answersMap = new Y.Map();
+ checklistYMap.set('answers', answersMap);
+ }
+
+ // Get handler for this type
+ const handler = getHandler(checklistType);
+ if (handler) {
+ // Use handler to update
+ handler.updateAnswer(answersMap, key, data);
+ } else {
+ // Fallback for unknown types: store data directly
+ answersMap.set(key, data);
+ }
+
+ // Auto-transition status from 'pending' to 'in-progress' on first edit
+ const currentStatus = checklistYMap.get('status');
+ if (currentStatus === CHECKLIST_STATUS.PENDING) {
+ checklistYMap.set('status', CHECKLIST_STATUS.IN_PROGRESS);
+ }
+
+ checklistYMap.set('updatedAt', Date.now());
+ }
+
+ /**
+ * Get a Y.Text reference for a question's note (AMSTAR2-specific)
+ * @param {string} studyId - The study ID
+ * @param {string} checklistId - The checklist ID
+ * @param {string} questionKey - The question key (e.g., 'q1', 'q9' for multi-part)
+ * @returns {Y.Text|null} The Y.Text reference or null
+ */
+ function getQuestionNote(studyId, checklistId, questionKey) {
+ const textGetter = amstar2Handler.getTextGetter(getYDoc);
+ if (!textGetter) return null;
+ return textGetter(studyId, checklistId, questionKey);
+ }
+
+ /**
+ * Get a Y.Text reference for a ROBINS-I 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 getRobinsText(studyId, checklistId, sectionKey, fieldKey, questionKey = null) {
+ const textGetter = robinsIHandler.getTextGetter(getYDoc);
+ if (!textGetter) return null;
+ return textGetter(studyId, checklistId, sectionKey, fieldKey, questionKey);
+ }
+
+ return {
+ createChecklist,
+ updateChecklist: commonOps.updateChecklist,
+ deleteChecklist: commonOps.deleteChecklist,
+ getChecklistAnswersMap: commonOps.getChecklistAnswersMap,
+ getChecklistData,
+ updateChecklistAnswer,
+ getQuestionNote,
+ getRobinsText,
+ };
+}
diff --git a/packages/web/src/primitives/useProject/index.js b/packages/web/src/primitives/useProject/index.js
index e97b70dfd..1b29b17fe 100644
--- a/packages/web/src/primitives/useProject/index.js
+++ b/packages/web/src/primitives/useProject/index.js
@@ -15,7 +15,7 @@ import useOnlineStatus from '../useOnlineStatus.js';
import { createConnectionManager } from './connection.js';
import { createSyncManager } from './sync.js';
import { createStudyOperations } from './studies.js';
-import { createChecklistOperations } from './checklists.js';
+import { createChecklistOperations } from './checklists/index.js';
import { createPdfOperations } from './pdfs.js';
import { createReconciliationOperations } from './reconciliation.js';
@@ -212,6 +212,7 @@ export function useProject(projectId) {
getChecklistData: connectionEntry.checklistOps.getChecklistData,
updateChecklistAnswer: connectionEntry.checklistOps.updateChecklistAnswer,
getQuestionNote: connectionEntry.checklistOps.getQuestionNote,
+ getRobinsText: connectionEntry.checklistOps.getRobinsText,
// PDF operations
addPdfToStudy: connectionEntry.pdfOps.addPdfToStudy,
removePdfFromStudy: connectionEntry.pdfOps.removePdfFromStudy,
@@ -335,6 +336,7 @@ export function useProject(projectId) {
updateChecklistAnswer: (...args) =>
connectionEntry?.checklistOps?.updateChecklistAnswer(...args),
getQuestionNote: (...args) => connectionEntry?.checklistOps?.getQuestionNote(...args),
+ getRobinsText: (...args) => connectionEntry?.checklistOps?.getRobinsText(...args),
// PDF operations
addPdfToStudy: (...args) => connectionEntry?.pdfOps?.addPdfToStudy(...args),
diff --git a/packages/web/src/primitives/useProject/sync.js b/packages/web/src/primitives/useProject/sync.js
index bcd0ae366..d5ebc87f1 100644
--- a/packages/web/src/primitives/useProject/sync.js
+++ b/packages/web/src/primitives/useProject/sync.js
@@ -3,6 +3,7 @@
* Handles syncing Y.Doc state to the project store
*/
+import * as Y from 'yjs';
import projectStore from '@/stores/projectStore.js';
import { scoreChecklistOfType } from '@/checklist-registry/index.js';
import { getAnswers as getAMSTAR2Answers } from '@/components/checklist/AMSTAR2Checklist/checklist.js';
@@ -223,9 +224,11 @@ function extractAnswersFromYMap(answersMap, checklistType) {
if (answersNestedYMap && typeof answersNestedYMap.entries === 'function') {
for (const [qKey, questionYMap] of answersNestedYMap.entries()) {
if (questionYMap && typeof questionYMap.get === 'function') {
+ const commentValue = questionYMap.get('comment');
sectionData.answers[qKey] = {
answer: questionYMap.get('answer') ?? null,
- comment: questionYMap.get('comment') ?? '',
+ comment:
+ commentValue instanceof Y.Text ? commentValue.toString() : (commentValue ?? ''),
};
} else {
sectionData.answers[qKey] = questionYMap;
@@ -246,9 +249,11 @@ function extractAnswersFromYMap(answersMap, checklistType) {
const sectionData = {};
for (const [subKey, subValue] of sectionYMap.entries()) {
if (subValue && typeof subValue.get === 'function') {
+ const commentValue = subValue.get('comment');
sectionData[subKey] = {
answer: subValue.get('answer') ?? null,
- comment: subValue.get('comment') ?? '',
+ comment:
+ commentValue instanceof Y.Text ? commentValue.toString() : (commentValue ?? ''),
};
} else {
sectionData[subKey] = subValue;
@@ -256,7 +261,16 @@ function extractAnswersFromYMap(answersMap, checklistType) {
}
answers[key] = sectionData;
} else {
- answers[key] = sectionYMap.toJSON ? sectionYMap.toJSON() : sectionYMap;
+ // Other sections (planning, sectionA, sectionC, sectionD): convert Y.Text to strings
+ 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;
}
} else {
// AMSTAR2 and other types: simple toJSON conversion
diff --git a/packages/workers/migrations/meta/0000_snapshot.json b/packages/workers/migrations/meta/0000_snapshot.json
index 738a907e6..41c3935c8 100644
--- a/packages/workers/migrations/meta/0000_snapshot.json
+++ b/packages/workers/migrations/meta/0000_snapshot.json
@@ -107,12 +107,8 @@
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
- "columnsFrom": [
- "userId"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["userId"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -190,12 +186,8 @@
"name": "invitation_inviterId_user_id_fk",
"tableFrom": "invitation",
"tableTo": "user",
- "columnsFrom": [
- "inviterId"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["inviterId"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -203,12 +195,8 @@
"name": "invitation_organizationId_organization_id_fk",
"tableFrom": "invitation",
"tableTo": "organization",
- "columnsFrom": [
- "organizationId"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["organizationId"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -284,12 +272,8 @@
"name": "mediaFiles_uploadedBy_user_id_fk",
"tableFrom": "mediaFiles",
"tableTo": "user",
- "columnsFrom": [
- "uploadedBy"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["uploadedBy"],
+ "columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -345,12 +329,8 @@
"name": "member_userId_user_id_fk",
"tableFrom": "member",
"tableTo": "user",
- "columnsFrom": [
- "userId"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["userId"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -358,12 +338,8 @@
"name": "member_organizationId_organization_id_fk",
"tableFrom": "member",
"tableTo": "organization",
- "columnsFrom": [
- "organizationId"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["organizationId"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -422,9 +398,7 @@
"indexes": {
"organization_slug_unique": {
"name": "organization_slug_unique",
- "columns": [
- "slug"
- ],
+ "columns": ["slug"],
"isUnique": true
}
},
@@ -528,9 +502,7 @@
"indexes": {
"project_invitations_token_unique": {
"name": "project_invitations_token_unique",
- "columns": [
- "token"
- ],
+ "columns": ["token"],
"isUnique": true
}
},
@@ -539,12 +511,8 @@
"name": "project_invitations_orgId_organization_id_fk",
"tableFrom": "project_invitations",
"tableTo": "organization",
- "columnsFrom": [
- "orgId"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["orgId"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -552,12 +520,8 @@
"name": "project_invitations_projectId_projects_id_fk",
"tableFrom": "project_invitations",
"tableTo": "projects",
- "columnsFrom": [
- "projectId"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["projectId"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -565,12 +529,8 @@
"name": "project_invitations_invitedBy_user_id_fk",
"tableFrom": "project_invitations",
"tableTo": "user",
- "columnsFrom": [
- "invitedBy"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["invitedBy"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -626,12 +586,8 @@
"name": "project_members_projectId_projects_id_fk",
"tableFrom": "project_members",
"tableTo": "projects",
- "columnsFrom": [
- "projectId"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["projectId"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -639,12 +595,8 @@
"name": "project_members_userId_user_id_fk",
"tableFrom": "project_members",
"tableTo": "user",
- "columnsFrom": [
- "userId"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["userId"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -714,12 +666,8 @@
"name": "projects_orgId_organization_id_fk",
"tableFrom": "projects",
"tableTo": "organization",
- "columnsFrom": [
- "orgId"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["orgId"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -727,12 +675,8 @@
"name": "projects_createdBy_user_id_fk",
"tableFrom": "projects",
"tableTo": "user",
- "columnsFrom": [
- "createdBy"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["createdBy"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -820,9 +764,7 @@
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
- "columns": [
- "token"
- ],
+ "columns": ["token"],
"isUnique": true
}
},
@@ -831,12 +773,8 @@
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
- "columnsFrom": [
- "userId"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["userId"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -844,12 +782,8 @@
"name": "session_impersonatedBy_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
- "columnsFrom": [
- "impersonatedBy"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["impersonatedBy"],
+ "columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
},
@@ -857,12 +791,8 @@
"name": "session_activeOrganizationId_organization_id_fk",
"tableFrom": "session",
"tableTo": "organization",
- "columnsFrom": [
- "activeOrganizationId"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["activeOrganizationId"],
+ "columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -960,23 +890,17 @@
"indexes": {
"subscriptions_userId_unique": {
"name": "subscriptions_userId_unique",
- "columns": [
- "userId"
- ],
+ "columns": ["userId"],
"isUnique": true
},
"subscriptions_stripeCustomerId_unique": {
"name": "subscriptions_stripeCustomerId_unique",
- "columns": [
- "stripeCustomerId"
- ],
+ "columns": ["stripeCustomerId"],
"isUnique": true
},
"subscriptions_stripeSubscriptionId_unique": {
"name": "subscriptions_stripeSubscriptionId_unique",
- "columns": [
- "stripeSubscriptionId"
- ],
+ "columns": ["stripeSubscriptionId"],
"isUnique": true
}
},
@@ -985,12 +909,8 @@
"name": "subscriptions_userId_user_id_fk",
"tableFrom": "subscriptions",
"tableTo": "user",
- "columnsFrom": [
- "userId"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["userId"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1053,12 +973,8 @@
"name": "twoFactor_userId_user_id_fk",
"tableFrom": "twoFactor",
"tableTo": "user",
- "columnsFrom": [
- "userId"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["userId"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1198,16 +1114,12 @@
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
- "columns": [
- "email"
- ],
+ "columns": ["email"],
"isUnique": true
},
"user_username_unique": {
"name": "user_username_unique",
- "columns": [
- "username"
- ],
+ "columns": ["username"],
"isUnique": true
}
},
@@ -1281,4 +1193,4 @@
"internal": {
"indexes": {}
}
-}
\ No newline at end of file
+}
diff --git a/packages/workers/migrations/meta/_journal.json b/packages/workers/migrations/meta/_journal.json
index 957112561..abac566a1 100644
--- a/packages/workers/migrations/meta/_journal.json
+++ b/packages/workers/migrations/meta/_journal.json
@@ -10,4 +10,4 @@
"breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/packages/workers/scripts/generate-openapi.mjs b/packages/workers/scripts/generate-openapi.mjs
index 4511a4606..dc35b84d8 100644
--- a/packages/workers/scripts/generate-openapi.mjs
+++ b/packages/workers/scripts/generate-openapi.mjs
@@ -252,9 +252,18 @@ async function generate() {
// Organization routes (new org-scoped architecture)
{ file: 'src/routes/orgs/index.js', basePath: '/api/orgs' },
{ file: 'src/routes/orgs/projects.js', basePath: '/api/orgs/{orgId}/projects' },
- { file: 'src/routes/orgs/members.js', basePath: '/api/orgs/{orgId}/projects/{projectId}/members' },
- { file: 'src/routes/orgs/invitations.js', basePath: '/api/orgs/{orgId}/projects/{projectId}/invitations' },
- { file: 'src/routes/orgs/pdfs.js', basePath: '/api/orgs/{orgId}/projects/{projectId}/studies/{studyId}/pdfs' },
+ {
+ file: 'src/routes/orgs/members.js',
+ basePath: '/api/orgs/{orgId}/projects/{projectId}/members',
+ },
+ {
+ file: 'src/routes/orgs/invitations.js',
+ basePath: '/api/orgs/{orgId}/projects/{projectId}/invitations',
+ },
+ {
+ file: 'src/routes/orgs/pdfs.js',
+ basePath: '/api/orgs/{orgId}/projects/{projectId}/studies/{studyId}/pdfs',
+ },
// Legacy routes (deprecated, kept for backward compatibility detection)
{ file: 'src/routes/projects.js', basePath: '/api/projects' },
{ file: 'src/routes/members.js', basePath: '/api/projects/{projectId}/members' },
diff --git a/scripts/loc-report.mjs b/scripts/loc-report.mjs
index 83ec1f99d..f4f878907 100644
--- a/scripts/loc-report.mjs
+++ b/scripts/loc-report.mjs
@@ -20,6 +20,7 @@ import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
+// const ROOT = '/Users/jacobmaynard/Documents/Repos/Courses/DOSSP/';
function checkCommand(command) {
const result = spawnSync('which', [command], { encoding: 'utf8' });