diff --git a/packages/shared/src/checklists/rob2/compare.ts b/packages/shared/src/checklists/rob2/compare.ts new file mode 100644 index 000000000..e92f0a897 --- /dev/null +++ b/packages/shared/src/checklists/rob2/compare.ts @@ -0,0 +1,466 @@ +/** + * 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; + + // Only count as agreement when both answers are present and equal + const isAgreement = ans1 != null && ans2 != null && ans1 === ans2; + + const comparison: QuestionComparison = { + key: qKey, + isAgreement, + 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, + ) + } + /> + } > } > -
+
{/* Panel Header */}
diff --git a/packages/web/src/components/project/reconcile-tab/rob2-reconcile/NavbarDomainPill.jsx b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/NavbarDomainPill.jsx new file mode 100644 index 000000000..8339daaf7 --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/NavbarDomainPill.jsx @@ -0,0 +1,203 @@ +import { For, createMemo, Show } from 'solid-js'; +import { FiChevronDown, FiChevronRight, FiCheck } from 'solid-icons/fi'; +import { Tooltip, CollapsiblePrimitive as Collapsible } from '@corates/ui'; +import { + getSectionLabel, + hasNavItemAnswer, + isNavItemAgreement, + getNavItemPillStyle, + NAV_ITEM_TYPES, +} from './navbar-utils.js'; + +/** + * Domain pill that expands inline to show question pills + * When collapsed: shows label + progress (e.g., "D1 2/5") + * When expanded: shows label + question pills inline (e.g., "D1 [1][2][3][4][5]") + * + * @param {Object} props + * @param {string} props.sectionKey - Section key (e.g., 'domain1', 'preliminary', 'overall') + * @param {Object} props.progress - Progress object with answered, total, hasDisagreements, isComplete, items + * @param {boolean} props.isExpanded - Whether this domain is currently expanded + * @param {boolean} props.isCurrentDomain - Whether current page is in this domain + * @param {Function} props.onClick - Click handler for the label/collapse button + * @param {Array} props.allNavItems - All navigation items (for global index lookup) + * @param {number} props.currentPage - Current page index + * @param {Function} props.goToPage - Navigate to page function + * @param {Object} props.comparison - Comparison results + * @param {Object} props.finalAnswers - Reconciled checklist data + */ +export default function NavbarDomainPill(props) { + const label = () => getSectionLabel(props.sectionKey); + + // Container style - wraps everything in one connected pill + const containerStyle = createMemo(() => { + let base = 'flex items-center rounded-md transition-all bg-gray-100 overflow-visible '; + + // Subtle ring for current domain only (when collapsed) + if (!props.isExpanded && props.isCurrentDomain) { + base += 'ring-2 ring-blue-300 '; + } + + return base; + }); + + // Label button style + const labelStyle = createMemo(() => { + let base = + 'flex items-center gap-1 rounded-md px-2 py-2 text-xs font-medium cursor-pointer select-none transition-all text-gray-700 '; + + if (props.isExpanded) { + // When expanded, label is slightly darker to stand out + base += 'bg-gray-200 hover:bg-gray-300 '; + } else { + base += 'hover:bg-gray-200 '; + } + + return base; + }); + + const tooltipContent = createMemo(() => { + const section = props.progress?.section || props.sectionKey; + const answered = props.progress?.answered || 0; + const total = props.progress?.total || 0; + + if (answered === total && total > 0) { + return `${section}: Complete (${total}/${total})`; + } + return `${section}: ${answered}/${total}`; + }); + + return ( + + {/* Label/collapse button */} + + + + + {/* Animated expanded question pills */} + + + {(item, idx) => { + const globalIndex = () => props.allNavItems?.indexOf(item) ?? -1; + const itemCount = () => props.progress?.items?.length || 0; + const isFirst = () => idx() === 0; + const isLast = () => idx() === itemCount() - 1; + return ( + + ); + }} + + + + ); +} + +/** + * Individual question pill within expanded domain + */ +function QuestionPill(props) { + const isCurrentPage = () => props.currentPage === props.globalIndex; + const isAgreement = () => isNavItemAgreement(props.item, props.comparison); + const hasAnswer = () => hasNavItemAnswer(props.item, props.finalAnswers); + + const pillStyle = createMemo(() => + getNavItemPillStyle(isCurrentPage(), hasAnswer(), isAgreement()), + ); + + const tooltip = createMemo(() => { + const item = props.item; + const answered = hasAnswer(); + const agreement = isAgreement(); + + let status = ''; + if (answered) { + status = 'Reconciled'; + } else if (agreement) { + status = 'Agreement (not yet confirmed)'; + } else { + status = 'Needs reconciliation'; + } + + return `${item.label}: ${status}`; + }); + + const displayLabel = () => { + const item = props.item; + if (item.type === NAV_ITEM_TYPES.PRELIMINARY) { + // Show first 2 chars of key (e.g., "St" for studyDesign, "Ai" for aim) + return item.key?.substring(0, 2) || '?'; + } + if (item.type === NAV_ITEM_TYPES.DOMAIN_QUESTION) { + // Extract question number like "1.1" -> "1" + const parts = item.label?.split('.') || []; + return parts.length > 1 ? parts[1] : item.label; + } + if ( + item.type === NAV_ITEM_TYPES.DOMAIN_DIRECTION || + item.type === NAV_ITEM_TYPES.OVERALL_DIRECTION + ) { + return 'D'; + } + return item.label || '?'; + }; + + const isDirection = () => + props.item.type === NAV_ITEM_TYPES.DOMAIN_DIRECTION || + props.item.type === NAV_ITEM_TYPES.OVERALL_DIRECTION; + + const pillSizeClass = () => (isDirection() ? 'h-6 px-2 text-2xs' : 'h-6 w-6 text-2xs'); + + const pillSpacingClass = () => { + let spacing = ''; + if (props.isFirst) spacing += 'ml-0.5 '; + if (props.isLast) spacing += 'mr-0.5 '; + if (!props.isFirst && !props.isLast) spacing += 'mx-0.5 '; + if (props.isFirst && !props.isLast) spacing += 'mr-0.5 '; + if (!props.isFirst && props.isLast) spacing += 'ml-0.5 '; + return spacing; + }; + + return ( + + + + ); +} diff --git a/packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2Navbar.jsx b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2Navbar.jsx new file mode 100644 index 000000000..d4813295f --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2Navbar.jsx @@ -0,0 +1,182 @@ +/** + * ROB2Navbar - Domain-grouped, expandable navbar for ROB-2 reconciliation + * Uses animated collapsible pills similar to ROBINS-I + */ + +import { For, createMemo, Show } from 'solid-js'; +import { FaSolidArrowRotateLeft } from 'solid-icons/fa'; +import { FiAlertTriangle } from 'solid-icons/fi'; +import { Tooltip } from '@corates/ui'; +import NavbarDomainPill from './NavbarDomainPill.jsx'; +import { + getDomainProgress, + getSectionKeyForPage, + getFirstUnansweredInSection, +} from './navbar-utils.js'; + +/** + * ROB2Navbar - Navigation bar for ROB-2 reconciliation + * Uses expandable domain pills with accordion behavior (only one expanded at a time) + * + * @param {Object} props + * @param {Object} props.store - Navbar store with: + * - navItems: array of navigation items + * - viewMode: 'questions' or 'summary' + * - currentPage: current nav item index + * - comparison: comparison result + * - finalAnswers: reconciled checklist data + * - aimMismatch: whether there's an aim mismatch + * - expandedDomain: which domain is currently expanded + * - setViewMode: function to change view mode + * - goToPage: function to go to a specific page index + * - setExpandedDomain: function to set which domain is expanded + * - onReset: function to reset all reconciliation answers + * @returns {JSX.Element} + */ +export default function ROB2Navbar(props) { + // Calculate progress for all domains/sections + const domainProgress = createMemo(() => + getDomainProgress(props.store.navItems || [], props.store.finalAnswers, props.store.comparison), + ); + + // Get ordered section keys from progress + const sectionKeys = createMemo(() => Object.keys(domainProgress())); + + // Current page's section + const currentSectionKey = createMemo(() => + getSectionKeyForPage(props.store.navItems || [], props.store.currentPage), + ); + + // Handle domain pill click - toggle expand or navigate + function handleDomainClick(sectionKey) { + const progress = domainProgress()[sectionKey]; + const isCurrentlyExpanded = props.store.expandedDomain === sectionKey; + + // If already expanded, do nothing (don't re-trigger animation) + if (isCurrentlyExpanded) { + return; + } + + // Expand and navigate to first unanswered (or first item if all complete) + props.store.setExpandedDomain?.(sectionKey); + + // Navigate to first unanswered item in this section + if (progress && props.store.navItems) { + const targetIndex = getFirstUnansweredInSection( + progress, + props.store.navItems, + props.store.finalAnswers, + ); + if (targetIndex >= 0) { + props.store.goToPage?.(targetIndex); + } + } + } + + // Handle navigation to a specific page - auto-expand its domain + function handleGoToPage(pageIndex) { + const sectionKey = getSectionKeyForPage(props.store.navItems || [], pageIndex); + if (sectionKey && sectionKey !== props.store.expandedDomain) { + props.store.setExpandedDomain?.(sectionKey); + } + props.store.goToPage?.(pageIndex); + } + + return ( + + ); +} + +/** + * Summary view button + */ +function SummaryButton(props) { + const isActive = () => props.store.viewMode === 'summary'; + + const buttonStyle = createMemo(() => + isActive() ? + 'bg-blue-600 text-white ring-2 ring-blue-300' + : 'bg-gray-100 text-gray-600 hover:bg-gray-200', + ); + + return ( + + + + ); +} + +/** + * Reset button to clear all reconciliation answers + */ +function ResetButton(props) { + return ( + + + + ); +} 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..abfa7d60f --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/ROB2Reconciliation.jsx @@ -0,0 +1,639 @@ +/** + * 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; + + // Clamp currentPage when navItems change (e.g., after aim change) to prevent out-of-bounds index + createEffect(() => { + const items = navItems(); + const total = items.length; + if (total === 0) return; + + const page = currentPage(); + const clampedPage = Math.max(0, Math.min(page, total - 1)); + + if (clampedPage !== page) { + setCurrentPage(clampedPage); + } + + // Resync expanded domain to the (potentially clamped) page + const sectionKey = getSectionKeyForPage(items, clampedPage); + if (sectionKey && sectionKey !== expandedDomain()) { + setExpandedDomain(sectionKey); + } + }); + + // 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..adcdf8487 --- /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..1d123f38f --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/index.js @@ -0,0 +1,20 @@ +// 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'; +export { default as NavbarDomainPill } from './NavbarDomainPill.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..02449df08 --- /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..5af2761ed --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/pages/DomainDirectionPage.jsx @@ -0,0 +1,160 @@ +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..f8814b94b --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/pages/OverallDirectionPage.jsx @@ -0,0 +1,163 @@ +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..1930dfe52 --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/pages/PreliminaryPage.jsx @@ -0,0 +1,392 @@ +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} +

+
+ } + > +