From 29490e9625666794cc41dce070294abf08deae05 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 17 Jan 2026 11:17:24 -0600 Subject: [PATCH 1/6] initial rob-2 reconcile setup --- .../shared/src/checklists/rob2/compare.ts | 473 ++++++++++++++ packages/shared/src/checklists/rob2/index.ts | 3 + .../reconcile-tab/ReconciliationWrapper.jsx | 93 ++- .../rob2-reconcile/ROB2Navbar.jsx | 231 +++++++ .../rob2-reconcile/ROB2Reconciliation.jsx | 615 ++++++++++++++++++ .../ROB2ReconciliationWithPdf.jsx | 149 +++++ .../rob2-reconcile/ROB2SummaryView.jsx | 253 +++++++ .../reconcile-tab/rob2-reconcile/index.js | 19 + .../rob2-reconcile/navbar-utils.js | 404 ++++++++++++ .../pages/DomainDirectionPage.jsx | 154 +++++ .../pages/OverallDirectionPage.jsx | 154 +++++ .../rob2-reconcile/pages/PreliminaryPage.jsx | 393 +++++++++++ .../pages/SignallingQuestionPage.jsx | 117 ++++ .../rob2-reconcile/panels/DirectionPanel.jsx | 136 ++++ .../rob2-reconcile/panels/JudgementPanel.jsx | 106 +++ .../rob2-reconcile/panels/ROB2AnswerPanel.jsx | 174 +++++ 16 files changed, 3450 insertions(+), 24 deletions(-) create mode 100644 packages/shared/src/checklists/rob2/compare.ts create mode 100644 packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2Navbar.jsx create mode 100644 packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2Reconciliation.jsx create mode 100644 packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2ReconciliationWithPdf.jsx create mode 100644 packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2SummaryView.jsx create mode 100644 packages/web/src/components/project/reconcile-tab/rob2-reconcile/index.js create mode 100644 packages/web/src/components/project/reconcile-tab/rob2-reconcile/navbar-utils.js create mode 100644 packages/web/src/components/project/reconcile-tab/rob2-reconcile/pages/DomainDirectionPage.jsx create mode 100644 packages/web/src/components/project/reconcile-tab/rob2-reconcile/pages/OverallDirectionPage.jsx create mode 100644 packages/web/src/components/project/reconcile-tab/rob2-reconcile/pages/PreliminaryPage.jsx create mode 100644 packages/web/src/components/project/reconcile-tab/rob2-reconcile/pages/SignallingQuestionPage.jsx create mode 100644 packages/web/src/components/project/reconcile-tab/rob2-reconcile/panels/DirectionPanel.jsx create mode 100644 packages/web/src/components/project/reconcile-tab/rob2-reconcile/panels/JudgementPanel.jsx create mode 100644 packages/web/src/components/project/reconcile-tab/rob2-reconcile/panels/ROB2AnswerPanel.jsx diff --git a/packages/shared/src/checklists/rob2/compare.ts b/packages/shared/src/checklists/rob2/compare.ts new file mode 100644 index 000000000..334eef9b0 --- /dev/null +++ b/packages/shared/src/checklists/rob2/compare.ts @@ -0,0 +1,473 @@ +/** + * ROB-2 Checklist Comparison + * + * Utilities for comparing two reviewer checklists and creating reconciled versions. + */ + +import { + ROB2_CHECKLIST, + PRELIMINARY_SECTION, + getActiveDomainKeys, + getDomainQuestions as getDomainQuestionsFromSchema, + type DomainKey, + type ROB2Domain, +} from './schema.js'; +import { + scoreRob2Domain, + scoreAllDomains, + type ChecklistState, + type DomainState, +} from './scoring.js'; +import type { ROB2Checklist } from './create.js'; + +// Re-export ROB2Checklist for convenience (it's defined in create.ts) +export type { ROB2Checklist }; + +// ============================================================================ +// Types +// ============================================================================ + +export interface QuestionComparison { + key: string; + isAgreement: boolean; + reviewer1: { + answer: string | null; + comment?: string; + }; + reviewer2: { + answer: string | null; + comment?: string; + }; +} + +export interface DomainComparison { + questions: { + agreements: QuestionComparison[]; + disagreements: QuestionComparison[]; + }; + judgement1: string | null; + judgement2: string | null; + judgementMatch: boolean; + direction1: string | null; + direction2: string | null; + directionMatch: boolean; +} + +export interface PreliminaryFieldComparison { + key: string; + isAgreement: boolean; + reviewer1Value: unknown; + reviewer2Value: unknown; +} + +export interface PreliminaryComparison { + fields: PreliminaryFieldComparison[]; + aimMismatch: boolean; + aim1: string | null; + aim2: string | null; +} + +export interface OverallComparison { + judgement1: string | null; + judgement2: string | null; + judgementMatch: boolean; + direction1: string | null; + direction2: string | null; + directionMatch: boolean; +} + +export interface ComparisonStats { + total: number; + agreed: number; + disagreed: number; + agreementRate: number; +} + +export interface ComparisonResult { + preliminary: PreliminaryComparison; + domains: Record; + overall: OverallComparison; + stats: ComparisonStats; +} + +// Use a looser checklist type for comparison since reconciled checklists may have partial data +interface PartialROB2Checklist { + id?: string; + name?: string; + reviewerName?: string; + createdAt?: string; + preliminary?: { + studyDesign?: string | null; + experimental?: string; + comparator?: string; + numericalResult?: string; + aim?: string | null; + deviationsToAddress?: string[]; + sources?: Record; + }; + domain1?: DomainState; + domain2a?: DomainState; + domain2b?: DomainState; + domain3?: DomainState; + domain4?: DomainState; + domain5?: DomainState; + overall?: { + judgement?: string | null; + direction?: string | null; + }; + [key: string]: unknown; +} + +// ============================================================================ +// Preliminary Section Comparison +// ============================================================================ + +const PRELIMINARY_FIELD_KEYS = [ + 'studyDesign', + 'experimental', + 'comparator', + 'numericalResult', + 'aim', + 'deviationsToAddress', + 'sources', +] as const; + +/** + * Compare preliminary sections of two checklists + */ +function comparePreliminary( + prelim1: PartialROB2Checklist['preliminary'], + prelim2: PartialROB2Checklist['preliminary'], +): PreliminaryComparison { + const fields: PreliminaryFieldComparison[] = []; + + for (const key of PRELIMINARY_FIELD_KEYS) { + const value1 = prelim1?.[key]; + const value2 = prelim2?.[key]; + + let isAgreement: boolean; + + if (key === 'deviationsToAddress') { + // Compare arrays + const arr1 = (value1 as string[] | undefined) || []; + const arr2 = (value2 as string[] | undefined) || []; + isAgreement = + arr1.length === arr2.length && arr1.every((v, i) => v === arr2[i]); + } else if (key === 'sources') { + // Compare objects + const obj1 = (value1 as Record | undefined) || {}; + const obj2 = (value2 as Record | undefined) || {}; + const keys1 = Object.keys(obj1).filter(k => obj1[k]); + const keys2 = Object.keys(obj2).filter(k => obj2[k]); + isAgreement = + keys1.length === keys2.length && keys1.every(k => keys2.includes(k)); + } else { + // Compare primitives (strings) + isAgreement = value1 === value2; + } + + fields.push({ + key, + isAgreement, + reviewer1Value: value1, + reviewer2Value: value2, + }); + } + + const aim1 = (prelim1?.aim as string) || null; + const aim2 = (prelim2?.aim as string) || null; + const aimMismatch = aim1 !== aim2 && aim1 !== null && aim2 !== null; + + return { + fields, + aimMismatch, + aim1, + aim2, + }; +} + +// ============================================================================ +// Domain Comparison +// ============================================================================ + +/** + * Compare a domain between two checklists + */ +function compareDomain( + domainKey: DomainKey, + domain1: DomainState | undefined, + domain2: DomainState | undefined, +): DomainComparison { + const domainDef = ROB2_CHECKLIST[domainKey] as ROB2Domain; + const questionKeys = Object.keys(domainDef?.questions || {}); + + const agreements: QuestionComparison[] = []; + const disagreements: QuestionComparison[] = []; + + for (const qKey of questionKeys) { + const ans1 = domain1?.answers?.[qKey]?.answer ?? null; + const ans2 = domain2?.answers?.[qKey]?.answer ?? null; + + const comparison: QuestionComparison = { + key: qKey, + isAgreement: ans1 === ans2, + reviewer1: { + answer: ans1, + comment: domain1?.answers?.[qKey]?.comment || '', + }, + reviewer2: { + answer: ans2, + comment: domain2?.answers?.[qKey]?.comment || '', + }, + }; + + if (comparison.isAgreement) { + agreements.push(comparison); + } else { + disagreements.push(comparison); + } + } + + // Get auto-calculated judgements + const scoring1 = scoreRob2Domain(domainKey, domain1?.answers); + const scoring2 = scoreRob2Domain(domainKey, domain2?.answers); + + const judgement1 = scoring1.judgement; + const judgement2 = scoring2.judgement; + + const direction1 = domain1?.direction ?? null; + const direction2 = domain2?.direction ?? null; + + return { + questions: { agreements, disagreements }, + judgement1, + judgement2, + judgementMatch: judgement1 === judgement2, + direction1, + direction2, + directionMatch: direction1 === direction2, + }; +} + +/** + * Compare overall judgement between two checklists + */ +function compareOverall( + checklist1: PartialROB2Checklist, + checklist2: PartialROB2Checklist, +): OverallComparison { + // Get auto-calculated overall judgements + // Cast to ChecklistState since the structures are compatible for scoring purposes + const scoring1 = scoreAllDomains(checklist1 as unknown as ChecklistState); + const scoring2 = scoreAllDomains(checklist2 as unknown as ChecklistState); + + const judgement1 = scoring1.overall; + const judgement2 = scoring2.overall; + + const direction1 = checklist1.overall?.direction ?? null; + const direction2 = checklist2.overall?.direction ?? null; + + return { + judgement1, + judgement2, + judgementMatch: judgement1 === judgement2, + direction1, + direction2, + directionMatch: direction1 === direction2, + }; +} + +// ============================================================================ +// Main Comparison Function +// ============================================================================ + +/** + * Compare the answers of two ROB-2 checklists and identify differences + */ +export function compareChecklists( + checklist1: PartialROB2Checklist | null | undefined, + checklist2: PartialROB2Checklist | null | undefined, +): ComparisonResult { + if (!checklist1 || !checklist2) { + return { + preliminary: { + fields: [], + aimMismatch: false, + aim1: null, + aim2: null, + }, + domains: {}, + overall: { + judgement1: null, + judgement2: null, + judgementMatch: false, + direction1: null, + direction2: null, + directionMatch: false, + }, + stats: { total: 0, agreed: 0, disagreed: 0, agreementRate: 0 }, + }; + } + + // Compare preliminary section + const preliminary = comparePreliminary( + checklist1.preliminary, + checklist2.preliminary, + ); + + // Determine active domains based on reconciled aim (use checklist1's aim as reference) + const isAdhering = checklist1.preliminary?.aim === 'ADHERING'; + const activeDomains = getActiveDomainKeys(isAdhering); + + // Compare each active domain + const domains: Record = {}; + for (const domainKey of activeDomains) { + domains[domainKey] = compareDomain( + domainKey, + checklist1[domainKey] as DomainState, + checklist2[domainKey] as DomainState, + ); + } + + // Compare overall + const overall = compareOverall(checklist1, checklist2); + + // Calculate stats + let totalItems = preliminary.fields.length; + let agreedItems = preliminary.fields.filter(f => f.isAgreement).length; + + for (const domainKey of activeDomains) { + const domain = domains[domainKey]; + totalItems += + domain.questions.agreements.length + domain.questions.disagreements.length; + agreedItems += domain.questions.agreements.length; + + // Count direction as an item + totalItems += 1; + if (domain.directionMatch) agreedItems += 1; + } + + // Count overall direction + totalItems += 1; + if (overall.directionMatch) agreedItems += 1; + + return { + preliminary, + domains, + overall, + stats: { + total: totalItems, + agreed: agreedItems, + disagreed: totalItems - agreedItems, + agreementRate: totalItems > 0 ? agreedItems / totalItems : 0, + }, + }; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Check if there is an aim mismatch between two checklists + */ +export function hasAimMismatch( + checklist1: PartialROB2Checklist | null | undefined, + checklist2: PartialROB2Checklist | null | undefined, +): boolean { + const aim1 = checklist1?.preliminary?.aim; + const aim2 = checklist2?.preliminary?.aim; + return aim1 !== aim2 && aim1 != null && aim2 != null; +} + +/** + * Get a summary of what needs reconciliation + */ +export function getReconciliationSummary(comparison: ComparisonResult): { + totalItems: number; + agreementCount: number; + disagreementCount: number; + agreementPercentage: number; + preliminaryDisagreements: number; + domainDisagreements: Array<{ + domain: string; + count: number; + questions: string[]; + }>; + directionDisagreements: string[]; + aimMismatch: boolean; + needsReconciliation: boolean; +} { + const { preliminary, domains, overall, stats } = comparison; + + const preliminaryDisagreements = preliminary.fields.filter( + f => !f.isAgreement, + ).length; + + const domainDisagreements: Array<{ + domain: string; + count: number; + questions: string[]; + }> = []; + const directionDisagreements: string[] = []; + + for (const [domainKey, domain] of Object.entries(domains)) { + if (domain.questions.disagreements.length > 0) { + domainDisagreements.push({ + domain: domainKey, + count: domain.questions.disagreements.length, + questions: domain.questions.disagreements.map(d => d.key), + }); + } + if (!domain.directionMatch) { + directionDisagreements.push(domainKey); + } + } + + if (!overall.directionMatch) { + directionDisagreements.push('overall'); + } + + return { + totalItems: stats.total, + agreementCount: stats.agreed, + disagreementCount: stats.disagreed, + agreementPercentage: Math.round(stats.agreementRate * 100), + preliminaryDisagreements, + domainDisagreements, + directionDisagreements, + aimMismatch: preliminary.aimMismatch, + needsReconciliation: stats.disagreed > 0 || preliminary.aimMismatch, + }; +} + +/** + * Get the domain definition from the schema + */ +export function getDomainDef(domainKey: string): ROB2Domain | undefined { + return ROB2_CHECKLIST[domainKey as keyof typeof ROB2_CHECKLIST] as + | ROB2Domain + | undefined; +} + +/** + * Get the domain name/title + */ +export function getDomainName(domainKey: string): string { + const domain = getDomainDef(domainKey); + return domain?.name || domainKey; +} + +/** + * Get questions for a domain (re-exported from schema) + */ +export { getDomainQuestionsFromSchema as getComparisonDomainQuestions }; + +/** + * Get a preliminary field definition + */ +export function getPreliminaryFieldDef( + fieldKey: string, +): (typeof PRELIMINARY_SECTION)[keyof typeof PRELIMINARY_SECTION] | undefined { + return PRELIMINARY_SECTION[fieldKey as keyof typeof PRELIMINARY_SECTION]; +} diff --git a/packages/shared/src/checklists/rob2/index.ts b/packages/shared/src/checklists/rob2/index.ts index 94ef4c90f..97c8fad9d 100644 --- a/packages/shared/src/checklists/rob2/index.ts +++ b/packages/shared/src/checklists/rob2/index.ts @@ -16,3 +16,6 @@ export * from './create.js'; // Answer manipulation export * from './answers.js'; + +// Comparison utilities +export * from './compare.js'; diff --git a/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.jsx b/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.jsx index 0ab2684e5..d4c43943a 100644 --- a/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.jsx +++ b/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.jsx @@ -19,6 +19,7 @@ import { getCachedPdf, cachePdf } from '@primitives/pdfCache.js'; import { showToast } from '@corates/ui'; import ReconciliationWithPdf from './ReconciliationWithPdf.jsx'; import { RobinsIReconciliationWithPdf } from './robins-i-reconcile/index.js'; +import { ROB2ReconciliationWithPdf } from './rob2-reconcile/index.js'; import { CHECKLIST_TYPES } from '@/checklist-registry/types.js'; /** @@ -47,6 +48,7 @@ export default function ReconciliationWrapper() { getReconciliationProgress, getQuestionNote, getRobinsText, + getRob2Text, saveReconciliationProgress, } = projectOps || {}; @@ -379,6 +381,12 @@ export default function ReconciliationWrapper() { return type === CHECKLIST_TYPES.ROBINS_I || type === 'ROBINS_I'; }); + // Check if this is a ROB-2 checklist + const isRob2 = createMemo(() => { + const type = checklistType(); + return type === CHECKLIST_TYPES.ROB2 || type === 'ROB2'; + }); + // Get reviewer name from userId function getReviewerName(userId) { if (!userId) return 'Unassigned'; @@ -459,31 +467,68 @@ export default function ReconciliationWrapper() { - getQuestionNote(params.studyId, reconciledChecklistId(), questionKey) + + getQuestionNote(params.studyId, reconciledChecklistId(), questionKey) + } + updateChecklistAnswer={(questionKey, questionData) => { + const id = reconciledChecklistId(); + if (!id) return; + updateChecklistAnswer(params.studyId, id, questionKey, questionData); + }} + /> } - updateChecklistAnswer={(questionKey, questionData) => { - const id = reconciledChecklistId(); - if (!id) return; - updateChecklistAnswer(params.studyId, id, questionKey, questionData); - }} - /> + > + { + const id = reconciledChecklistId(); + if (!id) return; + updateChecklistAnswer(params.studyId, id, questionKey, questionData); + }} + getRob2Text={(sectionKey, fieldKey, questionKey) => + getRob2Text( + params.studyId, + reconciledChecklistId(), + sectionKey, + fieldKey, + questionKey, + ) + } + /> + } > + getDomainProgress(props.store.navItems || [], props.store.finalAnswers, props.store.comparison), + ); + + // Get unique section keys in order + const sectionKeys = createMemo(() => { + const keys = []; + const seen = new Set(); + const navItems = props.store.navItems || []; + + for (const item of navItems) { + let key; + if (item.type === NAV_ITEM_TYPES.PRELIMINARY) { + key = 'preliminary'; + } else if (item.type === NAV_ITEM_TYPES.OVERALL_DIRECTION) { + key = 'overall'; + } else if (item.domainKey) { + key = item.domainKey; + } + + if (key && !seen.has(key)) { + seen.add(key); + keys.push(key); + } + } + return keys; + }); + + // Handle section click - expand and navigate to first unanswered + const handleSectionClick = sectionKey => { + if (props.store.expandedDomain === sectionKey) { + // Click on already expanded - collapse + props.store.setExpandedDomain?.(null); + } else { + // Expand and navigate to first unanswered + props.store.setExpandedDomain?.(sectionKey); + const sectionProgress = progress()[sectionKey]; + if (sectionProgress) { + const index = getFirstUnansweredInSection( + sectionProgress, + props.store.navItems || [], + props.store.finalAnswers, + ); + props.store.goToPage?.(index); + } + } + }; + + // Handle item click + const handleItemClick = item => { + const navItems = props.store.navItems || []; + const index = navItems.indexOf(item); + if (index >= 0) { + props.store.goToPage?.(index); + } + }; + + return ( +
+ {/* Aim Mismatch Warning */} + +
+ +
+
+ + {/* Section Pills */} + + {sectionKey => { + const sectionProgress = () => progress()[sectionKey]; + const isExpanded = () => props.store.expandedDomain === sectionKey; + const label = () => getSectionLabel(sectionKey); + + // Section status colors + const getSectionStyle = () => { + const sp = sectionProgress(); + if (!sp) return 'bg-gray-100 text-gray-600'; + const navItems = props.store.navItems || []; + + const isCurrentSection = sp.items.some( + (_, i) => navItems.indexOf(sp.items[i]) === props.store.currentPage, + ); + + if (isCurrentSection) { + return 'bg-blue-600 text-white ring-2 ring-blue-300'; + } + if (sp.isComplete) { + return 'bg-green-100 text-green-700'; + } + if (sp.hasDisagreements) { + return 'bg-amber-100 text-amber-700'; + } + return 'bg-gray-100 text-gray-600'; + }; + + return ( +
+ {/* Section Pill */} + + + {/* Expanded Items */} + +
+ + {item => { + const navItems = props.store.navItems || []; + const itemIndex = navItems.indexOf(item); + const isCurrent = () => itemIndex === props.store.currentPage; + const hasAnswer = () => hasNavItemAnswer(item, props.store.finalAnswers); + const isAgreement = () => isNavItemAgreement(item, props.store.comparison); + + // Get short label for item + const shortLabel = () => { + if (item.type === NAV_ITEM_TYPES.PRELIMINARY) { + return item.label?.substring(0, 2) || '?'; + } + if (item.type === NAV_ITEM_TYPES.DOMAIN_QUESTION) { + // Extract question number like "1.1" -> "1" + const num = item.label?.split('.')?.[1] || item.label; + return num; + } + if ( + item.type === NAV_ITEM_TYPES.DOMAIN_DIRECTION || + item.type === NAV_ITEM_TYPES.OVERALL_DIRECTION + ) { + return 'D'; // Direction + } + return '?'; + }; + + return ( + + ); + }} + +
+
+
+ ); + }} +
+ + {/* Spacer */} +
+ + {/* Summary Button */} + + + {/* Reset Button */} + +
+ ); +} diff --git a/packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2Reconciliation.jsx b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2Reconciliation.jsx new file mode 100644 index 000000000..b052028e5 --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2Reconciliation.jsx @@ -0,0 +1,615 @@ +/** + * ROB2Reconciliation - Main view for comparing and reconciling two ROB-2 checklists + * Shows one item per page with navigation through Preliminary, Domains, and Overall + */ + +import { createSignal, createMemo, createEffect, Show, Switch, Match } from 'solid-js'; +import { FiArrowLeft, FiArrowRight, FiAlertTriangle } from 'solid-icons/fi'; +import { showToast, useConfirmDialog } from '@corates/ui'; +import { compareChecklists, hasAimMismatch, getActiveDomainKeys } from '@corates/shared/checklists/rob2'; +import { + buildNavigationItems, + hasNavItemAnswer, + isNavItemAgreement, + getAnsweredCount, + getSectionKeyForPage, + NAV_ITEM_TYPES, +} from './navbar-utils.js'; +import PreliminaryPage from './pages/PreliminaryPage.jsx'; +import SignallingQuestionPage from './pages/SignallingQuestionPage.jsx'; +import DomainDirectionPage from './pages/DomainDirectionPage.jsx'; +import OverallDirectionPage from './pages/OverallDirectionPage.jsx'; +import ROB2SummaryView from './ROB2SummaryView.jsx'; + +/** + * ROB2Reconciliation - Main view for comparing and reconciling two ROB-2 checklists + * @param {Object} props + * @param {Object} props.checklist1 - First reviewer's checklist data + * @param {Object} props.checklist2 - Second reviewer's checklist data + * @param {Object} props.reconciledChecklist - The reconciled checklist data (read from Yjs, reactive) + * @param {string} props.reconciledChecklistId - ID of the reconciled checklist + * @param {Function} props.onSaveReconciled - Callback when reconciled checklist is saved + * @param {Function} props.onCancel - Callback to cancel and go back + * @param {string} props.reviewer1Name - Display name for first reviewer + * @param {string} props.reviewer2Name - Display name for second reviewer + * @param {Function} props.setNavbarStore - Store setter for navbar state + * @param {Function} props.updateChecklistAnswer - Function to update answer in reconciled checklist + * @param {Function} props.getRob2Text - Function to get Y.Text for comments (domainKey, fieldKey, questionKey) => Y.Text + * @returns {JSX.Element} + */ +export default function ROB2Reconciliation(props) { + const [saving, setSaving] = createSignal(false); + const confirmDialog = useConfirmDialog(); + + // Navigation state (localStorage-backed) + const getStorageKey = () => { + if (!props.checklist1?.id || !props.checklist2?.id) return null; + return `rob2-reconciliation-nav-${props.checklist1.id}-${props.checklist2.id}`; + }; + + const saveNavigationState = (page, mode) => { + const key = getStorageKey(); + if (!key) return; + try { + localStorage.setItem(key, JSON.stringify({ currentPage: page, viewMode: mode })); + } catch (e) { + console.error('Failed to save navigation state:', e); + } + }; + + const [currentPage, setCurrentPage] = createSignal(0); + const [viewMode, setViewMode] = createSignal('questions'); + const [expandedDomain, setExpandedDomain] = createSignal(null); + + // Initialize navigation state from localStorage + createEffect(() => { + if (!props.checklist1?.id || !props.checklist2?.id) return; + const key = getStorageKey(); + if (!key) return; + + try { + const stored = localStorage.getItem(key); + if (stored) { + const parsed = JSON.parse(stored); + setCurrentPage(parsed.currentPage ?? 0); + setViewMode(parsed.viewMode ?? 'questions'); + } + } catch (e) { + console.error('Failed to load navigation state:', e); + } + }); + + // Auto-expand domain based on current page + createEffect(() => { + const items = navItems(); + const page = currentPage(); + if (items.length > 0 && expandedDomain() === null) { + const sectionKey = getSectionKeyForPage(items, page); + if (sectionKey) { + setExpandedDomain(sectionKey); + } + } + }); + + // Save navigation state when it changes + createEffect(() => { + saveNavigationState(currentPage(), viewMode()); + }); + + // Determine aim type from reconciled checklist + const isAdhering = createMemo(() => { + return props.reconciledChecklist?.preliminary?.aim === 'ADHERING'; + }); + + // Check for aim mismatch between reviewers (blocking issue) + // Only show mismatch if reviewers disagree AND final aim hasn't been set yet + const aimMismatch = createMemo(() => { + const reviewersMismatch = hasAimMismatch(props.checklist1, props.checklist2); + if (!reviewersMismatch) return false; + + // If final aim has been set, mismatch is resolved + const finalAim = props.reconciledChecklist?.preliminary?.aim; + return !finalAim; + }); + + // Build navigation items based on aim type + const navItems = createMemo(() => buildNavigationItems(isAdhering())); + const totalPages = () => navItems().length; + + // Compare the two checklists + const comparison = createMemo(() => { + if (!props.checklist1 || !props.checklist2) return null; + return compareChecklists(props.checklist1, props.checklist2); + }); + + // Get final answers from reconciled checklist (reactive) + const finalAnswers = createMemo(() => { + return props.reconciledChecklist || {}; + }); + + // Expose navbar props for external rendering via store + createEffect(() => { + if (props.setNavbarStore) { + props.setNavbarStore({ + navItems: navItems(), + viewMode: viewMode(), + currentPage: currentPage(), + comparison: comparison(), + finalAnswers: finalAnswers(), + aimMismatch: aimMismatch(), + expandedDomain: expandedDomain(), + setViewMode, + goToPage, + setExpandedDomain, + onReset: handleReset, + }); + } + }); + + // Current navigation item + const currentNavItem = () => navItems()[currentPage()]; + + // Navigation functions + function goToNext() { + const item = currentNavItem(); + + // Auto-fill from reviewer1 if no final answer yet and reviewers agree + if (item && !hasNavItemAnswer(item, finalAnswers()) && isNavItemAgreement(item, comparison())) { + autoFillFromReviewer1(item); + } + + if (currentPage() < totalPages() - 1) { + const nextPage = currentPage() + 1; + setCurrentPage(nextPage); + + // Auto-expand domain if moving to a new one + const sectionKey = getSectionKeyForPage(navItems(), nextPage); + if (sectionKey && sectionKey !== expandedDomain()) { + setExpandedDomain(sectionKey); + } + } else { + setViewMode('summary'); + } + } + + function goToPrevious() { + if (viewMode() === 'summary') { + setViewMode('questions'); + return; + } + if (currentPage() > 0) { + const prevPage = currentPage() - 1; + setCurrentPage(prevPage); + + // Auto-expand domain if moving to a new one + const sectionKey = getSectionKeyForPage(navItems(), prevPage); + if (sectionKey && sectionKey !== expandedDomain()) { + setExpandedDomain(sectionKey); + } + } + } + + function goToPage(index) { + setCurrentPage(index); + setViewMode('questions'); + + // Auto-expand the domain containing this page + const sectionKey = getSectionKeyForPage(navItems(), index); + if (sectionKey) { + setExpandedDomain(sectionKey); + } + } + + // Auto-fill final answer from reviewer 1 + function autoFillFromReviewer1(item) { + if (!props.updateChecklistAnswer) return; + + if (item.type === NAV_ITEM_TYPES.PRELIMINARY) { + const value = props.checklist1?.preliminary?.[item.key]; + if (value !== undefined) { + updatePreliminaryField(item.key, value); + } + } else if (item.type === NAV_ITEM_TYPES.DOMAIN_QUESTION) { + const answer = props.checklist1?.[item.domainKey]?.answers?.[item.key]; + if (answer) { + updateDomainQuestionAnswer(item.domainKey, item.key, answer.answer); + copyCommentToYText(item.domainKey, 'comment', item.key, answer.comment); + } + } else if (item.type === NAV_ITEM_TYPES.DOMAIN_DIRECTION) { + const direction = props.checklist1?.[item.domainKey]?.direction; + if (direction) { + updateDomainDirection(item.domainKey, direction); + } + } else if (item.type === NAV_ITEM_TYPES.OVERALL_DIRECTION) { + const direction = props.checklist1?.overall?.direction; + if (direction) { + updateOverallDirection(direction); + } + } + } + + // Helper to copy comment text to Y.Text + function copyCommentToYText(sectionKey, fieldKey, questionKey, commentText) { + if (!props.getRob2Text) return; + const yText = props.getRob2Text(sectionKey, fieldKey, questionKey); + if (!yText) return; + const text = (commentText || '').slice(0, 2000); + yText.doc.transact(() => { + yText.delete(0, yText.length); + yText.insert(0, text); + }); + } + + // Update functions for different item types + function updatePreliminaryField(key, value) { + if (!props.updateChecklistAnswer) return; + const currentPrelim = finalAnswers().preliminary || {}; + props.updateChecklistAnswer('preliminary', { + ...currentPrelim, + [key]: value, + }); + } + + function updateDomainQuestionAnswer(domainKey, questionKey, answer) { + if (!props.updateChecklistAnswer) return; + const currentDomain = finalAnswers()[domainKey] || { answers: {} }; + props.updateChecklistAnswer(domainKey, { + ...currentDomain, + answers: { + ...currentDomain.answers, + [questionKey]: { answer }, + }, + }); + } + + function updateDomainDirection(domainKey, direction) { + if (!props.updateChecklistAnswer) return; + const currentDomain = finalAnswers()[domainKey] || { answers: {} }; + props.updateChecklistAnswer(domainKey, { + ...currentDomain, + direction, + }); + } + + function updateOverallDirection(direction) { + if (!props.updateChecklistAnswer) return; + const currentOverall = finalAnswers().overall || {}; + props.updateChecklistAnswer('overall', { + ...currentOverall, + direction, + }); + } + + // Reset all reconciliation answers + async function handleReset() { + if (!props.updateChecklistAnswer) return; + + // Reset preliminary + props.updateChecklistAnswer('preliminary', {}); + + // Reset domains + const activeDomains = getActiveDomainKeys(isAdhering()); + for (const domainKey of activeDomains) { + props.updateChecklistAnswer(domainKey, { + answers: {}, + direction: null, + }); + } + + // Reset overall + props.updateChecklistAnswer('overall', { + direction: null, + }); + + setCurrentPage(0); + setViewMode('questions'); + showToast.info('Reconciliation Reset', 'All reconciliations have been cleared.'); + } + + // Check if all items have been answered + const allAnswered = createMemo(() => { + const items = navItems(); + const finals = finalAnswers(); + return items.every(item => hasNavItemAnswer(item, finals)); + }); + + // Summary stats for summary view + const summaryStats = createMemo(() => { + const items = navItems(); + const comp = comparison(); + const finals = finalAnswers(); + + const total = items.length; + const agreed = items.filter(item => isNavItemAgreement(item, comp)).length; + const answered = getAnsweredCount(items, finals); + + return { + total, + agreed, + disagreed: total - agreed, + agreementPercentage: total > 0 ? Math.round((agreed / total) * 100) : 0, + answered, + }; + }); + + // Handle save + async function handleSave() { + if (!allAnswered()) { + showToast.error('Incomplete Review', 'Please review all items before saving.'); + return; + } + + const confirmed = await confirmDialog.open({ + title: 'Finish reconciliation?', + description: + 'This will mark the reconciled checklist as completed. You will no longer be able to edit these reconciliation answers afterwards.', + confirmText: 'Finish', + cancelText: 'Cancel', + variant: 'warning', + }); + + if (!confirmed) return; + + setSaving(true); + try { + await props.onSaveReconciled?.(); + } catch (err) { + console.error('Error saving reconciled checklist:', err); + showToast.error('Save Failed', 'Failed to save reconciled checklist. Please try again.'); + } finally { + setSaving(false); + } + } + + // Get current item's comparison data + const getCurrentItemComparison = () => { + const item = currentNavItem(); + const comp = comparison(); + if (!item || !comp) return null; + + if (item.type === NAV_ITEM_TYPES.PRELIMINARY) { + return comp.preliminary?.fields?.find(f => f.key === item.key); + } + if (item.type === NAV_ITEM_TYPES.DOMAIN_QUESTION) { + const domain = comp.domains?.[item.domainKey]; + if (!domain) return null; + const allItems = [ + ...(domain.questions?.agreements || []), + ...(domain.questions?.disagreements || []), + ]; + return allItems.find(c => c.key === item.key); + } + if (item.type === NAV_ITEM_TYPES.DOMAIN_DIRECTION) { + return comp.domains?.[item.domainKey]; + } + if (item.type === NAV_ITEM_TYPES.OVERALL_DIRECTION) { + return comp.overall; + } + return null; + }; + + return ( +
+
+ + + {/* Aim Mismatch Warning Banner */} + +
+ +
+ Aim Mismatch Detected: Reviewers selected different + aims. You must reconcile the aim field before proceeding to domain assessment. +
+
+
+ + {/* Main Content */} + + Loading...
}> + + {/* Preliminary Field */} + + updatePreliminaryField(currentNavItem().key, value)} + onUseReviewer1={() => { + const value = props.checklist1?.preliminary?.[currentNavItem().key]; + if (value !== undefined) { + updatePreliminaryField(currentNavItem().key, value); + } + }} + onUseReviewer2={() => { + const value = props.checklist2?.preliminary?.[currentNavItem().key]; + if (value !== undefined) { + updatePreliminaryField(currentNavItem().key, value); + } + }} + /> + + + {/* Domain Question */} + + + updateDomainQuestionAnswer( + currentNavItem().domainKey, + currentNavItem().key, + answer, + ) + } + onUseReviewer1={() => { + const data = + props.checklist1?.[currentNavItem().domainKey]?.answers?.[ + currentNavItem().key + ]; + if (data) { + updateDomainQuestionAnswer( + currentNavItem().domainKey, + currentNavItem().key, + data.answer, + ); + copyCommentToYText( + currentNavItem().domainKey, + 'comment', + currentNavItem().key, + data.comment, + ); + } + }} + onUseReviewer2={() => { + const data = + props.checklist2?.[currentNavItem().domainKey]?.answers?.[ + currentNavItem().key + ]; + if (data) { + updateDomainQuestionAnswer( + currentNavItem().domainKey, + currentNavItem().key, + data.answer, + ); + copyCommentToYText( + currentNavItem().domainKey, + 'comment', + currentNavItem().key, + data.comment, + ); + } + }} + /> + + + {/* Domain Direction */} + + + updateDomainDirection(currentNavItem().domainKey, direction) + } + onUseReviewer1={() => { + const direction = props.checklist1?.[currentNavItem().domainKey]?.direction; + if (direction) { + updateDomainDirection(currentNavItem().domainKey, direction); + } + }} + onUseReviewer2={() => { + const direction = props.checklist2?.[currentNavItem().domainKey]?.direction; + if (direction) { + updateDomainDirection(currentNavItem().domainKey, direction); + } + }} + /> + + + {/* Overall Direction */} + + { + const direction = props.checklist1?.overall?.direction; + if (direction) { + updateOverallDirection(direction); + } + }} + onUseReviewer2={() => { + const direction = props.checklist2?.overall?.direction; + if (direction) { + updateOverallDirection(direction); + } + }} + /> + + + + {/* Navigation Buttons */} +
+ + +
+ Item {currentPage() + 1} of {totalPages()} +
+ + +
+ + + + {/* Summary View */} + + + +
+
+ ); +} diff --git a/packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2ReconciliationWithPdf.jsx b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2ReconciliationWithPdf.jsx new file mode 100644 index 000000000..66b1451d3 --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2ReconciliationWithPdf.jsx @@ -0,0 +1,149 @@ +/** + * ROB2ReconciliationWithPdf - Wrapper that combines ROB2Reconciliation with a PDF viewer + * in a split-screen layout. The PDF is read-only during reconciliation. + */ + +import { Show, createMemo, lazy, Suspense } from 'solid-js'; +import { createStore } from 'solid-js/store'; +import { FiArrowLeft } from 'solid-icons/fi'; +import ROB2Reconciliation from './ROB2Reconciliation.jsx'; +import ROB2Navbar from './ROB2Navbar.jsx'; +import SplitScreenLayout from '@/components/checklist/SplitScreenLayout.jsx'; + +const EmbedPdfViewer = lazy(() => import('@pdf/embedpdf/EmbedPdfViewer.jsx')); + +/** + * ROB2ReconciliationWithPdf - Wrapper that combines ROB2Reconciliation with a PDF viewer + * in a split-screen layout. The PDF is read-only during reconciliation. + * + * @param {Object} props + * @param {Object} props.checklist1 - First reviewer's checklist data + * @param {Object} props.checklist2 - Second reviewer's checklist data + * @param {Object} props.reconciledChecklist - The reconciled checklist data + * @param {string} props.reconciledChecklistId - ID of the reconciled checklist + * @param {Function} props.onSaveReconciled - Callback when reconciled checklist is saved + * @param {Function} props.onCancel - Callback to cancel and go back + * @param {string} props.reviewer1Name - Display name for first reviewer + * @param {string} props.reviewer2Name - Display name for second reviewer + * @param {ArrayBuffer} props.pdfData - ArrayBuffer of the study PDF (optional) + * @param {string} props.pdfFileName - Name of the PDF file (optional) + * @param {string} props.pdfUrl - URL for opening PDF in new tab + * @param {boolean} props.pdfLoading - Whether PDF is still loading + * @param {Array} props.pdfs - Array of PDFs for multi-PDF selection + * @param {string} props.selectedPdfId - Currently selected PDF ID + * @param {Function} props.onPdfSelect - Handler for PDF selection change + * @param {Function} props.updateChecklistAnswer - Function to update answer + * @param {Function} props.getRob2Text - Function to get Y.Text for comments (domainKey, questionKey) => Y.Text + * @returns {JSX.Element} + */ +export default function ROB2ReconciliationWithPdf(props) { + // Navbar store for deep reactivity - ROB2Reconciliation will update this + const [navbarStore, setNavbarStore] = createStore({ + navItems: [], + viewMode: 'questions', + currentPage: 0, + comparison: null, + finalAnswers: {}, + aimMismatch: false, + expandedDomain: null, + setViewMode: null, + goToPage: null, + setExpandedDomain: null, + onReset: null, + }); + + // Check if we have PDF to show (reactive) + const hasPdf = createMemo(() => !!(props.pdfData || props.pdfLoading)); + + // Build header content with back button, title, and navbar + const headerContent = ( + <> + {/* Back button */} + + + {/* Title */} +
+

ROB-2 Reconciliation

+

+ {props.reviewer1Name || 'Reviewer 1'} vs {props.reviewer2Name || 'Reviewer 2'} +

+
+ +
+ + {/* Navbar - navigation pills */} + 0}> +
+ +
+
+ + ); + + return ( +
+ + {/* First panel: Reconciliation view */} + + + {/* Second panel: PDF Viewer (read-only) - only rendered when PDF exists */} + + +
+
+ Loading PDF... +
+
+ } + > + +
+
+ } + > + +
+
+
+
+
+ ); +} diff --git a/packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2SummaryView.jsx b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2SummaryView.jsx new file mode 100644 index 000000000..99ca0e0f8 --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2SummaryView.jsx @@ -0,0 +1,253 @@ +/** + * ROB2SummaryView - Summary view showing all items before final save + */ + +import { Show, For, createMemo } from 'solid-js'; +import { FiCheck, FiX, FiChevronRight, FiSave, FiArrowLeft } from 'solid-icons/fi'; +import { + hasNavItemAnswer, + isNavItemAgreement, + getGroupedNavigationItems, + NAV_ITEM_TYPES, +} from './navbar-utils.js'; + +/** + * Get display value for a preliminary field + */ +function getPreliminaryDisplayValue(key, finalAnswers) { + const value = finalAnswers?.preliminary?.[key]; + if (value === null || value === undefined || value === '') return 'Not set'; + + if (key === 'deviationsToAddress' && Array.isArray(value)) { + return value.length > 0 ? `${value.length} selected` : 'None selected'; + } + if (key === 'sources' && typeof value === 'object') { + const count = Object.values(value || {}).filter(Boolean).length; + return count > 0 ? `${count} selected` : 'None selected'; + } + if (typeof value === 'string' && value.length > 50) { + return value.substring(0, 50) + '...'; + } + return value; +} + +/** + * Get display value for a domain question + */ +function getDomainQuestionDisplayValue(domainKey, questionKey, finalAnswers) { + const answer = finalAnswers?.[domainKey]?.answers?.[questionKey]?.answer; + return answer || 'Not set'; +} + +/** + * Get display value for a direction + */ +function getDirectionDisplayValue(domainKey, finalAnswers) { + if (domainKey === 'overall') { + return finalAnswers?.overall?.direction || 'Not set'; + } + return finalAnswers?.[domainKey]?.direction || 'Not set'; +} + +/** + * ROB2SummaryView - Summary view showing all items before final save + * + * @param {Object} props + * @param {Array} props.navItems - All navigation items + * @param {Object} props.finalAnswers - The reconciled checklist data + * @param {Object} props.comparison - The comparison result + * @param {Object} props.summary - Summary stats { total, agreed, disagreed, agreementPercentage, answered } + * @param {Function} props.onGoToPage - Navigate to a specific page + * @param {Function} props.onSave - Save the reconciled checklist + * @param {Function} props.onBack - Go back to questions view + * @param {boolean} props.allAnswered - Whether all items have been answered + * @param {boolean} props.saving - Whether save is in progress + * @returns {JSX.Element} + */ +export default function ROB2SummaryView(props) { + const groups = createMemo(() => getGroupedNavigationItems(props.navItems)); + + // Get global index for a nav item + const getItemIndex = item => props.navItems.indexOf(item); + + // Get display value for any nav item + const getDisplayValue = item => { + if (item.type === NAV_ITEM_TYPES.PRELIMINARY) { + return getPreliminaryDisplayValue(item.key, props.finalAnswers); + } + if (item.type === NAV_ITEM_TYPES.DOMAIN_QUESTION) { + return getDomainQuestionDisplayValue(item.domainKey, item.key, props.finalAnswers); + } + if (item.type === NAV_ITEM_TYPES.DOMAIN_DIRECTION) { + return getDirectionDisplayValue(item.domainKey, props.finalAnswers); + } + if (item.type === NAV_ITEM_TYPES.OVERALL_DIRECTION) { + return getDirectionDisplayValue('overall', props.finalAnswers); + } + return 'N/A'; + }; + + return ( +
+ {/* Header */} +
+

Reconciliation Summary

+

+ Review all reconciled items before saving. Click any item to edit. +

+
+ + {/* Stats Grid */} +
+
+
{props.summary?.total || 0}
+
Total Items
+
+
+
{props.summary?.agreed || 0}
+
Agreements
+
+
+
{props.summary?.disagreed || 0}
+
Disagreements
+
+
+
+ {props.summary?.agreementPercentage || 0}% +
+
Agreement Rate
+
+
+ + {/* Grouped Items List */} +
+ + {group => ( +
+ {/* Section Header */} +
+

{group.section}

+
+ + {/* Section Items */} +
+ + {item => { + const hasAnswer = () => hasNavItemAnswer(item, props.finalAnswers); + const isAgreement = () => isNavItemAgreement(item, props.comparison); + const displayValue = () => getDisplayValue(item); + + return ( + + ); + }} + +
+
+ )} +
+
+ + {/* Action Buttons */} +
+ + +
+
+ {props.summary?.answered || 0} of {props.summary?.total || 0} items reconciled +
+ + +
+
+
+ ); +} diff --git a/packages/web/src/components/project/reconcile-tab/rob2-reconcile/index.js b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/index.js new file mode 100644 index 000000000..f8577aff2 --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/index.js @@ -0,0 +1,19 @@ +// ROB-2 Reconciliation Components +export { default as ROB2Reconciliation } from './ROB2Reconciliation.jsx'; +export { default as ROB2ReconciliationWithPdf } from './ROB2ReconciliationWithPdf.jsx'; +export { default as ROB2Navbar } from './ROB2Navbar.jsx'; +export { default as ROB2SummaryView } from './ROB2SummaryView.jsx'; + +// Pages +export { default as PreliminaryPage } from './pages/PreliminaryPage.jsx'; +export { default as SignallingQuestionPage } from './pages/SignallingQuestionPage.jsx'; +export { default as DomainDirectionPage } from './pages/DomainDirectionPage.jsx'; +export { default as OverallDirectionPage } from './pages/OverallDirectionPage.jsx'; + +// Panels +export { default as ROB2AnswerPanel } from './panels/ROB2AnswerPanel.jsx'; +export { default as JudgementPanel } from './panels/JudgementPanel.jsx'; +export { default as DirectionPanel } from './panels/DirectionPanel.jsx'; + +// Utils +export * from './navbar-utils.js'; diff --git a/packages/web/src/components/project/reconcile-tab/rob2-reconcile/navbar-utils.js b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/navbar-utils.js new file mode 100644 index 000000000..f48754a9d --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/navbar-utils.js @@ -0,0 +1,404 @@ +/** + * Utility functions for ROB-2 Reconciliation Navbar + * Handles navigation item building, state calculations and styling + */ +import { + ROB2_CHECKLIST, + PRELIMINARY_SECTION, + getActiveDomainKeys, +} from '@corates/shared/checklists/rob2'; + +/** + * Navigation item types + */ +export const NAV_ITEM_TYPES = { + PRELIMINARY: 'preliminary', + DOMAIN_QUESTION: 'domainQuestion', + DOMAIN_DIRECTION: 'domainDirection', + OVERALL_DIRECTION: 'overallDirection', +}; + +/** + * Preliminary field keys in order + */ +const PRELIMINARY_FIELD_KEYS = [ + 'studyDesign', + 'experimental', + 'comparator', + 'numericalResult', + 'aim', + 'deviationsToAddress', + 'sources', +]; + +/** + * Build the full navigation items array based on aim selection + * @param {boolean} isAdhering - Whether using adhering aim (per-protocol) or assignment aim (ITT) + * @returns {Array} Array of navigation items + */ +export function buildNavigationItems(isAdhering) { + const items = []; + + // Preliminary section fields + for (const key of PRELIMINARY_FIELD_KEYS) { + const fieldDef = PRELIMINARY_SECTION[key]; + items.push({ + type: NAV_ITEM_TYPES.PRELIMINARY, + key, + label: fieldDef?.label || key, + section: 'Preliminary', + fieldDef, + }); + } + + // Active domains based on aim type + const activeDomains = getActiveDomainKeys(isAdhering); + + for (const domainKey of activeDomains) { + const domain = ROB2_CHECKLIST[domainKey]; + if (!domain) continue; + + const questions = domain.questions || {}; + const questionKeys = Object.keys(questions); + + // Add each question in the domain + for (const qKey of questionKeys) { + const q = questions[qKey]; + items.push({ + type: NAV_ITEM_TYPES.DOMAIN_QUESTION, + key: qKey, + domainKey, + label: q.number || qKey, + section: domain.name, + questionDef: q, + }); + } + + // Add domain direction item (judgement is auto-calculated, direction is manual) + if (domain.hasDirection) { + items.push({ + type: NAV_ITEM_TYPES.DOMAIN_DIRECTION, + key: `${domainKey}_direction`, + domainKey, + label: `D${domainKey.replace('domain', '').replace('a', 'A').replace('b', 'B')} Direction`, + section: domain.name, + isDirection: true, + }); + } + } + + // Overall direction + items.push({ + type: NAV_ITEM_TYPES.OVERALL_DIRECTION, + key: 'overall_direction', + label: 'Overall', + section: 'Overall', + isDirection: true, + }); + + return items; +} + +/** + * Get grouped navigation items for display (grouped by section) + * @param {Array} navItems - Array of navigation items + * @returns {Array} Array of groups with section name and items + */ +export function getGroupedNavigationItems(navItems) { + const groups = []; + let currentGroup = null; + + for (const item of navItems) { + if (!currentGroup || currentGroup.section !== item.section) { + currentGroup = { section: item.section, items: [] }; + groups.push(currentGroup); + } + currentGroup.items.push(item); + } + + return groups; +} + +/** + * Check if a preliminary field has been answered in the final answers + * @param {string} key - The field key + * @param {Object} finalAnswers - The reconciled checklist data + * @returns {boolean} + */ +export function hasPreliminaryAnswer(key, finalAnswers) { + const value = finalAnswers?.preliminary?.[key]; + if (key === 'deviationsToAddress') { + return Array.isArray(value) && value.length > 0; + } + if (key === 'sources') { + return typeof value === 'object' && Object.values(value || {}).some(v => v); + } + return value != null && value !== ''; +} + +/** + * Check if a domain question has been answered in the final answers + * @param {string} domainKey - The domain key (domain1, domain2a, etc.) + * @param {string} questionKey - The question key (d1_1, d2a_1, etc.) + * @param {Object} finalAnswers - The reconciled checklist data + * @returns {boolean} + */ +export function hasDomainQuestionAnswer(domainKey, questionKey, finalAnswers) { + return finalAnswers?.[domainKey]?.answers?.[questionKey]?.answer != null; +} + +/** + * Check if a domain direction has been set + * @param {string} domainKey - The domain key + * @param {Object} finalAnswers - The reconciled checklist data + * @returns {boolean} + */ +export function hasDomainDirection(domainKey, finalAnswers) { + return finalAnswers?.[domainKey]?.direction != null; +} + +/** + * Check if overall direction has been set + * @param {Object} finalAnswers - The reconciled checklist data + * @returns {boolean} + */ +export function hasOverallDirection(finalAnswers) { + return finalAnswers?.overall?.direction != null; +} + +/** + * Check if a navigation item has been answered/completed + * @param {Object} navItem - The navigation item + * @param {Object} finalAnswers - The reconciled checklist data + * @returns {boolean} + */ +export function hasNavItemAnswer(navItem, finalAnswers) { + switch (navItem.type) { + case NAV_ITEM_TYPES.PRELIMINARY: + return hasPreliminaryAnswer(navItem.key, finalAnswers); + case NAV_ITEM_TYPES.DOMAIN_QUESTION: + return hasDomainQuestionAnswer(navItem.domainKey, navItem.key, finalAnswers); + case NAV_ITEM_TYPES.DOMAIN_DIRECTION: + return hasDomainDirection(navItem.domainKey, finalAnswers); + case NAV_ITEM_TYPES.OVERALL_DIRECTION: + return hasOverallDirection(finalAnswers); + default: + return false; + } +} + +/** + * Check if reviewers agreed on a navigation item + * @param {Object} navItem - The navigation item + * @param {Object} comparison - The comparison result from compareChecklists + * @returns {boolean} + */ +export function isNavItemAgreement(navItem, comparison) { + if (!comparison) return false; + + switch (navItem.type) { + case NAV_ITEM_TYPES.PRELIMINARY: { + const field = comparison.preliminary?.fields?.find(f => f.key === navItem.key); + return field?.isAgreement ?? false; + } + case NAV_ITEM_TYPES.DOMAIN_QUESTION: { + const domain = comparison.domains?.[navItem.domainKey]; + if (!domain) return false; + const found = domain.questions?.agreements?.find(a => a.key === navItem.key); + return !!found; + } + case NAV_ITEM_TYPES.DOMAIN_DIRECTION: { + const domain = comparison.domains?.[navItem.domainKey]; + return domain?.directionMatch ?? false; + } + case NAV_ITEM_TYPES.OVERALL_DIRECTION: { + return comparison.overall?.directionMatch ?? false; + } + default: + return false; + } +} + +/** + * Get pill styling classes based on item state + * @param {boolean} isCurrentPage - Is this the active page + * @param {boolean} hasAnswer - Has this item been answered + * @param {boolean} isAgreement - Do reviewers agree on this item + * @returns {string} Tailwind CSS classes + */ +export function getNavItemPillStyle(isCurrentPage, hasAnswer, isAgreement) { + if (isCurrentPage) { + return 'bg-blue-600 text-white ring-2 ring-inset ring-blue-300'; + } + // Use lighter colors - checkmark icon indicates if answered + return isAgreement + ? 'bg-green-100 text-green-700 hover:bg-green-200' + : 'bg-amber-100 text-amber-700 hover:bg-amber-200'; +} + +/** + * Generate descriptive tooltip for a navigation pill + * @param {Object} navItem - The navigation item + * @param {boolean} hasAnswer - Has this item been answered + * @param {boolean} isAgreement - Do reviewers agree on this item + * @returns {string} Tooltip text + */ +export function getNavItemTooltip(navItem, hasAnswer, isAgreement) { + const label = navItem.label; + + if (hasAnswer) { + return `${label} - Reconciled`; + } + if (isAgreement) { + return `${label} - Reviewers agreed`; + } + return `${label} - Reviewers disagree`; +} + +/** + * Get the count of answered items + * @param {Array} navItems - Array of navigation items + * @param {Object} finalAnswers - The reconciled checklist data + * @returns {number} + */ +export function getAnsweredCount(navItems, finalAnswers) { + return navItems.filter(item => hasNavItemAnswer(item, finalAnswers)).length; +} + +/** + * Get domain display number from domain key + * @param {string} domainKey - The domain key (domain1, domain2a, etc.) + * @returns {string} Display number (1, 2A, 2B, 3, etc.) + */ +export function getDomainDisplayNumber(domainKey) { + return domainKey.replace('domain', '').replace('a', 'A').replace('b', 'B'); +} + +/** + * Get section key from section name (for grouping) + * @param {string} sectionName - The section name from nav item + * @returns {string} Section key (preliminary, domain1, etc.) + */ +export function getSectionKey(sectionName) { + if (sectionName === 'Preliminary') return 'preliminary'; + if (sectionName === 'Overall') return 'overall'; + + // Domain names like "Domain 1: Bias arising from the randomization process" + const domainMatch = sectionName.match(/Domain (\d+)/); + if (domainMatch) { + const domainNum = domainMatch[1]; + if (domainNum === '2') { + // Could be 2a or 2b - handled by first item in group + return 'domain2'; + } + return `domain${domainNum}`; + } + + return sectionName; +} + +/** + * Get abbreviated label for a section/domain pill + * @param {string} sectionKey - The section key + * @returns {string} Short label (P, D1, D2, etc.) + */ +export function getSectionLabel(sectionKey) { + if (sectionKey === 'preliminary') return 'P'; + if (sectionKey === 'overall') return 'OA'; + if (sectionKey.startsWith('domain')) { + const num = sectionKey.replace('domain', '').replace('a', 'A').replace('b', 'B'); + return `D${num}`; + } + return sectionKey; +} + +/** + * Get progress stats for each section/domain + * @param {Array} navItems - All navigation items + * @param {Object} finalAnswers - Reconciled checklist data + * @param {Object} comparison - Comparison results + * @returns {Object} Progress by section key + */ +export function getDomainProgress(navItems, finalAnswers, comparison) { + const progress = {}; + const groups = getGroupedNavigationItems(navItems); + + for (const group of groups) { + // Get the actual domain key from the first item in the group + const firstItem = group.items[0]; + let sectionKey; + + if (firstItem.type === NAV_ITEM_TYPES.PRELIMINARY) { + sectionKey = 'preliminary'; + } else if (firstItem.type === NAV_ITEM_TYPES.OVERALL_DIRECTION) { + sectionKey = 'overall'; + } else if (firstItem.domainKey) { + sectionKey = firstItem.domainKey; + } else { + sectionKey = getSectionKey(group.section); + } + + let answered = 0; + let hasDisagreements = false; + + for (const item of group.items) { + if (hasNavItemAnswer(item, finalAnswers)) { + answered++; + } + if (!isNavItemAgreement(item, comparison)) { + hasDisagreements = true; + } + } + + progress[sectionKey] = { + answered, + total: group.items.length, + hasDisagreements, + isComplete: answered === group.items.length, + section: group.section, + items: group.items, + }; + } + + return progress; +} + +/** + * Get the domain key that contains a specific nav item index + * @param {Array} navItems - All navigation items + * @param {number} pageIndex - The page index + * @returns {string|null} The section key or null + */ +export function getSectionKeyForPage(navItems, pageIndex) { + const item = navItems[pageIndex]; + if (!item) return null; + + if (item.type === NAV_ITEM_TYPES.PRELIMINARY) { + return 'preliminary'; + } + if (item.type === NAV_ITEM_TYPES.OVERALL_DIRECTION) { + return 'overall'; + } + if (item.domainKey) { + return item.domainKey; + } + + return null; +} + +/** + * Find the first unanswered item index within a section + * @param {Object} sectionProgress - Progress object for the section + * @param {Array} navItems - All navigation items + * @param {Object} finalAnswers - Reconciled checklist data + * @returns {number} Global index of first unanswered item, or first item if all answered + */ +export function getFirstUnansweredInSection(sectionProgress, navItems, finalAnswers) { + for (const item of sectionProgress.items) { + if (!hasNavItemAnswer(item, finalAnswers)) { + return navItems.indexOf(item); + } + } + // All answered - return first item in section + return navItems.indexOf(sectionProgress.items[0]); +} diff --git a/packages/web/src/components/project/reconcile-tab/rob2-reconcile/pages/DomainDirectionPage.jsx b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/pages/DomainDirectionPage.jsx new file mode 100644 index 000000000..d1e78322d --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/pages/DomainDirectionPage.jsx @@ -0,0 +1,154 @@ +import { Show, createMemo } from 'solid-js'; +import { FiCheck, FiX, FiInfo } from 'solid-icons/fi'; +import { ROB2_CHECKLIST, scoreRob2Domain } from '@corates/shared/checklists/rob2'; +import JudgementPanel from '../panels/JudgementPanel.jsx'; +import DirectionPanel from '../panels/DirectionPanel.jsx'; + +/** + * Page for reconciling domain direction and viewing auto-calculated judgement + * + * @param {Object} props + * @param {string} props.domainKey - The domain key (domain1, domain2a, etc.) + * @param {Object} props.reviewer1Answers - Reviewer 1's domain answers (for auto-calculation) + * @param {Object} props.reviewer2Answers - Reviewer 2's domain answers (for auto-calculation) + * @param {Object} props.finalAnswers - The final reconciled domain answers (for auto-calculation) + * @param {string} props.reviewer1Direction - Reviewer 1's direction selection + * @param {string} props.reviewer2Direction - Reviewer 2's direction selection + * @param {string} props.finalDirection - The final reconciled direction + * @param {string} props.reviewer1Name - Display name for reviewer 1 + * @param {string} props.reviewer2Name - Display name for reviewer 2 + * @param {boolean} props.directionMatch - Whether reviewers agree on direction + * @param {Function} props.onFinalDirectionChange - Callback when final direction changes + * @param {Function} props.onUseReviewer1 - Callback to use reviewer 1's direction + * @param {Function} props.onUseReviewer2 - Callback to use reviewer 2's direction + * @returns {JSX.Element} + */ +export default function DomainDirectionPage(props) { + // Get domain definition + const domain = createMemo(() => ROB2_CHECKLIST[props.domainKey]); + + // Calculate auto-judgements + const reviewer1Scoring = createMemo(() => scoreRob2Domain(props.domainKey, props.reviewer1Answers)); + const reviewer2Scoring = createMemo(() => scoreRob2Domain(props.domainKey, props.reviewer2Answers)); + const finalScoring = createMemo(() => scoreRob2Domain(props.domainKey, props.finalAnswers)); + + // Check if judgements match + const judgementMatch = createMemo( + () => reviewer1Scoring().judgement === reviewer2Scoring().judgement, + ); + + return ( +
+ {/* Header */} +
+
+ + +
+ } + > +
+ +
+ +
+

+ {domain()?.name} - Judgement & Direction +

+

Review the calculated judgement and select the bias direction

+
+
+
+ + {/* Auto-calculated Judgement Section */} +
+
+ +

+ The risk of bias judgement is automatically calculated from the signalling question + answers. To change it, reconcile the signalling questions above. +

+
+ +

Auto-calculated Judgement

+ +
+ + + +
+
+ + {/* Direction Section */} +
+
+

Predicted Direction of Bias

+ + Disagree + + } + > + + Agree + + +
+ +
+ + + +
+
+
+ ); +} diff --git a/packages/web/src/components/project/reconcile-tab/rob2-reconcile/pages/OverallDirectionPage.jsx b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/pages/OverallDirectionPage.jsx new file mode 100644 index 000000000..c471e345b --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/pages/OverallDirectionPage.jsx @@ -0,0 +1,154 @@ +import { Show, createMemo } from 'solid-js'; +import { FiCheck, FiX, FiInfo } from 'solid-icons/fi'; +import { scoreAllDomains } from '@corates/shared/checklists/rob2'; +import JudgementPanel from '../panels/JudgementPanel.jsx'; +import DirectionPanel from '../panels/DirectionPanel.jsx'; + +/** + * Page for reconciling overall direction and viewing auto-calculated overall judgement + * + * @param {Object} props + * @param {Object} props.checklist1 - Complete reviewer 1 checklist (for overall scoring) + * @param {Object} props.checklist2 - Complete reviewer 2 checklist (for overall scoring) + * @param {Object} props.finalChecklist - Complete final reconciled checklist (for overall scoring) + * @param {string} props.reviewer1Direction - Reviewer 1's overall direction selection + * @param {string} props.reviewer2Direction - Reviewer 2's overall direction selection + * @param {string} props.finalDirection - The final reconciled overall direction + * @param {string} props.reviewer1Name - Display name for reviewer 1 + * @param {string} props.reviewer2Name - Display name for reviewer 2 + * @param {boolean} props.directionMatch - Whether reviewers agree on direction + * @param {Function} props.onFinalDirectionChange - Callback when final direction changes + * @param {Function} props.onUseReviewer1 - Callback to use reviewer 1's direction + * @param {Function} props.onUseReviewer2 - Callback to use reviewer 2's direction + * @returns {JSX.Element} + */ +export default function OverallDirectionPage(props) { + // Calculate auto-judgements + const reviewer1Scoring = createMemo(() => scoreAllDomains(props.checklist1)); + const reviewer2Scoring = createMemo(() => scoreAllDomains(props.checklist2)); + const finalScoring = createMemo(() => scoreAllDomains(props.finalChecklist)); + + // Check if judgements match + const judgementMatch = createMemo( + () => reviewer1Scoring().overall === reviewer2Scoring().overall, + ); + + return ( +
+ {/* Header */} +
+
+ + +
+ } + > +
+ +
+ +
+

Overall Risk of Bias - Judgement & Direction

+

+ Review the overall calculated judgement and select the overall bias direction +

+
+
+
+ + {/* Auto-calculated Overall Judgement Section */} +
+
+ +
+

+ The overall risk of bias judgement is automatically calculated from the domain + judgements: +

+
    +
  • If any domain is High, overall is High
  • +
  • Otherwise, if any domain has Some concerns, overall is Some concerns
  • +
  • Otherwise, overall is Low
  • +
+
+
+ +

Auto-calculated Overall Judgement

+ +
+ + + +
+
+ + {/* Direction Section */} +
+
+

Predicted Overall Direction of Bias

+ + Disagree + + } + > + + Agree + + +
+ +
+ + + +
+
+ + ); +} diff --git a/packages/web/src/components/project/reconcile-tab/rob2-reconcile/pages/PreliminaryPage.jsx b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/pages/PreliminaryPage.jsx new file mode 100644 index 000000000..165fae2da --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/pages/PreliminaryPage.jsx @@ -0,0 +1,393 @@ +import { Show, For, createMemo } from 'solid-js'; +import { FiCheck, FiX, FiAlertTriangle } from 'solid-icons/fi'; +import { + PRELIMINARY_SECTION, + STUDY_DESIGNS, + AIM_OPTIONS, + DEVIATION_OPTIONS, + INFORMATION_SOURCES, +} from '@corates/shared/checklists/rob2'; + +/** + * Get panel background based on type + * @param {string} panelType - 'reviewer1', 'reviewer2', or 'final' + * @returns {string} Tailwind CSS classes + */ +function getPanelBackground(panelType) { + switch (panelType) { + case 'reviewer1': + return 'bg-blue-50/30'; + case 'reviewer2': + return 'bg-purple-50/30'; + case 'final': + return 'bg-green-50/30'; + default: + return ''; + } +} + +/** + * Render a text field (display or edit) + */ +function TextField(props) { + return ( + +

+ {props.value || Not specified} +

+ + } + > +