diff --git a/.vscode/settings.json b/.vscode/settings.json index eccb27945..9c0220518 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,5 +39,5 @@ "tailwindCSS.experimental.configFile": "packages/web/src/global.css", "files.associations": { "*.css": "tailwindcss" - }, + } } diff --git a/packages/web/src/Routes.jsx b/packages/web/src/Routes.jsx index 5f2f5b8b9..bf5d5fe4a 100644 --- a/packages/web/src/Routes.jsx +++ b/packages/web/src/Routes.jsx @@ -20,6 +20,8 @@ import { BASEPATH } from '@config/api.js'; import ProtectedGuard from '@/components/auth/ProtectedGuard.jsx'; import ProjectView from '@/components/project/ProjectView.jsx'; import { CreateOrgPage } from '@/components/org/index.js'; +import MockIndex from '@/components/mock/MockIndex.jsx'; +import RobinsReconcileSectionBQuestionMock from '@/components/mock/RobinsReconcileSectionBQuestionMock.jsx'; export default function AppRoutes() { return ( @@ -68,6 +70,13 @@ export default function AppRoutes() { {/* Local checklists (not org-scoped, work offline) */} + + {/* Mock routes - public, visual-only wireframes */} + + diff --git a/packages/web/src/components/checklist/ChecklistWithPdf.jsx b/packages/web/src/components/checklist/ChecklistWithPdf.jsx index e1b24873d..323f4f3c7 100644 --- a/packages/web/src/components/checklist/ChecklistWithPdf.jsx +++ b/packages/web/src/components/checklist/ChecklistWithPdf.jsx @@ -24,6 +24,7 @@ export default function ChecklistWithPdf(props) { // props.selectedPdfId - currently selected PDF ID // props.onPdfSelect - handler for PDF selection change // props.getQuestionNote - function to get Y.Text for a question note + // props.getRobinsText - function to get Y.Text for a ROBINS-I free-text field // props.pdfUrl - optional PDF URL (for server-hosted PDFs) return ( @@ -44,6 +45,7 @@ export default function ChecklistWithPdf(props) { onUpdate={props.onUpdate} readOnly={props.readOnly} getQuestionNote={props.getQuestionNote} + getRobinsText={props.getRobinsText} /> {/* Second panel: PDF Viewer */} diff --git a/packages/web/src/components/checklist/ChecklistYjsWrapper.jsx b/packages/web/src/components/checklist/ChecklistYjsWrapper.jsx index 49d8535a2..a123baca1 100644 --- a/packages/web/src/components/checklist/ChecklistYjsWrapper.jsx +++ b/packages/web/src/components/checklist/ChecklistYjsWrapper.jsx @@ -40,6 +40,7 @@ export default function ChecklistYjsWrapper() { getChecklistData, addPdfToStudy, getQuestionNote, + getRobinsText, } = useProject(params.projectId); // Set active project for action store @@ -460,6 +461,9 @@ export default function ChecklistYjsWrapper() { getQuestionNote={questionKey => getQuestionNote(params.studyId, params.checklistId, questionKey) } + getRobinsText={(sectionKey, fieldKey, questionKey) => + getRobinsText(params.studyId, params.checklistId, sectionKey, fieldKey, questionKey) + } /> diff --git a/packages/web/src/components/checklist/GenericChecklist.jsx b/packages/web/src/components/checklist/GenericChecklist.jsx index 4eadaf958..e2d527474 100644 --- a/packages/web/src/components/checklist/GenericChecklist.jsx +++ b/packages/web/src/components/checklist/GenericChecklist.jsx @@ -25,6 +25,7 @@ import { ROBINSIChecklist } from '@/components/checklist/ROBINSIChecklist/index. * @param {Function} props.onUpdate - Callback for checklist updates * @param {boolean} [props.readOnly] - Whether the checklist is read-only * @param {Function} [props.getQuestionNote] - Function to get Y.Text for a question note + * @param {Function} [props.getRobinsText] - Function to get Y.Text for a ROBINS-I free-text field */ export default function GenericChecklist(props) { // Determine the checklist type from props or state @@ -55,6 +56,7 @@ export default function GenericChecklist(props) { showComments={true} showLegend={true} readOnly={props.readOnly} + getRobinsText={props.getRobinsText} /> diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/DomainJudgement.jsx b/packages/web/src/components/checklist/ROBINSIChecklist/DomainJudgement.jsx index 77e30c52b..9b9d7aa7d 100644 --- a/packages/web/src/components/checklist/ROBINSIChecklist/DomainJudgement.jsx +++ b/packages/web/src/components/checklist/ROBINSIChecklist/DomainJudgement.jsx @@ -1,8 +1,10 @@ -import { For } from 'solid-js'; +import { For, Show } from 'solid-js'; import { ROB_JUDGEMENTS, BIAS_DIRECTIONS, DOMAIN1_DIRECTIONS } from './checklist-map.js'; /** * Domain judgement selector with risk of bias level and optional direction + * Supports auto-first mode: in auto mode, buttons are visually secondary and clicking switches to manual + * * @param {Object} props * @param {string} props.domainId - Unique domain identifier * @param {string} props.judgement - Current judgement value @@ -12,11 +14,20 @@ import { ROB_JUDGEMENTS, BIAS_DIRECTIONS, DOMAIN1_DIRECTIONS } from './checklist * @param {boolean} [props.showDirection] - Whether to show direction selector * @param {boolean} [props.isDomain1] - Whether this is Domain 1 (uses limited direction options) * @param {boolean} [props.disabled] - Whether the selector is disabled + * @param {boolean} [props.isAutoMode] - Whether in auto mode (buttons are secondary, clicking switches to manual) */ export function DomainJudgement(props) { const directionOptions = () => (props.isDomain1 ? DOMAIN1_DIRECTIONS : BIAS_DIRECTIONS); - const getJudgementColor = judgement => { + const getJudgementColor = (judgement, isSelected) => { + if (!isSelected) { + // Unselected state - slightly dimmed in auto mode + return props.isAutoMode ? + 'border-gray-200 bg-gray-50 text-gray-500 hover:border-gray-300 hover:bg-white' + : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'; + } + + // Selected state switch (judgement) { case 'Low': return 'bg-green-100 border-green-400 text-green-800'; @@ -33,41 +44,42 @@ export function DomainJudgement(props) { } }; + // Shorten long judgement labels for display + const getShortLabel = judgement => { + if (judgement === 'Low (except for concerns about uncontrolled confounding)') { + return 'Low (except confounding)'; + } + return judgement; + }; + return ( -
- {/* Risk of bias judgement */} -
-
Risk of bias judgement
-
- - {judgement => { - const isSelected = () => props.judgement === judgement; - return ( - - ); - }} - -
+
+ {/* Risk of bias judgement buttons */} +
+ + {judgement => { + const isSelected = () => props.judgement === judgement; + return ( + + ); + }} +
{/* Direction of bias (optional) */} - {props.showDirection && ( -
+ +
Predicted direction of bias (optional) @@ -81,7 +93,6 @@ export function DomainJudgement(props) { type='button' onClick={() => { if (props.disabled) return; - // Toggle: deselect if already selected, otherwise select props.onDirectionChange?.(isSelected() ? null : direction); }} disabled={props.disabled} @@ -89,7 +100,7 @@ export function DomainJudgement(props) { isSelected() ? 'border-blue-400 bg-blue-100 text-blue-800' : 'border-gray-200 bg-white text-gray-500 hover:border-gray-300' - } `} + }`} > {direction} @@ -98,7 +109,7 @@ export function DomainJudgement(props) {
- )} +
); } @@ -125,9 +136,17 @@ export function JudgementBadge(props) { } }; + // Shorten long judgement labels for badges + const getShortLabel = () => { + if (props.judgement === 'Low (except for concerns about uncontrolled confounding)') { + return 'Low (except confounding)'; + } + return props.judgement || 'Not assessed'; + }; + return ( - {props.judgement || 'Not assessed'} + {getShortLabel()} ); } diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/DomainSection.jsx b/packages/web/src/components/checklist/ROBINSIChecklist/DomainSection.jsx index 72f32ce37..0c2928317 100644 --- a/packages/web/src/components/checklist/ROBINSIChecklist/DomainSection.jsx +++ b/packages/web/src/components/checklist/ROBINSIChecklist/DomainSection.jsx @@ -1,39 +1,70 @@ -import { For, Show } from 'solid-js'; +import { For, Show, createMemo } from 'solid-js'; import { ROBINS_I_CHECKLIST, getDomainQuestions } from './checklist-map.js'; import { SignallingQuestion } from './SignallingQuestion.jsx'; import { DomainJudgement, JudgementBadge } from './DomainJudgement.jsx'; +import { scoreRobinsDomain, getEffectiveDomainJudgement } from './scoring/robins-scoring.js'; /** * A complete domain section with questions and judgement + * Now with auto-first scoring: calculated judgements are primary, manual override is explicit + * * @param {Object} props * @param {string} props.domainKey - The domain key (e.g., 'domain1a') - * @param {Object} props.domainState - Current domain state { answers, judgement, direction } + * @param {Object} props.domainState - Current domain state { answers, judgement, judgementSource, direction } * @param {Function} props.onUpdate - Callback when domain state changes * @param {boolean} [props.disabled] - Whether the domain is disabled * @param {boolean} [props.showComments] - Whether to show comment fields * @param {boolean} [props.collapsed] - Whether the domain is collapsed * @param {Function} [props.onToggleCollapse] - Callback to toggle collapse + * @param {Function} [props.getRobinsText] - Function to get Y.Text for a ROBINS-I free-text field */ export function DomainSection(props) { const domain = () => ROBINS_I_CHECKLIST[props.domainKey]; const questions = () => getDomainQuestions(props.domainKey); const hasSubsections = () => !!domain()?.subsections; + // Smart scoring: compute auto judgement from answers + const autoScore = createMemo(() => { + return scoreRobinsDomain(props.domainKey, props.domainState?.answers); + }); + + // Effective judgement: auto unless manually overridden + const effectiveJudgement = createMemo(() => { + return getEffectiveDomainJudgement(props.domainState, autoScore()); + }); + + // Check if currently in manual mode (reactive) + const isManualMode = createMemo(() => props.domainState?.judgementSource === 'manual'); + function handleQuestionUpdate(questionKey, newAnswer) { const newAnswers = { ...props.domainState.answers, [questionKey]: newAnswer, }; - props.onUpdate({ + + // Compute what the auto judgement would be with new answers + const newAutoScore = scoreRobinsDomain(props.domainKey, newAnswers); + + // If in auto mode, sync judgement with calculated value + const currentSource = props.domainState?.judgementSource || 'auto'; + const newState = { ...props.domainState, answers: newAnswers, - }); + }; + + if (currentSource === 'auto' && newAutoScore.judgement) { + newState.judgement = newAutoScore.judgement; + } + + props.onUpdate(newState); } function handleJudgementChange(judgement) { + // Clicking a judgement button switches to manual mode props.onUpdate({ ...props.domainState, judgement, + judgementSource: 'manual', }); } @@ -44,6 +75,26 @@ export function DomainSection(props) { }); } + function handleRevertToAuto() { + // Reset to auto mode with calculated judgement + const currentState = props.domainState || {}; + props.onUpdate({ + ...currentState, + judgement: autoScore().judgement, + judgementSource: 'auto', + }); + } + + function handleSwitchToManual() { + // Switch to manual mode but keep current judgement (or use current auto if no judgement set) + const currentState = props.domainState || {}; + props.onUpdate({ + ...currentState, + judgement: currentState.judgement || autoScore().judgement, + judgementSource: 'manual', + }); + } + // Get completion status const completionStatus = () => { const qs = questions(); @@ -75,9 +126,17 @@ export function DomainSection(props) { {completionStatus().answered}/{completionStatus().total} - {/* Judgement badge if set */} - - + {/* Judgement badge with mode indicator */} + +
+ + Manual + + +
+
+ + Incomplete {/* Collapse indicator */} @@ -113,6 +172,9 @@ export function DomainSection(props) { onUpdate={newAnswer => handleQuestionUpdate(qKey, newAnswer)} disabled={props.disabled} showComment={props.showComments} + domainKey={props.domainKey} + questionKey={qKey} + getRobinsText={props.getRobinsText} /> )} @@ -144,17 +206,73 @@ export function DomainSection(props) {
- {/* Domain judgement */} - + {/* Auto-first judgement section */} +
+ {/* Calculated judgement display */} +
+
+ Risk of bias judgement + +
+ Calculated: + +
+
+ + (answer more questions) + +
+ + {/* Mode toggle */} +
+
+ + +
+
+
+ + {/* Judgement selector - only fully interactive in manual mode */} + +
diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/OverallSection.jsx b/packages/web/src/components/checklist/ROBINSIChecklist/OverallSection.jsx index cad9b3043..8aa0ce3b0 100644 --- a/packages/web/src/components/checklist/ROBINSIChecklist/OverallSection.jsx +++ b/packages/web/src/components/checklist/ROBINSIChecklist/OverallSection.jsx @@ -1,23 +1,47 @@ -import { For } from 'solid-js'; +import { For, Show, createMemo } from 'solid-js'; import { OVERALL_ROB_JUDGEMENTS, BIAS_DIRECTIONS } from './checklist-map.js'; -import { scoreChecklist } from './checklist.js'; +import { getSmartScoring, mapOverallJudgementToDisplay } from './checklist.js'; /** * Overall risk of bias section with final judgement + * Now with auto-first scoring: calculated judgement is primary, manual override is explicit + * * @param {Object} props - * @param {Object} props.overallState - Current overall state { judgement, direction } + * @param {Object} props.overallState - Current overall state { judgement, judgementSource, direction } * @param {Object} props.checklistState - Full checklist state (for auto-scoring) * @param {Function} props.onUpdate - Callback when overall state changes * @param {boolean} [props.disabled] - Whether the section is disabled */ export function OverallSection(props) { - // Calculated score based on domains - const calculatedScore = () => scoreChecklist(props.checklistState); + // Smart scoring: compute auto judgement from all domains + const smartScoring = createMemo(() => getSmartScoring(props.checklistState)); + + // Calculated overall score (internal format) + const calculatedScore = () => smartScoring().overall; + + // Calculated overall in display format (maps to OVERALL_ROB_JUDGEMENTS) + const calculatedDisplayJudgement = createMemo(() => { + const score = calculatedScore(); + return mapOverallJudgementToDisplay(score); + }); + + // Check if currently in manual mode (reactive) + const isManualMode = createMemo(() => props.overallState?.judgementSource === 'manual'); + + // Effective judgement: what's actually shown/used + const effectiveJudgement = createMemo(() => { + if (isManualMode() && props.overallState?.judgement) { + return props.overallState.judgement; + } + return calculatedDisplayJudgement(); + }); function handleJudgementChange(judgement) { + // Clicking a judgement button switches to manual mode props.onUpdate({ ...props.overallState, judgement, + judgementSource: 'manual', }); } @@ -28,83 +52,153 @@ export function OverallSection(props) { }); } - const getJudgementColor = judgement => { + function handleRevertToAuto() { + // Reset to auto mode with calculated judgement + const currentState = props.overallState || {}; + props.onUpdate({ + ...currentState, + judgement: calculatedDisplayJudgement(), + judgementSource: 'auto', + }); + } + + function handleSwitchToManual() { + // Switch to manual mode but keep current judgement (or use current auto if no judgement set) + const currentState = props.overallState || {}; + props.onUpdate({ + ...currentState, + judgement: currentState.judgement || calculatedDisplayJudgement(), + judgementSource: 'manual', + }); + } + + const getJudgementColor = (judgement, isSelected) => { + if (!isSelected) { + return isManualMode() ? + 'border-gray-200 bg-white text-gray-600 hover:border-gray-300' + : 'border-gray-200 bg-gray-50 text-gray-500 hover:border-gray-300 hover:bg-white'; + } + switch (judgement) { case 'Low risk of bias except for concerns about uncontrolled confounding': - case 'Low (except confounding)': return 'bg-green-100 border-green-400 text-green-800'; case 'Moderate risk': - case 'Moderate': return 'bg-yellow-100 border-yellow-400 text-yellow-800'; case 'Serious risk': - case 'Serious': return 'bg-orange-100 border-orange-400 text-orange-800'; case 'Critical risk': - case 'Critical': return 'bg-red-100 border-red-400 text-red-800'; default: return 'bg-gray-50 border-gray-200 text-gray-600'; } }; - const getScoreColor = score => { + const getScoreBadgeColor = score => { switch (score) { case 'Low': - return 'text-green-600'; + case 'Low (except for concerns about uncontrolled confounding)': + return 'bg-green-100 text-green-800'; case 'Moderate': - return 'text-yellow-600'; + return 'bg-yellow-100 text-yellow-800'; case 'Serious': - return 'text-orange-600'; + return 'bg-orange-100 text-orange-800'; case 'Critical': - return 'text-red-600'; + return 'bg-red-100 text-red-800'; default: - return 'text-gray-500'; + return 'bg-gray-100 text-gray-600'; } }; return (
-

Overall Risk of Bias

-

Final assessment based on all domain judgements

+
+
+

Overall Risk of Bias

+

+ Final assessment based on all domain judgements +

+
+ + {/* Overall calculated badge in header */} + +
+ + {calculatedScore()} + + + Manual override + +
+
+ + Incomplete + +
- {/* Calculated score hint */} -
-
- Calculated score: - - {calculatedScore()} - + {/* Calculated score display with mode toggle */} +
+
+ Calculated judgement: + Complete all domains} + > + + {calculatedDisplayJudgement()} + + +
+ + {/* Mode toggle */} +
+
+ + +
-

- Based on the highest risk of bias across all domains. You may override this judgement - below. -

- {/* Overall risk of bias judgement */} + {/* Overall risk of bias judgement buttons */}
Overall risk of bias judgement
{judgement => { - const isSelected = () => props.overallState?.judgement === judgement; + const isSelected = () => effectiveJudgement() === judgement; return ( @@ -129,7 +223,6 @@ export function OverallSection(props) { type='button' onClick={() => { if (props.disabled) return; - // Toggle: deselect if already selected, otherwise select handleDirectionChange(isSelected() ? null : direction); }} disabled={props.disabled} @@ -137,7 +230,7 @@ export function OverallSection(props) { isSelected() ? 'border-blue-400 bg-blue-100 text-blue-800' : 'border-gray-200 bg-gray-50 text-gray-500 hover:border-gray-300' - } `} + }`} > {direction} diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/PlanningSection.jsx b/packages/web/src/components/checklist/ROBINSIChecklist/PlanningSection.jsx index 87efcd02a..9d8c5dfa7 100644 --- a/packages/web/src/components/checklist/ROBINSIChecklist/PlanningSection.jsx +++ b/packages/web/src/components/checklist/ROBINSIChecklist/PlanningSection.jsx @@ -1,4 +1,5 @@ import { PLANNING_SECTION } from './checklist-map.js'; +import NoteEditor from '@/components/checklist/common/NoteEditor.jsx'; /** * Planning Section: List confounding factors at planning stage @@ -6,18 +7,15 @@ import { PLANNING_SECTION } from './checklist-map.js'; * @param {Object} props.planningState - Current planning state { confoundingFactors } * @param {Function} props.onUpdate - Callback when planning state changes * @param {boolean} [props.disabled] - Whether the section is disabled + * @param {Function} [props.getRobinsText] - Function to get Y.Text for a ROBINS-I free-text field */ export function PlanningSection(props) { const p1Field = PLANNING_SECTION.p1; - function handleFieldChange(value) { - props.onUpdate({ - ...props.planningState, - [p1Field.stateKey]: value, - }); - } - - const value = () => props.planningState?.[p1Field.stateKey] || ''; + const yText = () => { + if (!props.getRobinsText) return null; + return props.getRobinsText('planning', 'confoundingFactors'); + }; return (
@@ -33,14 +31,14 @@ export function PlanningSection(props) { {p1Field.label}. {p1Field.text} -