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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@
"eslint-plugin-unicorn": "^62.0.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"turbo": "^2.7.6",
"wrangler": "^4.61.0"
"turbo": "^2.8.1",
"wrangler": "^4.61.1"
},
"engines": {
"node": ">=24.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-memory/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.0.10",
"@types/node": "^25.2.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^25.0.10",
"@types/node": "^25.2.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
Expand Down
20 changes: 20 additions & 0 deletions packages/shared/src/checklists/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@

import type { ChecklistStatus } from './status.js';

/**
* Outcome entity - represents a specific outcome being assessed in a study
*/
export interface Outcome {
id: string;
name: string;
createdAt: number;
createdBy: string;
}

/**
* Base checklist metadata shared by all checklist types
*/
Expand All @@ -15,6 +25,16 @@ export interface ChecklistMetadata {
assignedTo?: string | null;
status?: ChecklistStatus;
type: 'AMSTAR2' | 'ROBINS_I' | 'ROB2';
outcomeId?: string | null;
}

/**
* Check if a checklist type requires an outcome selection
* @param type - The checklist type
* @returns True if the type requires an outcome
*/
export function requiresOutcome(type: string): boolean {
return type === 'ROB2' || type === 'ROBINS_I';
}

/**
Expand Down
10 changes: 5 additions & 5 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,19 @@
"@embedpdf/plugin-view-manager": "^2.3.0",
"@embedpdf/plugin-viewport": "^2.3.0",
"@embedpdf/plugin-zoom": "^2.3.0",
"@sentry/solid": "^10.37.0",
"@sentry/solid": "^10.38.0",
"@solid-primitives/scheduled": "^1.5.2",
"@solidjs/router": "^0.15.4",
"@tanstack/solid-query": "^5.90.23",
"@tanstack/solid-table": "^8.21.3",
"better-auth": "^1.4.17",
"better-auth": "^1.4.18",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"countup.js": "^2.9.0",
"d3": "^7.9.0",
"dexie": "^4.2.1",
"preact": "^10.28.2",
"dexie": "^4.3.0",
"preact": "^10.28.3",
"solid-chartjs": "^1.3.11",
"solid-icons": "^1.2.0",
"solid-js": "^1.9.11",
Expand All @@ -72,7 +72,7 @@
"@preact/preset-vite": "^2.10.3",
"@solidjs/testing-library": "^0.8.10",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/solid-query-devtools": "^5.91.2",
"@tanstack/solid-query-devtools": "^5.91.3",
"@testing-library/jest-dom": "^6.9.1",
"@vitest/ui": "^4.0.18",
"fake-indexeddb": "^6.2.5",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { For, Show, createMemo } from 'solid-js';
import { For, Show, createMemo, createEffect, on } from 'solid-js';
import { BIAS_DIRECTIONS } from './checklist-map.js';
import { getSmartScoring, mapOverallJudgementToDisplay } from './checklist.js';

Expand Down Expand Up @@ -30,6 +30,27 @@ export function OverallSection(props) {
return calculatedDisplayJudgement();
});

// Auto-persist calculated judgement when all domains are complete
// This ensures the overall judgement is stored for reconciliation and export
createEffect(
on(
() => [calculatedDisplayJudgement(), props.overallState?.judgement],
([calculated, stored]) => {
// Only auto-persist if:
// 1. A valid calculated judgement exists (not null/undefined)
// 2. The stored judgement differs from calculated (or is null)
// 3. Not disabled
if (calculated && calculated !== stored && !props.disabled) {
const currentState = props.overallState || {};
props.onUpdate({
...currentState,
judgement: calculated,
});
}
},
),
);

function handleDirectionChange(direction) {
props.onUpdate({
...props.overallState,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { For, Show, createMemo } from 'solid-js';
import { For, Show, createMemo, createEffect, on } from 'solid-js';
import { OVERALL_ROB_JUDGEMENTS, BIAS_DIRECTIONS } from './checklist-map.js';
import { getSmartScoring, mapOverallJudgementToDisplay } from './checklist.js';

Expand Down Expand Up @@ -36,6 +36,29 @@ export function OverallSection(props) {
return calculatedDisplayJudgement();
});

// Auto-persist calculated judgement when in auto mode and all domains are complete
// This ensures isROBINSIComplete() passes when the calculated judgement is valid
createEffect(
on(
() => [calculatedDisplayJudgement(), isManualMode(), props.overallState?.judgement],
([calculated, manual, stored]) => {
// Only auto-persist if:
// 1. Not in manual mode
// 2. A valid calculated judgement exists (not null/undefined)
// 3. The stored judgement differs from calculated (or is null)
// 4. Not disabled
if (!manual && calculated && calculated !== stored && !props.disabled) {
const currentState = props.overallState || {};
props.onUpdate({
...currentState,
judgement: calculated,
judgementSource: 'auto',
});
}
},
),
);

function handleJudgementChange(judgement) {
// Clicking a judgement button switches to manual mode
props.onUpdate({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import GoogleDrivePickerModal from '../google-drive/GoogleDrivePickerModal.jsx';
import { StudyCard } from './study-card/index.js';
import AssignReviewersModal from './AssignReviewersModal.jsx';
import ReviewerAssignment from '../overview-tab/ReviewerAssignment.jsx';
import { OutcomeManager } from '../outcomes/index.js';
import projectStore from '@/stores/projectStore.js';
import projectActionsStore from '@/stores/projectActionsStore';
import { useProjectContext } from '../ProjectContext.jsx';
Expand Down Expand Up @@ -143,6 +144,13 @@ export default function AllStudiesTab() {
/>
</Show>

{/* Outcome Management - Always visible for owners */}
<Show when={hasData()}>
<div class='mt-5'>
<OutcomeManager />
</div>
</Show>

{/* Reviewer Assignment - Shown for owners with unassigned studies */}
<Show when={shouldShowReviewerAssignment()}>
<div class='mt-5'>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* CompletedOutcomeRow - Single outcome row within completed study
*
* Displays a completed checklist for a specific outcome with actions to open
* the checklist and view previous reviewer checklists (for dual-reviewer studies).
*/

import { createSignal, Show } from 'solid-js';
import { getChecklistMetadata } from '@/checklist-registry';
import { getStatusLabel, getStatusStyle } from '@/constants/checklist-status.js';
import PreviousReviewersView from './PreviousReviewersView.jsx';

export default function CompletedOutcomeRow(props) {
// props.study: Study object
// props.outcomeGroup: { outcomeId: string|null, type: string, checklists: Array }
// props.onOpenChecklist: (checklistId) => void
// props.getAssigneeName: (userId) => string
// props.getOutcomeName: (outcomeId) => string | null
// props.getReconciliationProgress: (outcomeId, type) => Object | null

const [showPreviousReviewers, setShowPreviousReviewers] = createSignal(false);

const outcomeGroup = () => props.outcomeGroup;

// Get the first finalized checklist (the reconciled one)
const finalizedChecklist = () => outcomeGroup().checklists[0];

// Get outcome name for display
const outcomeName = () => {
const outcomeId = outcomeGroup().outcomeId;
if (!outcomeId) return null;
return props.getOutcomeName?.(outcomeId) || 'Unknown Outcome';
};

// Get reconciliation progress for this outcome
const reconciliationProgress = () => {
return props.getReconciliationProgress?.(outcomeGroup().outcomeId, outcomeGroup().type);
};

// Check if we have previous reviewers to show
const hasPreviousReviewers = () => {
const progress = reconciliationProgress();
return !!(progress?.checklist1Id && progress?.checklist2Id);
};

return (
<>
<div class='bg-muted/50 flex items-center justify-between rounded-lg p-3'>
<div class='flex items-center gap-3'>
{/* Outcome badge */}
<Show when={outcomeName()}>
<span class='bg-secondary text-secondary-foreground rounded-full px-2 py-0.5 text-xs font-medium'>
{outcomeName()}
</span>
</Show>

{/* Checklist type */}
<span class='text-foreground text-sm font-medium'>
{getChecklistMetadata(outcomeGroup().type)?.name || outcomeGroup().type}
</span>

{/* Status badge */}
<span
class={`rounded-full px-2 py-0.5 text-xs font-medium ${getStatusStyle(finalizedChecklist()?.status)}`}
>
{getStatusLabel(finalizedChecklist()?.status)}
</span>
</div>

<div class='flex items-center gap-2'>
{/* View Previous button (for dual-reviewer studies) */}
<Show when={hasPreviousReviewers()}>
<button
onClick={() => setShowPreviousReviewers(true)}
class='bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors'
>
View Previous
</button>
</Show>

{/* Open button */}
<button
onClick={() => props.onOpenChecklist?.(finalizedChecklist()?.id)}
class='bg-primary hover:bg-primary/90 focus:ring-primary rounded-lg px-3 py-1.5 text-sm font-medium text-white transition-colors focus:ring-2 focus:outline-none'
>
Open
</button>
Comment on lines +25 to +87
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard the Open action when no finalized checklist is available.
If outcomeGroup.checklists is empty during sync or data inconsistencies, onOpenChecklist will receive undefined. Consider disabling or hiding the button when there is no checklist id.

Proposed fix
-  const finalizedChecklist = () => outcomeGroup().checklists[0];
+  const finalizedChecklist = () => outcomeGroup().checklists[0];
+  const finalizedChecklistId = () => finalizedChecklist()?.id;
@@
-          <button
-            onClick={() => props.onOpenChecklist?.(finalizedChecklist()?.id)}
+          <button
+            onClick={() => finalizedChecklistId() && props.onOpenChecklist?.(finalizedChecklistId())}
+            disabled={!finalizedChecklistId()}
             class='bg-primary hover:bg-primary/90 focus:ring-primary rounded-lg px-3 py-1.5 text-sm font-medium text-white transition-colors focus:ring-2 focus:outline-none'
           >
             Open
           </button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Get the first finalized checklist (the reconciled one)
const finalizedChecklist = () => outcomeGroup().checklists[0];
// Get outcome name for display
const outcomeName = () => {
const outcomeId = outcomeGroup().outcomeId;
if (!outcomeId) return null;
return props.getOutcomeName?.(outcomeId) || 'Unknown Outcome';
};
// Get reconciliation progress for this outcome
const reconciliationProgress = () => {
return props.getReconciliationProgress?.(outcomeGroup().outcomeId, outcomeGroup().type);
};
// Check if we have previous reviewers to show
const hasPreviousReviewers = () => {
const progress = reconciliationProgress();
return !!(progress?.checklist1Id && progress?.checklist2Id);
};
return (
<>
<div class='bg-muted/50 flex items-center justify-between rounded-lg p-3'>
<div class='flex items-center gap-3'>
{/* Outcome badge */}
<Show when={outcomeName()}>
<span class='bg-secondary text-secondary-foreground rounded-full px-2 py-0.5 text-xs font-medium'>
{outcomeName()}
</span>
</Show>
{/* Checklist type */}
<span class='text-foreground text-sm font-medium'>
{getChecklistMetadata(outcomeGroup().type)?.name || outcomeGroup().type}
</span>
{/* Status badge */}
<span
class={`rounded-full px-2 py-0.5 text-xs font-medium ${getStatusStyle(finalizedChecklist()?.status)}`}
>
{getStatusLabel(finalizedChecklist()?.status)}
</span>
</div>
<div class='flex items-center gap-2'>
{/* View Previous button (for dual-reviewer studies) */}
<Show when={hasPreviousReviewers()}>
<button
onClick={() => setShowPreviousReviewers(true)}
class='bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors'
>
View Previous
</button>
</Show>
{/* Open button */}
<button
onClick={() => props.onOpenChecklist?.(finalizedChecklist()?.id)}
class='bg-primary hover:bg-primary/90 focus:ring-primary rounded-lg px-3 py-1.5 text-sm font-medium text-white transition-colors focus:ring-2 focus:outline-none'
>
Open
</button>
// Get the first finalized checklist (the reconciled one)
const finalizedChecklist = () => outcomeGroup().checklists[0];
const finalizedChecklistId = () => finalizedChecklist()?.id;
// Get outcome name for display
const outcomeName = () => {
const outcomeId = outcomeGroup().outcomeId;
if (!outcomeId) return null;
return props.getOutcomeName?.(outcomeId) || 'Unknown Outcome';
};
// Get reconciliation progress for this outcome
const reconciliationProgress = () => {
return props.getReconciliationProgress?.(outcomeGroup().outcomeId, outcomeGroup().type);
};
// Check if we have previous reviewers to show
const hasPreviousReviewers = () => {
const progress = reconciliationProgress();
return !!(progress?.checklist1Id && progress?.checklist2Id);
};
return (
<>
<div class='bg-muted/50 flex items-center justify-between rounded-lg p-3'>
<div class='flex items-center gap-3'>
{/* Outcome badge */}
<Show when={outcomeName()}>
<span class='bg-secondary text-secondary-foreground rounded-full px-2 py-0.5 text-xs font-medium'>
{outcomeName()}
</span>
</Show>
{/* Checklist type */}
<span class='text-foreground text-sm font-medium'>
{getChecklistMetadata(outcomeGroup().type)?.name || outcomeGroup().type}
</span>
{/* Status badge */}
<span
class={`rounded-full px-2 py-0.5 text-xs font-medium ${getStatusStyle(finalizedChecklist()?.status)}`}
>
{getStatusLabel(finalizedChecklist()?.status)}
</span>
</div>
<div class='flex items-center gap-2'>
{/* View Previous button (for dual-reviewer studies) */}
<Show when={hasPreviousReviewers()}>
<button
onClick={() => setShowPreviousReviewers(true)}
class='bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors'
>
View Previous
</button>
</Show>
{/* Open button */}
<button
onClick={() => finalizedChecklistId() && props.onOpenChecklist?.(finalizedChecklistId())}
disabled={!finalizedChecklistId()}
class='bg-primary hover:bg-primary/90 focus:ring-primary rounded-lg px-3 py-1.5 text-sm font-medium text-white transition-colors focus:ring-2 focus:outline-none'
>
Open
</button>
🤖 Prompt for AI Agents
In `@packages/web/src/components/project/completed-tab/CompletedOutcomeRow.jsx`
around lines 25 - 87, The Open button can call props.onOpenChecklist with
undefined when there is no finalized checklist; guard it by checking
finalizedChecklist()?.id before enabling or invoking the action. Update the Open
button rendering/handler in CompletedOutcomeRow.jsx to (a) compute const
checklistId = finalizedChecklist()?.id and use that for both the button disabled
state and the onClick only if truthy, or (b) hide the button when checklistId is
falsy; adjust the onClick to call props.onOpenChecklist(checklistId) only when
checklistId exists so onOpenChecklist never receives undefined.

</div>
</div>

{/* Previous Reviewers View Dialog */}
<Show when={showPreviousReviewers()}>
<PreviousReviewersView
study={props.study}
reconciliationProgress={reconciliationProgress()}
getAssigneeName={props.getAssigneeName}
onClose={() => setShowPreviousReviewers(false)}
/>
</Show>
</>
);
}
Loading