Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@ export default function MultiPartQuestionPage(props) {
// Local state for each part
const [localFinal, setLocalFinal] = createSignal({});
const [selectedSource, setSelectedSource] = createSignal(null);
const [hasAutoFilled, setHasAutoFilled] = createSignal(false);

// Reset auto-fill tracking when question changes
createEffect(() => {
props.questionKey;
setHasAutoFilled(false);
});

// Check if both reviewers have the same answers
const reviewersAgree = () =>
multiPartAnswersEqual(props.reviewer1Answers, props.reviewer2Answers);

Expand Down Expand Up @@ -51,6 +57,40 @@ export default function MultiPartQuestionPage(props) {
}
});

// Check if the final answer last column has at least one part as true
function hasValidFinalAnswer(finalAnswers, partKeys) {
if (!finalAnswers || !Array.isArray(partKeys) || partKeys.length === 0) return false;
return partKeys.some(dk => {
const part = finalAnswers[dk];
if (!part?.answers || !Array.isArray(part.answers) || part.answers.length === 0) return false;
const lastCol = part.answers[part.answers.length - 1];
return Array.isArray(lastCol) && lastCol.some(v => v === true);
});
}

// Auto-fill when reviewers agree and no final answer exists
createEffect(() => {
const keys = dataKeys();
if (!keys || keys.length === 0) return;

// Check if we have a valid final answer (has at least one part)
let hasFinalAnswer = hasValidFinalAnswer(props.finalAnswers, keys);

// Only auto-fill if: reviewers agree, no final answer exists, we have reviewer1's answer, and we haven't auto-filled yet
if (
props.isAgreement &&
!hasFinalAnswer &&
props.reviewer1Answers &&
keys.some(dk => props.reviewer1Answers[dk]) &&
!hasAutoFilled() &&
props.onFinalChange
) {
const newFinal = JSON.parse(JSON.stringify(props.reviewer1Answers));
props.onFinalChange(newFinal);
setHasAutoFilled(true);
}
});

function multiPartAnswersEqual(a, b) {
if (!a || !b) return false;
for (const dk of dataKeys()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,14 @@ function SingleQuestionPage(props) {
// Local state for the final/merged answer that user can edit
const [localFinal, setLocalFinal] = createSignal(null);
const [selectedSource, setSelectedSource] = createSignal(null); // 'reviewer1' | 'reviewer2' | 'custom'
const [hasAutoFilled, setHasAutoFilled] = createSignal(false);

// Reset auto-fill tracking when question changes
createEffect(() => {
props.questionKey;
setHasAutoFilled(false);
});

// Check if both reviewers have the same answers
const reviewersAgree = () => answersEqual(props.reviewer1Answers, props.reviewer2Answers);

// Initialize local final from props or default to reviewer1
Expand All @@ -68,12 +74,41 @@ function SingleQuestionPage(props) {
setSelectedSource('custom');
}
} else if (props.reviewer1Answers) {
// Default to reviewer1
// Default to reviewer1 for local state
setLocalFinal(JSON.parse(JSON.stringify(props.reviewer1Answers)));
setSelectedSource('reviewer1');
}
});

// Check if the final answer last column has at least one part as true
function hasValidFinalAnswer(finalAnswers) {
if (
!finalAnswers?.answers ||
!Array.isArray(finalAnswers.answers) ||
finalAnswers.answers.length === 0
)
return false;
const lastCol = finalAnswers.answers[finalAnswers.answers.length - 1];
return Array.isArray(lastCol) && lastCol.some(v => v === true);
}

// Auto-fill when reviewers agree and no final answer exists
createEffect(() => {
let hasFinalAnswer = hasValidFinalAnswer(props.finalAnswers);
// Only auto-fill if: reviewers agree, no final answer exists, we have reviewer1's answer, and we haven't auto-filled yet
if (
props.isAgreement &&
!hasFinalAnswer &&
props.reviewer1Answers &&
!hasAutoFilled() &&
props.onFinalChange
) {
const newFinal = JSON.parse(JSON.stringify(props.reviewer1Answers));
props.onFinalChange(newFinal);
setHasAutoFilled(true);
}
});

// Check if two answer objects are equal
function answersEqual(a, b) {
if (!a || !b) return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export default function ReconciliationWrapper() {
getReconciliationProgress,
getQuestionNote,
saveReconciliationProgress,
clearReconciliationProgress,
connect,
} = useProject(params.projectId);

Expand Down Expand Up @@ -365,8 +364,16 @@ export default function ReconciliationWrapper() {
title: reconciledName || 'Reconciled Checklist',
});

// Clear the reconciliation progress since we've completed it
clearReconciliationProgress(params.studyId);
// Mark the individual reviewer checklists as completed
updateChecklist(params.studyId, params.checklist1Id, {
status: CHECKLIST_STATUS.COMPLETED,
});
updateChecklist(params.studyId, params.checklist2Id, {
status: CHECKLIST_STATUS.COMPLETED,
});

// Keep reconciliation progress (checklist1Id and checklist2Id) so users can view previous reviewers
// The progress data is needed for the "View Previous" button in the completed tab

// Navigate back to the project view (completed tab)
navigate(`/projects/${params.projectId}?tab=completed`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import { Avatar, useConfirmDialog, showToast, Progress, Collapsible } from '@cor
import { API_BASE } from '@config/api.js';
import { CHECKLIST_STATUS } from '@/constants/checklist-status.js';
import { shouldShowInTab } from '@/lib/checklist-domain.js';
import {
calculateInterRaterReliability,
getKappaInterpretation,
} from '@/lib/inter-rater-reliability.js';
import CircularProgress from './CircularProgress.jsx';

/**
Expand Down Expand Up @@ -149,6 +153,11 @@ export default function OverviewTab() {
return projectActionsStore.checklist.getData(studyId, checklistId);
};

// Calculate inter-rater reliability metrics
const interRaterMetrics = createMemo(() => {
return calculateInterRaterReliability(studies(), getChecklistData);
});

// Calculate unassigned studies for Reviewer Assignment visibility
const unassignedStudies = createMemo(() => studies().filter(s => !s.reviewer1 && !s.reviewer2));

Expand Down Expand Up @@ -206,6 +215,37 @@ export default function OverviewTab() {
</div>
</div>
</div>

{/* Inter-rater Reliability Section */}
<Show when={interRaterMetrics().studyCount > 0}>
<div class='mt-6 rounded-lg border border-purple-200 bg-purple-50 p-5'>
<h3 class='mb-4 text-base font-semibold text-gray-900'>Inter-rater Reliability</h3>
<div class='grid grid-cols-1 gap-4 md:grid-cols-3'>
<div class='text-center'>
<p class='text-2xl font-bold text-gray-900'>{interRaterMetrics().studyCount}</p>
<p class='mt-1 text-sm text-gray-600'>Studies Included</p>
</div>
<div class='text-center'>
<p class='text-2xl font-bold text-gray-900'>
{interRaterMetrics().percentAgreement != null ?
`${interRaterMetrics().percentAgreement.toFixed(1)}%`
: 'N/A'}
</p>
<p class='mt-1 text-sm text-gray-600'>Percent Agreement</p>
</div>
<div class='text-center'>
<p class='text-2xl font-bold text-gray-900'>
{interRaterMetrics().cohensKappa != null ?
interRaterMetrics().cohensKappa.toFixed(3)
: 'N/A'}
</p>
<p class='mt-1 text-xs text-gray-600'>
Cohen's Kappa ({getKappaInterpretation(interRaterMetrics().cohensKappa)})
</p>
</div>
</div>
</div>
</Show>
</div>

{/* Section 2: Team & Collaboration */}
Expand Down
Loading