diff --git a/architecture-goals.md b/architecture-goals.md deleted file mode 100644 index a4b005e4a..000000000 --- a/architecture-goals.md +++ /dev/null @@ -1,233 +0,0 @@ -# Architecture Goals - -Date: 2025-11-24 -Author: Automation (drafted by GitHub Copilot assistant) - ---- - -## Goal (summary) - -Implement a Yjs-based CRDT sync layer backed by per-project Durable Objects (DOs). Clients will use Yjs and persist locally with y-indexeddb for offline support. Users will be stored centrally in D1 and authenticated through a dedicated worker (BetterAuth). One project => one Durable Object is the recommended partitioning strategy. - -We will standardize on UUIDs for entity IDs. D1 is used only for the global users table. R2 will be used to store PDF documents uploaded by users for projects. - ---- - -## High-level architecture (target) - -Client (browser) -- WebSocket/HTTP/WebAuth --> Edge Worker (auth check) --> Durable Object (per-project) - -Durable Object: holds Y.Doc (Yjs) for the project, validates domain constraints, and broadcasts updates to connected clients. Also holds user roles for the project: owner or member. - -D1 Persistence: Users table is handled globally by the BetterAuth worker + D1. - -R2 is used for storing PDF documents uploaded by users for projects. - -UI Local Persistence: Client persists Y.Doc to IndexedDB using y-indexeddb for offline support. When device reconnects, the Y.Doc merges with the DO and resolves conflicts automatically. - -Official diagram (conceptual): - -Client A <-> Edge Worker (auth) <-> Durable Object (Y.Doc) -^ -Client B ----------------------------------------| - ---- - -## Data model mapping - -Hierarchical Y.Doc structure per project: - -``` -Project (1 Durable Object per project) - - meta: Y.Map { name, description, createdAt, updatedAt } - - members: Y.Map (userId => { role, joinedAt }) - - reviews: Y.Map (reviewId => { - name, description, createdAt, updatedAt, - checklists: Y.Map (checklistId => { - title, assignedTo, status, createdAt, updatedAt, - answers: Y.Map (questionKey => { value, notes, updatedAt, updatedBy }) - }) - }) -``` - -Example Yjs usage: - -```js -const ydoc = new Y.Doc(); - -// Create a review -const reviews = ydoc.getMap('reviews'); -const reviewId = crypto.randomUUID(); -const reviewMap = new Y.Map(); -reviewMap.set('name', 'Sleep Study Review'); -reviewMap.set('description', 'AMSTAR2 evaluation'); -reviewMap.set('createdAt', Date.now()); -reviewMap.set('checklists', new Y.Map()); -reviews.set(reviewId, reviewMap); - -// Add a checklist to the review -const checklistsMap = reviewMap.get('checklists'); -const checklistId = crypto.randomUUID(); -const checklistMap = new Y.Map(); -checklistMap.set('title', 'Study 1 Assessment'); -checklistMap.set('assignedTo', 'user-uuid'); // reviewer userId -checklistMap.set('status', 'pending'); // pending, in-progress, completed -checklistMap.set('createdAt', Date.now()); -checklistMap.set('answers', new Y.Map()); -checklistsMap.set(checklistId, checklistMap); - -// Record an answer (AMSTAR2 format with boolean arrays per column) -const answersMap = checklistMap.get('answers'); -answersMap.set( - 'q1', - new Y.Map([ - // Each question stores: answers (nested boolean arrays), critical flag, notes - ['answers', [[false, false, false, false], [false], [false, true]]], // matches column structure - ['critical', false], - ['notes', ''], - ['updatedAt', Date.now()], - ['updatedBy', 'user-uuid'], - ]), -); -``` - -Why this structure: - -- Hierarchical: Projects -> Reviews -> Checklists -> Answers -- Fine-grained CRDT: Each level is a Y.Map for efficient merges -- Assignments: Each checklist has `assignedTo` for reviewer assignment -- Status tracking: Checklists have status (pending/in-progress/completed) - ---- - -## ID strategy - -- Use UUIDs across clients and servers to avoid temp-id swap headaches. Generates deterministic unique ids (v4) on creation. - ---- - -## Durable Object responsibilities - -- Own the authoritative Y.Doc for the project. -- Authenticate and authorize connecting clients (validate tokens via BetterAuth worker/service). - ---- - -## Client responsibilities - -- Use Yjs (Y.Doc) and y-indexeddb for local persistence and offline use. -- Connect to DO via secure WebSocket provider (or a worker that routes to DO and validates auth token). -- Translate Yjs document to the UI data model (you can either use Yjs directly in UI or maintain a lightweight local mirror if necessary). - ---- - -## Authentication & Users - -- Global users table and authentication lives outside project DOs. -- Store users in D1 (Cloudflare). BetterAuth worker handles secure token minting and validation. -- On client connect, the worker validates the user and checks membership/permission for requested project. Connection allowed only for authorized member roles. - -## Project Documents (PDFs) - -- User-uploaded PDF documents for projects are stored in R2 (Cloudflare object storage). - ---- - -## Architecture Implementation Roadmap - -This roadmap outlines the step-by-step plan for implementing the new architecture in this project, transitioning from previous approaches to a Yjs + Durable Objects + D1 model. - -**Phase 1 — Foundation & Pilot** - -- Establish UUID strategy and implement generation utilities with tests. -- Build initial Durable Object (DO) for a single project, syncing a simple `checklist_answers` array using Yjs. - -**Phase 2 — Core Expansion** - -- Extend DO to support reviews, checklists, and assignments. -- Add permission checks and role logic within DOs (owner/member rules). - -**Phase 3 — Authentication & Integration** - -- Deploy BetterAuth worker and global D1 user table. -- Integrate client authentication flow to obtain tokens for WebSocket connections. - -**Phase 4 — Testing & Hardening** - -- Develop and run tests for offline reconciliation and conflict scenarios. -- Harden system for reliability and edge cases. - -**Phase 5 — Analytics & Monitoring** - -- Add monitoring and metrics for DO performance. - ---- - -## Example minimal DO + client pseudocode (PoC) - -DO (high-level pseudocode): - -```js -// Durable Object pseudocode -class ProjectDoc { - constructor() { - this.ydoc = new Y.Doc(); - this.clients = []; - } - - onConnect(client, user) { - // Auth done by Edge Worker - this.clients.push(client); - // Send current snapshot to client - client.send(Y.encodeStateAsUpdate(this.ydoc)); - } - - onMessage(client, update) { - // update is a Yjs update (Uint8Array) - Y.applyUpdate(this.ydoc, update); - // DO-side validation hooks here for domain invariants - - // Broadcast to other clients - this.clients.forEach(c => c !== client && c.send(update)); - } -} -``` - -Client (pseudocode): - -```js -const ydoc = new Y.Doc(); -const provider = new WebsocketProvider('wss://example.com/project', projectId, ydoc, { params: { token } }); - -// Persist locally -const persistence = new IndexeddbPersistence(projectKey, ydoc); - -// UI can read from ydoc.getMap('checklist_answers') directly -const answers = ydoc.getMap('checklist_answers'); - -// create a new answer -const ansId = uuidv4(); -answers.set( - ansId, - new Y.Map({ id: ansId, checklist_id, question_key, answers: new Y.Array(['yes']), critical: false }), -); - -// Yjs + provider handles sync, DO enforces validation. -``` - -## Project-level Y.Doc (checklist PoC) - -To start small we keep _one Y.Doc per project_ (a Durable Object). Each project doc exposes a top-level `checklists` Y.Map where each checklist is a `Y.Map` with fields like `title` and `items` (a `Y.Array`). - -Server (DO): implement a `ProjectDoc` Durable Object which: - -- holds a Y.Doc for the project -- persists the Y.Doc to the DO state -- handles WebSocket connections for real-time sync - -Client: connect to the worker WebSocket at `/api/projects/:projectId`. When connected the server sends the current state as a Yjs update; clients apply updates and push local updates back as encoded Yjs updates. - -Notes: - -- We intentionally do not provide per-checklist HTTP fetch routes; clients read the container Y.Doc and use the `checklistId` inside the document. -- You will need an environment binding for `PROJECT_DOC` in your worker configuration to enable the DO route. diff --git a/packages/web/src/ROBINS-I/checklist-map.js b/packages/web/src/ROBINS-I/checklist-map.js index 1fb90a817..a7dc3ab1e 100644 --- a/packages/web/src/ROBINS-I/checklist-map.js +++ b/packages/web/src/ROBINS-I/checklist-map.js @@ -67,19 +67,32 @@ export const INFORMATION_SOURCES = [ 'Journal article(s)', 'Study protocol', 'Statistical analysis plan (SAP)', - 'Non-commercial registry record (e.g. ClinicalTrials.gov)', - 'Company-owned registry record', + 'Non-commercial registry record (e.g. ClinicalTrials.gov record)', + 'Company-owned registry record (e.g. GSK Clinical Study Register record)', 'Grey literature (e.g. unpublished thesis)', 'Conference abstract(s)', - 'Regulatory document (e.g. CSR, approval package)', + 'Regulatory document (e.g. Clinical Study Report, Drug Approval Package)', 'Individual participant data', 'Research ethics application', - 'Grant database summary (e.g. NIH RePORTER)', + 'Grant database summary (e.g. NIH RePORTER, Research Councils UK Gateway to Research)', 'Personal communication with investigator', 'Personal communication with sponsor', - 'Other', ]; +// Section D: Information sources +export const SECTION_D = { + title: 'Part D: Information Sources', + description: + 'Which of the following sources have you obtained to help you inform your risk of bias judgements (tick as many as apply)?', + otherField: { + id: 'otherSpecify', + label: 'Please specify any additional sources not listed above', + placeholder: 'e.g., Additional data sources, correspondence, supplementary materials...', + type: 'textarea', + stateKey: 'otherSpecify', + }, +}; + // Checklist type definition export const CHECKLIST_TYPES = { ROBINS_I: { @@ -88,6 +101,50 @@ export const CHECKLIST_TYPES = { }, }; +// Planning Stage: List confounding factors +export const PLANNING_SECTION = { + title: 'The ROBINS-I V2 Tool', + subtitle: 'At planning stage: list confounding factors', + p1: { + id: 'p1', + label: 'P1', + text: 'List the important confounding factors relevant to all or most studies on this topic. Specify whether these are particular to specific intervention-outcome combinations.', + placeholder: + 'e.g., Age, baseline disease severity, comorbidities, concomitant medications, socioeconomic status...', + type: 'textarea', + stateKey: 'confoundingFactors', + }, +}; + +// Section A: Specify the result being assessed for risk of bias +export const SECTION_A = { + a1: { + id: 'a1', + label: 'A1', + text: 'Specify the numerical result being assessed', + placeholder: 'e.g., OR = 1.5 (95% CI: 1.2-1.9)', + type: 'textarea', + stateKey: 'numericalResult', + }, + a2: { + id: 'a2', + label: 'A2', + text: 'Provide further details about this result (for example, location in the study report, reason it was chosen)', + optional: true, + placeholder: 'e.g., Table 3, primary outcome analysis', + type: 'textarea', + stateKey: 'furtherDetails', + }, + a3: { + id: 'a3', + label: 'A3', + text: 'Specify the outcome to which this result relates', + placeholder: 'e.g., All-cause mortality at 12 months', + type: 'textarea', + stateKey: 'outcome', + }, +}; + // Section B: Decide whether to proceed with a risk-of-bias assessment export const SECTION_B = { b1: { @@ -109,6 +166,47 @@ export const SECTION_B = { }, }; +// Section C: Specify the (hypothetical) target randomized trial specific to the study +export const SECTION_C = { + description: + "The target randomized trial is either explicitly described by the primary study investigators or implied by the study's design and analysis.", + c1: { + id: 'c1', + label: 'C1', + text: 'Specify the participants and eligibility criteria', + placeholder: 'e.g., Adults aged 18+ with type 2 diabetes, no prior cardiovascular disease', + type: 'textarea', + stateKey: 'participants', + }, + c2: { + id: 'c2', + label: 'C2', + text: 'Specify the intervention strategy', + placeholder: 'e.g., Initiation of metformin 500mg twice daily', + type: 'textarea', + stateKey: 'interventionStrategy', + }, + c3: { + id: 'c3', + label: 'C3', + text: 'Specify the comparator strategy', + placeholder: 'e.g., Initiation of sulfonylurea therapy', + type: 'textarea', + stateKey: 'comparatorStrategy', + }, + c4: { + id: 'c4', + label: 'C4', + text: 'Did the analysis account for switches during follow-up between the intervention strategies being compared, or for other protocol deviations during follow-up?', + type: 'radio', + stateKey: 'isPerProtocol', + options: [ + { value: false, label: 'No (the analysis is estimating the intention-to-treat effect)' }, + { value: true, label: 'Yes (the analysis is estimating the per-protocol effect)' }, + ], + }, +}; + // Domain 1A: Bias due to confounding (Intention-to-Treat Effect) export const DOMAIN_1A = { id: 'domain1a', diff --git a/packages/web/src/components/checklist-ui/ROBINSIChecklist/DomainJudgement.jsx b/packages/web/src/components/checklist-ui/ROBINSIChecklist/DomainJudgement.jsx index ec310b4e8..3a4af9e79 100644 --- a/packages/web/src/components/checklist-ui/ROBINSIChecklist/DomainJudgement.jsx +++ b/packages/web/src/components/checklist-ui/ROBINSIChecklist/DomainJudgement.jsx @@ -1,4 +1,4 @@ -import { For, createUniqueId } from 'solid-js'; +import { For } from 'solid-js'; import { ROB_JUDGEMENTS, BIAS_DIRECTIONS, DOMAIN1_DIRECTIONS } from '@/ROBINS-I/checklist-map.js'; /** @@ -14,13 +14,14 @@ import { ROB_JUDGEMENTS, BIAS_DIRECTIONS, DOMAIN1_DIRECTIONS } from '@/ROBINS-I/ * @param {boolean} [props.disabled] - Whether the selector is disabled */ export function DomainJudgement(props) { - const uniqueId = createUniqueId(); const directionOptions = () => (props.isDomain1 ? DOMAIN1_DIRECTIONS : BIAS_DIRECTIONS); const getJudgementColor = judgement => { switch (judgement) { case 'Low': return 'bg-green-100 border-green-400 text-green-800'; + case 'Low (except for concerns about uncontrolled confounding)': + return 'bg-green-100 border-green-400 text-green-800'; case 'Moderate': return 'bg-yellow-100 border-yellow-400 text-yellow-800'; case 'Serious': @@ -39,43 +40,33 @@ export function DomainJudgement(props) {
Risk of bias judgement
- {judgement => ( - - {/* Clear judgement button */} - {props.judgement && ( - - )}
@@ -88,43 +79,33 @@ export function DomainJudgement(props) {
- {direction => ( - - {/* Clear direction button */} - {props.direction && ( - - )}
)} @@ -139,6 +120,8 @@ export function JudgementBadge(props) { const getColor = () => { switch (props.judgement) { case 'Low': + return 'bg-green-100 text-green-800'; + case 'Low (except for concerns about uncontrolled confounding)': case 'Low (except confounding)': return 'bg-green-100 text-green-800'; case 'Moderate': diff --git a/packages/web/src/components/checklist-ui/ROBINSIChecklist/OverallSection.jsx b/packages/web/src/components/checklist-ui/ROBINSIChecklist/OverallSection.jsx index a056232fb..c87314d8b 100644 --- a/packages/web/src/components/checklist-ui/ROBINSIChecklist/OverallSection.jsx +++ b/packages/web/src/components/checklist-ui/ROBINSIChecklist/OverallSection.jsx @@ -1,4 +1,4 @@ -import { For, Show, createUniqueId } from 'solid-js'; +import { For } from 'solid-js'; import { OVERALL_ROB_JUDGEMENTS, BIAS_DIRECTIONS } from '@/ROBINS-I/checklist-map.js'; import { scoreChecklist } from '@/ROBINS-I/checklist.js'; @@ -11,7 +11,6 @@ import { scoreChecklist } from '@/ROBINS-I/checklist.js'; * @param {boolean} [props.disabled] - Whether the section is disabled */ export function OverallSection(props) { - const uniqueId = createUniqueId(); // Calculated score based on domains const calculatedScore = () => scoreChecklist(props.checklistState); @@ -31,12 +30,16 @@ export function OverallSection(props) { const getJudgementColor = judgement => { 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: @@ -86,43 +89,33 @@ export function OverallSection(props) {
Overall risk of bias judgement
- {judgement => ( - - {/* Clear judgement button */} - - -
@@ -134,43 +127,33 @@ export function OverallSection(props) {
- {direction => ( - - {/* Clear button */} - - -
diff --git a/packages/web/src/components/checklist-ui/ROBINSIChecklist/PlanningSection.jsx b/packages/web/src/components/checklist-ui/ROBINSIChecklist/PlanningSection.jsx new file mode 100644 index 000000000..a1f1b7f9f --- /dev/null +++ b/packages/web/src/components/checklist-ui/ROBINSIChecklist/PlanningSection.jsx @@ -0,0 +1,56 @@ +import { PLANNING_SECTION } from '@/ROBINS-I/checklist-map.js'; + +/** + * Planning Section: List confounding factors at planning stage + * @param {Object} props + * @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 + */ +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] || ''; + + return ( +
+
+

{PLANNING_SECTION.title}

+

{PLANNING_SECTION.subtitle}

+
+ +
+
+