From 0e9a7fe64340e775e2a7c1d68334b08c07b68237 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 10 Jan 2026 22:14:05 -0600 Subject: [PATCH 1/8] add domains --- .claude/settings.json | 3 +- .../{decision-diagrams/1.md => checklist.js} | 0 .../scoring/decision-diagrams/domain-1.md | 138 ++++++++++++ .../scoring/decision-diagrams/domain-2-a.md | 206 ++++++++++++++++++ .../scoring/decision-diagrams/domain-2-b.md | 157 +++++++++++++ .../scoring/decision-diagrams/domain-3.md | 132 +++++++++++ .../scoring/decision-diagrams/domain-4.md | 137 ++++++++++++ .../scoring/decision-diagrams/domain-5.md | 152 +++++++++++++ .../scoring/decision-diagrams/overall.md | 23 ++ .../checklist/ROB2Checklist/scoring/score.js | 0 10 files changed, 947 insertions(+), 1 deletion(-) rename packages/web/src/components/checklist/ROB2Checklist/scoring/{decision-diagrams/1.md => checklist.js} (100%) create mode 100644 packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-1.md create mode 100644 packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-2-a.md create mode 100644 packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-2-b.md create mode 100644 packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-3.md create mode 100644 packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-4.md create mode 100644 packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-5.md create mode 100644 packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/overall.md delete mode 100644 packages/web/src/components/checklist/ROB2Checklist/scoring/score.js diff --git a/.claude/settings.json b/.claude/settings.json index 24448ed81..c12f94c5d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,6 +1,7 @@ { "enabledPlugins": { "frontend-design@claude-plugins-official": true, - "plugin-dev@claude-plugins-official": true + "plugin-dev@claude-plugins-official": true, + "feature-dev@claude-plugins-official": true } } diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/1.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/checklist.js similarity index 100% rename from packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/1.md rename to packages/web/src/components/checklist/ROB2Checklist/scoring/checklist.js diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-1.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-1.md new file mode 100644 index 000000000..26feefca9 --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-1.md @@ -0,0 +1,138 @@ +Signalling questions Elaboration Response options +1.1 Was the allocation +sequence random? + +Answer ‘Yes’ if a random component was used in the sequence generation process. Examples include +computer-generated random numbers; reference to a random number table; coin tossing; shuffling cards +or envelopes; throwing dice; or drawing lots. Minimization is generally implemented with a random +element (at least when the scores are equal), so an allocation sequence that is generated using +minimization should generally be considered to be random. +Answer ‘No’ if no random element was used in generating the allocation sequence or the sequence is +predictable. Examples include alternation; methods based on dates (of birth or admission); patient +record numbers; allocation decisions made by clinicians or participants; allocation based on the +availability of the intervention; or any other systematic or haphazard method. +Answer ‘No information’ if the only information about randomization methods is a statement that the +study is randomized. +In some situations a judgement may be made to answer ‘Probably no’ or ‘Probably yes’. For example, , in +the context of a large trial run by an experienced clinical trials unit, absence of specific information about +generation of the randomization sequence, in a paper published in a journal with rigorously enforced word +count limits, is likely to result in a response of ‘Probably yes’ rather than ‘No information’. Alternatively, if +other (contemporary) trials by the same investigator team have clearly used non-random sequences, it +might be reasonable to assume that the current study was done using similar methods. + +Y/PY/PN/N/NI + +1.2 Was the allocation +sequence concealed until +participants were +enrolled and assigned to +interventions? + +Answer ‘Yes’ if the trial used any form of remote or centrally administered method to allocate +interventions to participants, where the process of allocation is controlled by an external unit or +organization, independent of the enrolment personnel (e.g. independent central pharmacy, telephone or +internet-based randomization service providers). +Answer ‘Yes’ if envelopes or drug containers were used appropriately. Envelopes should be opaque, +sequentially numbered, sealed with a tamper-proof seal and opened only after the envelope has been +irreversibly assigned to the participant. Drug containers should be sequentially numbered and of +identical appearance, and dispensed or administered only after they have been irreversibly assigned to +the participant. This level of detail is rarely provided in reports, and a judgement may be required to +justify an answer of ‘Probably yes’ or ‘Probably no’. +Answer ‘No’ if there is reason to suspect that the enrolling investigator or the participant had knowledge +of the forthcoming allocation. + +Y/PY/PN/N/NI + +5 + +1.3 Did baseline +differences between +intervention groups +suggest a problem with +the randomization +process? + +Note that differences that are compatible with chance do not lead to a risk of bias. A small number of +differences identified as ‘statistically significant’ at the conventional 0.05 threshold should usually be +considered to be compatible with chance. +Answer ‘No’ if no imbalances are apparent or if any observed imbalances are compatible with chance. +Answer ‘Yes’ if there are imbalances that indicate problems with the randomization process, including: +(1) substantial differences between intervention group sizes, compared with the intended allocation +ratio; +or +(2) a substantial excess in statistically significant differences in baseline characteristics between +intervention groups, beyond that expected by chance; or +(3) imbalance in one or more key prognostic factors, or baseline measures of outcome variables, +that is very unlikely to be due to chance and for which the between-group difference is big +enough to result in bias in the intervention effect estimate. +Also answer ‘Yes’ if there are other reasons to suspect that the randomization process was problematic: +(4) excessive similarity in baseline characteristics that is not compatible with chance. +Answer ‘No information’ when there is no useful baseline information available (e.g. abstracts, or studies +that reported only baseline characteristics of participants in the final analysis). +The answer to this question should not influence answers to questions 1.1 or 1.2. For example, if the trial +has large baseline imbalances, but authors report adequate randomization methods, questions 1.1 and +1.2 should still be answered on the basis of the reported adequate methods, and any concerns about the + +imbalance should be raised in the answer to the question 1.3 and reflected in the domain-level risk-of- +bias judgement. + +Trialists may undertake analyses that attempt to deal with flawed randomization by controlling for +imbalances in prognostic factors at baseline. To remove the risk of bias caused by problems in the +randomization process, it would be necessary to know, and measure, all the prognostic factors that were +imbalanced at baseline. It is unlikely that all important prognostic factors are known and measured, so +such analyses will at best reduce the risk of bias. If review authors wish to assess the risk of bias in a trial +that controlled for baseline imbalances in order to mitigate failures of randomization, the study should +be assessed using the ROBINS-I tool. + +Y/PY/PN/N/NI + +Risk-of-bias judgement See algorithm. Low / High / Some +concerns + +6 + +Algorithm for suggested judgement of risk of bias arising from the randomization process + +Optional: What is the +predicted direction of +bias arising from the +randomization process? + +If the likely direction of bias can be predicted, it is helpful to state this. The direction might be +characterized either as being towards (or away from) the null, or as being in favour of one of the +interventions. + +NA / Favours +experimental / +Favours comparator / +Towards null /Away +from null / +Unpredictable + +flowchart LR +A["1.2 Allocation sequence concealed?"] + + B["1.1 Allocation sequence random?"] + C1["1.3 Baseline imbalances suggest a problem?"] + C2["1.3 Baseline imbalances suggest a problem?"] + + L["Low risk"] + M["Some concerns"] + H["High risk"] + + %% Paths from allocation concealment + A -- "Y/PY" --> B + A -- "NI" --> C2 + A -- "N/PN" --> H + + %% Paths from random sequence + B -- "Y/PY/NI" --> C1 + B -- "N/PN" --> M + + %% Baseline imbalance (top branch) + C1 -- "N/PN/NI" --> L + C1 -- "Y/PY" --> M + + %% Baseline imbalance (middle branch) + C2 -- "N/PN/NI" --> M + C2 -- "Y/PY" --> H diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-2-a.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-2-a.md new file mode 100644 index 000000000..444468d99 --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-2-a.md @@ -0,0 +1,206 @@ +Domain 2: Risk of bias due to deviations from the intended interventions (effect of assignment to intervention) +Signalling questions Elaboration Response options +2.1. Were participants +aware of their assigned +intervention during the +trial? + +If participants are aware of their assigned intervention it is more likely that health-related behaviours will +differ between the intervention groups. Blinding participants, most commonly through use of a placebo +or sham intervention, may prevent such differences. If participants experienced side effects or toxicities +that they knew to be specific to one of the interventions, answer this question ‘Yes’ or ‘Probably yes’. + +Y/PY/PN/N/NI + +2.2. Were carers and +people delivering the +interventions aware of +participants' assigned +intervention during the +trial? + +If carers or people delivering the interventions are aware of the assigned intervention then its +implementation, or administration of non-protocol interventions, may differ between the intervention +groups. Blinding may prevent such differences. If participants experienced side effects or toxicities that +carers or people delivering the interventions knew to be specific to one of the interventions, answer +question ‘Yes’ or ‘Probably yes’. If randomized allocation was not concealed, then it is likely that carers +and people delivering the interventions were aware of participants' assigned intervention during the +trial. + +Y/PY/PN/N/NI + +8 + +2.3. If Y/PY/NI to 2.1 or +2.2: Were there +deviations from the +intended intervention +that arose because of the +trial context? + +For the effect of assignment to intervention, this domain assesses problems that arise when changes from +assigned intervention that are inconsistent with the trial protocol arose because of the trial context. We +use the term trial context to refer to effects of recruitment and engagement activities on trial participants +and when trial personnel (carers or people delivering the interventions) undermine the implementation of +the trial protocol in ways that would not happen outside the trial. For example, the process of securing +informed consent may lead participants subsequently assigned to the comparator group to feel unlucky +and therefore seek the experimental intervention, or other interventions that improve their prognosis. +Answer ‘Yes’ or ‘Probably yes’ only if there is evidence, or strong reason to believe, that the trial context +led to failure to implement the protocol interventions or to implementation of interventions not allowed +by the protocol. +Answer ‘No’ or ‘Probably no’ if there were changes from assigned intervention that are inconsistent with +the trial protocol, such as non-adherence to intervention, but these are consistent with what could occur +outside the trial context. +Answer ‘No’ or ‘Probably no’ for changes to intervention that are consistent with the trial protocol, for +example cessation of a drug intervention because of acute toxicity or use of additional interventions whose +aim is to treat consequences of one of the intended interventions. +If blinding is compromised because participants report side effects or toxicities that are specific to one of +the interventions, answer ‘Yes’ or ‘Probably yes’ only if there were changes from assigned intervention +that are inconsistent with the trial protocol and arose because of the trial context. +The answer ‘No information’ may be appropriate, because trialists do not always report whether +deviations arose because of the trial context. + +NA/Y/PY/PN/N/NI + +2.4 If Y/PY to 2.3: Were +these deviations likely to +have affected the +outcome? + +Changes from assigned intervention that are inconsistent with the trial protocol and arose because of the +trial context will impact on the intervention effect estimate if they affect the outcome, but not +otherwise. + +NA/Y/PY/PN/N/NI + +9 + +2.5. If Y/PY/NI to 2.4: +Were these deviations +from intended +intervention balanced +between groups? + +Changes from assigned intervention that are inconsistent with the trial protocol and arose because of the +trial context are more likely to impact on the intervention effect estimate if they are not balanced +between the intervention groups. + +NA/Y/PY/PN/N/NI + +2.6 Was an appropriate +analysis used to estimate +the effect of assignment +to intervention? + +Both intention-to-treat (ITT) analyses and modified intention-to-treat (mITT) analyses excluding +participants with missing outcome data should be considered appropriate. Both naïve ‘per-protocol’ +analyses (excluding trial participants who did not receive their assigned intervention) and ‘as treated’ +analyses (in which trial participants are grouped according to the intervention that they received, rather +than according to their assigned intervention) should be considered inappropriate. Analyses excluding + +eligible trial participants post-randomization should also be considered inappropriate, but post- +randomization exclusions of ineligible participants (when eligibility was not confirmed until after + +randomization, and could not have been influenced by intervention group assignment) can be +considered appropriate. + +Y/PY/PN/N/NI + +2.7 If N/PN/NI to 2.6: +Was there potential for a +substantial impact (on +the result) of the failure +to analyse participants in +the group to which they +were randomized? + +This question addresses whether the number of participants who were analysed in the wrong +intervention group, or excluded from the analysis, was sufficient that there could have been a substantial +impact on the result. It is not possible to specify a precise rule: there may be potential for substantial +impact even if fewer than 5% of participants were analysed in the wrong group or excluded, if the +outcome is rare or if exclusions are strongly related to prognostic factors. + +NA/Y/PY/PN/N/NI + +Risk-of-bias judgement See algorithm. Low / High / Some +concerns + +Optional: What is the +predicted direction of +bias due to deviations +from intended +interventions? + +If the likely direction of bias can be predicted, it is helpful to state this. The direction might be +characterized either as being towards (or away from) the null, or as being in favour of one of the +interventions. + +NA / Favours +experimental / Favours +comparator / Towards +null /Away from null / +Unpredictable + +flowchart LR +%% ----------------------- +%% Part 1 +%% ----------------------- +subgraph P1["Part 1: Questions 2.1 to 2.5"] +Q21["2.1 Participants aware of intervention?\n\n2.2 Personnel aware of intervention?"] +Q23["2.3 Deviations that arose because of the trial context?"] +Q24["2.4 Deviations affect outcome?"] +Q25["2.5 Deviations balanced between groups?"] + + P1Low["Low risk"] + P1Some["Some concerns"] + P1High["High risk"] + + Q21 -- "Both N/PN" --> P1Low + Q21 -- "Either Y/PY/NI" --> Q23 + + Q23 -- "N/PN" --> P1Low + Q23 -- "NI" --> P1Some + Q23 -- "Y/PY" --> Q24 + + Q24 -- "N/PN" --> P1Some + Q24 -- "Y/PY/NI" --> Q25 + + Q25 -- "Y/PY" --> P1Some + Q25 -- "N/PN/NI" --> P1High + end + + %% ----------------------- + %% Part 2 + %% ----------------------- + subgraph P2["Part 2: Questions 2.6 & 2.7"] + Q26["2.6 Appropriate analysis to estimate the effect of assignment?"] + Q27["2.7 Substantial impact of the failure to analyse participants in randomized groups?"] + + P2Low["Low risk"] + P2Some["Some concerns"] + P2High["High risk"] + + Q26 -- "Y/PY" --> P2Low + Q26 -- "N/PN/NI" --> Q27 + + Q27 -- "N/PN" --> P2Some + Q27 -- "Y/PY/NI" --> P2High + end + + %% ----------------------- + %% Overall domain judgement + %% ----------------------- + subgraph D["Criteria for the domain"] + DLow["Low risk"] + DSome["Some concerns"] + DHigh["High risk"] + end + + P1Low --> DLow + P2Low --> DLow + + P1Some --> DSome + P2Some --> DSome + + P1High --> DHigh + P2High --> DHigh diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-2-b.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-2-b.md new file mode 100644 index 000000000..f1de23448 --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-2-b.md @@ -0,0 +1,157 @@ +Domain 2: Risk of bias due to deviations from the intended interventions (effect of adhering to intervention) +Signalling questions Elaboration Response options +2.1. Were participants +aware of their assigned +intervention during the +trial? + +If participants are aware of their assigned intervention it is more likely that health-related behaviours will +differ between the intervention groups. Blinding participants, most commonly through use of a placebo +or sham intervention, may prevent such differences. If participants experienced side effects or toxicities +that they knew to be specific to one of the interventions, answer this question ‘Yes’ or ‘Probably yes’. + +Y/PY/PN/N/NI + +2.2. Were carers and +people delivering the +interventions aware of +participants' assigned +intervention during the +trial? + +If carers or people delivering the interventions are aware of the assigned intervention then its +implementation, or administration of non-protocol interventions, may differ between the intervention +groups. Blinding may prevent such differences. If participants experienced side effects or toxicities that +carers or people delivering the interventions knew to be specific to one of the interventions, answer ‘Yes’ +or ‘Probably yes’. If randomized allocation was not concealed, then it is likely that carers and people +delivering the interventions were aware of participants' assigned intervention during the trial. + +Y/PY/PN/N/NI + +2.3. [If applicable:] If +Y/PY/NI to 2.1 or 2.2: + +Were important non- +protocol interventions + +balanced across +intervention groups? + +This question is asked only if the preliminary considerations specify that the assessment will address + +imbalance of important non-protocol interventions between intervention groups. Important non- +protocol interventions are the additional interventions or exposures that: (1) are inconsistent with the + +trial protocol; (2) trial participants might receive with or after starting their assigned intervention; and (3) +are prognostic for the outcome. Risk of bias will be higher if there is imbalance in such interventions +between the intervention groups. + +NA/Y/PY/PN/N/NI + +2.4. [If applicable:] Were +there failures in +implementing the +intervention that could +have affected the +outcome? + +This question is asked only if the preliminary considerations specify that the assessment will address +failures in implementing the intervention that could have affected the outcome. Risk of bias will be +higher if the intervention was not implemented as intended by, for example, the health care +professionals delivering care. Answer ‘No’ or ‘Probably no’ if implementation of the intervention was +successful for most participants. + +NA/Y/PY/PN/N/NI + +2.5. [If applicable:] Was +there non-adherence to +the assigned intervention +regimen that could have +affected participants’ +outcomes? + +This question is asked only if the preliminary considerations specify that the assessment will address non- +adherence that could have affected participants’ outcomes. Non-adherence includes imperfect + +compliance with a sustained intervention, cessation of intervention, crossovers to the comparator +intervention and switches to another active intervention. Consider available information on the +proportion of study participants who continued with their assigned intervention throughout follow up, +and answer ‘Yes’ or ‘Probably yes’ if the proportion who did not adhere is high enough to raise concerns. +Answer ‘No’ for studies of interventions that are administered once, so that imperfect adherence is not +possible, and all or most participants received the assigned intervention. + +NA/Y/PY/PN/N/NI + +12 + +2.6. If N/PN/NI to 2.3, or +Y/PY/NI to 2.4 or 2.5: +Was an appropriate +analysis used to estimate +the effect of adhering to +the intervention? + +Both ‘ naïve ‘per-protocol’ analyses (excluding trial participants who did not receive their allocated +intervention) and ‘as treated’ analyses (comparing trial participants according to the intervention they +actually received) will usually be inappropriate for estimating the effect of adhering to intervention (the +‘per-protocol’ effect). However, it is possible to use data from a randomized trial to derive an unbiased +estimate of the effect of adhering to intervention. Examples of appropriate methods include: (1) +instrumental variable analyses to estimate the effect of receiving the assigned intervention in trials in +which a single intervention, administered only at baseline and with all-or-nothing adherence, is compared +with standard care; and (2) inverse probability weighting to adjust for censoring of participants who cease +adherence to their assigned intervention, in trials of sustained treatment strategies. These methods +depend on strong assumptions, which should be appropriate and justified if the answer to this question is +‘Yes’ or ‘Probably yes’. It is possible that a paper reports an analysis based on such methods without +reporting information on the deviations from intended intervention, but it would be hard to judge such an +analysis to be appropriate in the absence of such information. +If an important non-protocol intervention was administered to all participants in one intervention group, +adjustments cannot be made to overcome this. +Some examples of analysis strategies that would not be appropriate to estimate the effect of adhering to +intervention are (i) ‘Intention to treat (ITT) analysis’, (ii) ‘per protocol analysis’, (iii) ‘as-treated analysis’, +(iv) ‘analysis by treatment received’. + +NA/Y/PY/PN/N/NI + +Risk-of-bias judgement See algorithm. Low / High / Some +concerns + +Optional: What is the +predicted direction of +bias due to deviations +from intended +interventions? + +If the likely direction of bias can be predicted, it is helpful to state this. The direction might be +characterized either as being towards (or away from) the null, or as being in favour of one of the +interventions. + +NA / Favours +experimental / Favours +comparator / Towards +null /Away from null + +flowchart LR +Q21["2.1 Participants aware of intervention?\n\n2.2 Personnel aware of intervention?"] +Q23["2.3 Balanced non-protocol interventions?"] +Q24["2.4 Failures in implementation affecting outcome?\n\n2.5 Non-adherence affecting outcome?"] +Q26["2.6 Appropriate analysis to estimate the effect of adhering?"] + + L["Low risk"] + M["Some concerns"] + H["High risk"] + + %% Awareness + Q21 -- "Both N/PN" --> Q24 + Q21 -- "Either Y/PY/NI" --> Q23 + + %% Balanced non-protocol interventions + Q23 -- "NA/Y/PY" --> Q24 + Q23 -- "N/PN/NI" --> Q26 + + %% Failures / non-adherence + Q24 -- "Both NA/N/PN" --> L + Q24 -- "Either Y/PY/NI" --> Q26 + + %% Analysis appropriateness + Q26 -- "Y/PY" --> M + Q26 -- "N/PN/NI" --> H diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-3.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-3.md new file mode 100644 index 000000000..dfabfa4c5 --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-3.md @@ -0,0 +1,132 @@ +Domain 3: Risk of bias due to missing outcome data +Signalling questions Elaboration Response options +3.1 Were data for this +outcome available for all, +or nearly all, participants +randomized? + +The appropriate study population for an analysis of the intention to treat effect is all randomized +participants. +“Nearly all” should be interpreted as that the number of participants with missing outcome data is +sufficiently small that their outcomes, whatever they were, could have made no important difference to +the estimated effect of intervention. +For continuous outcomes, availability of data from 95% of the participants will often be sufficient. For +dichotomous outcomes, the proportion required is directly linked to the risk of the event. If the observed +number of events is much greater than the number of participants with missing outcome data, the bias +would necessarily be small. +Only answer ‘No information’ if the trial report provides no information about the extent of missing +outcome data. This situation will usually lead to a judgement that there is a high risk of bias due to missing +outcome data. +Note that imputed data should be regarded as missing data, and not considered as ‘outcome data’ in +the context of this question. + +Y/PY/PN/N/NI + +3.2 If N/PN/NI to 3.1: Is +there evidence that the +result was not biased by +missing outcome data? + +Evidence that the result was not biased by missing outcome data may come from: (1) analysis methods +that correct for bias; or (2) sensitivity analyses showing that results are little changed under a range of +plausible assumptions about the relationship between missingness in the outcome and its true value. + +However, imputing the outcome variable, either through methods such as ‘last-observation-carried- +forward’ or via multiple imputation based only on intervention group, should not be assumed to correct + +for bias due to missing outcome data. + +NA/Y/PY/PN/N + +3.3 If N/PN to 3.2: Could +missingness in the +outcome depend on its +true value? + +If loss to follow up, or withdrawal from the study, could be related to participants’ health status, then it +is possible that missingness in the outcome was influenced by its true value. However, if all missing +outcome data occurred for documented reasons that are unrelated to the outcome then the risk of bias +due to missing outcome data will be low (for example, failure of a measuring device or interruptions to +routine data collection). +In time-to-event analyses, participants censored during trial follow-up, for example because they +withdrew from the study, should be regarded as having missing outcome data, even though some of their +follow up is included in the analysis. Note that such participants may be shown as included in analyses in +CONSORT flow diagrams. + +NA/Y/PY/PN/N/NI + +15 + +3.4 If Y/PY/NI to 3.3: Is it +likely that missingness in +the outcome depended on +its true value? + +This question distinguishes between situations in which (i) missingness in the outcome could depend on +its true value (assessed as ‘Some concerns’) from those in which (ii) it is likely that missingness in the +outcome depended on its true value (assessed as ‘High risk of bias’). Five reasons for answering ‘Yes’ are: + +1. Differences between intervention groups in the proportions of missing outcome data. If there is a + difference between the effects of the experimental and comparator interventions on the outcome, + and the missingness in the outcome is influenced by its true value, then the proportions of missing + outcome data are likely to differ between intervention groups. Such a difference suggests a risk of + bias due to missing outcome data, because the trial result will be sensitive to missingness in the + outcome being related to its true value. For time-to-event-data, the analogue is that rates of + censoring (loss to follow-up) differ between the intervention groups. +2. Reported reasons for missing outcome data provide evidence that missingness in the outcome + depends on its true value; +3. Reported reasons for missing outcome data differ between the intervention groups; +4. The circumstances of the trial make it likely that missingness in the outcome depends on its true + value. For example, in trials of interventions to treat schizophrenia it is widely understood that + continuing symptoms make drop out more likely. +5. In time-to-event analyses, participants’ follow up is censored when they stop or change their + assigned intervention, for example because of drug toxicity or, in cancer trials, when participants + switch to second-line chemotherapy. + Answer ‘No’ if the analysis accounted for participant characteristics that are likely to explain the + relationship between missingness in the outcome and its true value. + +NA/Y/PY/PN/N/NI + +Risk-of-bias judgement See algorithm. Low / High / Some +concerns + +Optional: What is the +predicted direction of bias +due to missing outcome +data? + +If the likely direction of bias can be predicted, it is helpful to state this. The direction might be +characterized either as being towards (or away from) the null, or as being in favour of one of the +interventions. + +NA / Favours +experimental / Favours +comparator / Towards +null /Away from null / +Unpredictable + +flowchart LR +Q31["3.1 Outcome data for all participants?"] +Q32["3.2 Evidence that result is not biased?"] +Q33["3.3 Missingness could depend on true value?"] +Q34["3.4 Likely that missingness depended on true value?"] + + L["Low risk"] + M["Some concerns"] + H["High risk"] + + %% Question 3.1 + Q31 -- "Y/PY" --> L + Q31 -- "N/PN/NI" --> Q32 + + %% Question 3.2 + Q32 -- "Y/PY" --> L + Q32 -- "N/PN" --> Q33 + + %% Question 3.3 + Q33 -- "N/PN" --> L + Q33 -- "Y/PY/NI" --> Q34 + + %% Question 3.4 + Q34 -- "N/PN" --> M + Q34 -- "Y/PY/NI" --> H diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-4.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-4.md new file mode 100644 index 000000000..853cc093f --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-4.md @@ -0,0 +1,137 @@ +Domain 4: Risk of bias in measurement of the outcome +Signalling questions Elaboration Response options +4.1 Was the method of +measuring the outcome +inappropriate? + +This question aims to identify methods of outcome measurement (data collection) that are unsuitable for +the outcome they are intended to evaluate. The question does not aim to assess whether the choice of +outcome being evaluated was sensible (e.g. because it is a surrogate or proxy for the main outcome of +interest). In most circumstances, for pre-specified outcomes, the answer to this question will be ‘No’ or +‘Probably no’. +Answer ‘Yes’ or ‘Probably yes’ if the method of measuring the outcome is inappropriate, for example +because: +(1) it is unlikely to be sensitive to plausible intervention effects (e.g. important ranges of outcome +values fall outside levels that are detectable using the measurement method); or +(2) the measurement instrument has been demonstrated to have poor validity. + +Y/PY/PN/N/NI + +4.2 Could measurement +or ascertainment of the +outcome have differed +between intervention +groups? + +Comparable methods of outcome measurement (data collection) involve the same measurement +methods and thresholds, used at comparable time points. Differences between intervention groups may +arise because of ‘diagnostic detection bias’ in the context of passive collection of outcome data, or if an +intervention involves additional visits to a healthcare provider, leading to additional opportunities for +outcome events to be identified. + +Y/PY/PN/N/NI + +4.3 If N/PN/NI to 4.1 and +4.2: Were outcome +assessors aware of the +intervention received by +study participants? + +Answer ‘No’ if outcome assessors were blinded to intervention status. For participant-reported +outcomes, the outcome assessor is the study participant. + +NA/Y/PY/PN/N/NI + +4.4 If Y/PY/NI to 4.3: +Could assessment of the +outcome have been +influenced by knowledge +of intervention received? + +Knowledge of the assigned intervention could influence participant-reported outcomes (such as level of +pain), observer-reported outcomes involving some judgement, and intervention provider decision +outcomes. They are unlikely to influence observer-reported outcomes that do not involve judgement, for +example all-cause mortality. + +NA/Y/PY/PN/N/NI + +18 + +4.5 If Y/PY/NI to 4.4: Is it +likely that assessment of +the outcome was +influenced by knowledge +of intervention received? + +This question distinguishes between situations in which (i) knowledge of intervention status could have +influenced outcome assessment but there is no reason to believe that it did (assessed as ‘Some +concerns’) from those in which (ii) knowledge of intervention status was likely to influence outcome +assessment (assessed as ‘High’). When there are strong levels of belief in either beneficial or harmful +effects of the intervention, it is more likely that the outcome was influenced by knowledge of the +intervention received. Examples may include patient-reported symptoms in trials of homeopathy, or +assessments of recovery of function by a physiotherapist who delivered the intervention. + +NA/Y/PY/PN/N/NI + +Risk-of-bias judgement See algorithm. Low / High / Some +concerns + +Optional: What is the +predicted direction of +bias in measurement of +the outcome? + +If the likely direction of bias can be predicted, it is helpful to state this. The direction might be +characterized either as being towards (or away from) the null, or as being in favour of one of the +interventions. + +NA / Favours +experimental / Favours +comparator / Towards +null /Away from null / +Unpredictable + +flowchart LR +Q41["4.1 Method of measuring the outcome inappropriate?"] +Q42["4.2 Measurement or ascertainment of outcome differ between groups?"] + + Q43a["4.3 Outcome assessors aware of intervention received?"] + Q44a["4.4 Could assessment have been influenced by knowledge of intervention?"] + Q45a["4.5 Likely that assessment was influenced by knowledge of intervention?"] + + Q43b["4.3 Outcome assessors aware of intervention received?"] + Q44b["4.4 Could assessment have been influenced by knowledge of intervention?"] + Q45b["4.5 Likely that assessment was influenced by knowledge of intervention?"] + + L["Low risk"] + M["Some concerns"] + H["High risk"] + + %% 4.1 + Q41 -- "N/PN/NI" --> Q42 + Q41 -- "Y/PY" --> H + + %% 4.2 + Q42 -- "N/PN" --> Q43a + Q42 -- "NI" --> Q43b + Q42 -- "Y/PY" --> H + + %% Branch A (from N/PN) + Q43a -- "N/PN" --> L + Q43a -- "Y/PY/NI" --> Q44a + + Q44a -- "N/PN" --> L + Q44a -- "Y/PY/NI" --> Q45a + + Q45a -- "N/PN" --> M + Q45a -- "Y/PY/NI" --> H + + %% Branch B (from NI) + Q43b -- "N/PN" --> M + Q43b -- "Y/PY/NI" --> Q44b + + Q44b -- "N/PN" --> M + Q44b -- "Y/PY/NI" --> Q45b + + Q45b -- "N/PN" --> M + Q45b -- "Y/PY/NI" --> H diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-5.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-5.md new file mode 100644 index 000000000..ff59697e2 --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/domain-5.md @@ -0,0 +1,152 @@ +Domain 5: Risk of bias in selection of the reported result +Signalling questions Elaboration Response options +5.1 Were the data that +produced this result +analysed in accordance with +a pre-specified analysis plan +that was finalized before +unblinded outcome data +were available for analysis? + +If the researchers’ pre-specified intentions are available in sufficient detail, then planned outcome +measurements and analyses can be compared with those presented in the published report(s). To +avoid the possibility of selection of the reported result, finalization of the analysis intentions must +precede availability of unblinded outcome data to the trial investigators. +Changes to analysis plans that were made before unblinded outcome data were available, or that +were clearly unrelated to the results (e.g. due to a broken machine making data collection impossible) +do not raise concerns about bias in selection of the reported result. + +Y/PY/PN/N/NI + +Is the numerical result being +assessed likely to have been +selected, on the basis of the +results, from... +5.2. ... multiple eligible +outcome measurements +(e.g. scales, definitions, +time points) within the +outcome domain? + +A particular outcome domain (i.e. a true state or endpoint of interest) may be measured in multiple +ways. For example, the domain pain may be measured using multiple scales (e.g. a visual analogue + +scale and the McGill Pain Questionnaire), each at multiple time points (e.g. 3, 6 and 12 weeks post- +treatment). If multiple measurements were made, but only one or a subset is reported on the basis of + +the results (e.g. statistical significance), there is a high risk of bias in the fully reported result. +Attention should be restricted to outcome measurements that are eligible for consideration by the +RoB 2 tool user. For example, if only a result using a specific measurement scale is eligible for +inclusion in a meta-analysis (e.g. Hamilton Depression Rating Scale), and this is reported by the trial, +then there would not be an issue of selection even if this result was reported (on the basis of the +results) in preference to the result from a different measurement scale (e.g. Beck Depression +Inventory). +Answer ‘Yes’ or ‘Probably yes’ if: +There is clear evidence (usually through examination of a trial protocol or statistical analysis plan) +that a domain was measured in multiple eligible ways, but data for only one or a subset of +measures is fully reported (without justification), and the fully reported result is likely to have been +selected on the basis of the results. Selection on the basis of the results can arise from a desire for +findings to be newsworthy, sufficiently noteworthy to merit publication, or to confirm a prior +hypothesis. For example, trialists who have a preconception, or vested interest in showing, that an + +Y/PY/PN/N/NI + +21 + +experimental intervention is beneficial may be inclined to report outcome measurements +selectively that are favourable to the experimental intervention. +Answer ‘No’ or ‘Probably no’ if: +There is clear evidence (usually through examination of a trial protocol or statistical analysis plan) +that all eligible reported results for the outcome domain correspond to all intended outcome +measurements. +or +There is only one possible way in which the outcome domain can be measured (hence there is no +opportunity to select from multiple measures). +or +Outcome measurements are inconsistent across different reports on the same trial, but the +trialists have provided the reason for the inconsistency and it is not related to the nature of the +results. +Answer ‘No information’ if: +Analysis intentions are not available, or the analysis intentions are not reported in sufficient detail to +enable an assessment, and there is more than one way in which the outcome domain could have +been measured. + +5.3 ... multiple eligible +analyses of the data? + +A particular outcome measurement may be analysed in multiple ways. Examples include: unadjusted +and adjusted models; final value vs change from baseline vs analysis of covariance; transformations of +variables; different definitions of composite outcomes (e.g. ‘major adverse event’); conversion of +continuously scaled outcome to categorical data with different cut-points; different sets of covariates +for adjustment; and different strategies for dealing with missing data. Application of multiple +methods generates multiple effect estimates for a specific outcome measurement. If multiple +estimates are generated but only one or a subset is reported on the basis of the results (e.g. statistical +significance), there is a high risk of bias in the fully reported result. Attention should be restricted to +analyses that are eligible for consideration by the RoB 2 tool user. For example, if only the result from +an analysis of post-intervention values is eligible for inclusion in a meta-analysis (e.g. at 12 weeks +after randomization), and this is reported by the trial, then there would not be an issue of selection +even if this result was reported (on the basis of the results) in preference to the result from an +analysis of changes from baseline. +Answer ‘Yes’ or ‘Probably yes’ if: + +Y/PY/PN/N/NI + +22 + +There is clear evidence (usually through examination of a trial protocol or statistical analysis plan) +that a measurement was analysed in multiple eligible ways, but data for only one or a subset of +analyses is fully reported (without justification), and the fully reported result is likely to have been +selected on the basis of the results. Selection on the basis of the results arises from a desire for +findings to be newsworthy, sufficiently noteworthy to merit publication, or to confirm a prior +hypothesis. For example, trialists who have a preconception or vested interest in showing that an +experimental intervention is beneficial may be inclined to selectively report analyses that are +favourable to the experimental intervention. +Answer ‘No’ or ‘Probably no’ if: +There is clear evidence (usually through examination of a trial protocol or statistical analysis plan) +that all eligible reported results for the outcome measurement correspond to all intended +analyses. +or +There is only one possible way in which the outcome measurement can be analysed (hence there +is no opportunity to select from multiple analyses). +or +Analyses are inconsistent across different reports on the same trial, but the trialists have provided +the reason for the inconsistency and it is not related to the nature of the results. +Answer ‘No information’ if: +Analysis intentions are not available, or the analysis intentions are not reported in sufficient detail to +enable an assessment, and there is more than one way in which the outcome measurement could +have been analysed. + +Risk-of-bias judgement See algorithm. Low / High / Some +concerns + +Optional: What is the +predicted direction of bias +due to selection of the +reported result? + +If the likely direction of bias can be predicted, it is helpful to state this. The direction might be +characterized either as being towards (or away from) the null, or as being in favour of one of the +interventions. + +NA / Favours +experimental / Favours +comparator / Towards +null /Away from null / +Unpredictable + +flowchart LR +Q52["Result selected from…\n\n5.2 …multiple outcome measurements?\n\n5.3 …multiple analyses of the data?"] +Q51["5.1 Trial analysed in accordance with a pre-specified plan?"] + + L["Low risk"] + M["Some concerns"] + H["High risk"] + + %% From result selection questions + Q52 -- "Both N/PN" --> Q51 + Q52 -- "At least one NI,\nbut neither Y/PY" --> M + Q52 -- "Either Y/PY" --> H + + %% From pre-specified analysis plan + Q51 -- "Y/PY" --> L + Q51 -- "N/PN/NI" --> M diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/overall.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/overall.md new file mode 100644 index 000000000..7ea38d050 --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/overall.md @@ -0,0 +1,23 @@ +Overall risk of bias +Risk-of-bias judgement Low / High / Some +concerns + +Optional: What is the overall +predicted direction of bias for this +outcome? + +Favours experimental / +Favours comparator / +Towards null /Away from +null / Unpredictable / NA + +Overall risk-of-bias judgement Criteria +Low risk of bias The study is judged to be at low risk of bias for all domains for this result. +Some concerns The study is judged to raise some concerns in at least one domain for this result, but not to be at high risk of bias for any + +domain. + +High risk of bias The study is judged to be at high risk of bias in at least one domain for this result. + +Or +The study is judged to have diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/score.js b/packages/web/src/components/checklist/ROB2Checklist/scoring/score.js deleted file mode 100644 index e69de29bb..000000000 From 6a41f0b4bb54f2a6ee5a2c7e2a44ea4073a2c939 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sun, 11 Jan 2026 01:16:20 -0600 Subject: [PATCH 2/8] rob-2 mvp working --- .../audits/rob2-checklist-implementation.md | 172 ++++ packages/shared/package.json | 8 + .../src/checklists/__tests__/rob2.test.ts | 804 ++++++++++++++++++ packages/shared/src/checklists/index.ts | 12 + .../shared/src/checklists/rob2/answers.ts | 175 ++++ packages/shared/src/checklists/rob2/create.ts | 142 ++++ packages/shared/src/checklists/rob2/index.ts | 18 + packages/shared/src/checklists/rob2/schema.ts | 403 +++++++++ .../shared/src/checklists/rob2/scoring.ts | 677 +++++++++++++++ packages/shared/src/checklists/types.ts | 60 +- packages/web/src/checklist-registry/index.js | 15 + packages/web/src/checklist-registry/types.js | 16 +- .../components/checklist/ChecklistWithPdf.jsx | 1 + .../checklist/ChecklistYjsWrapper.jsx | 32 +- .../components/checklist/GenericChecklist.jsx | 11 + .../ROB2Checklist/DomainJudgement.jsx | 132 +++ .../checklist/ROB2Checklist/DomainSection.jsx | 184 ++++ .../ROB2Checklist/OverallSection.jsx | 176 ++++ .../ROB2Checklist/PreliminarySection.jsx | 299 +++++++ .../checklist/ROB2Checklist/ROB2Checklist.jsx | 149 ++++ .../ROB2Checklist/ScoringSummary.jsx | 234 +++++ .../ROB2Checklist/SignallingQuestion.jsx | 122 +++ .../checklist/ROB2Checklist/checklist-map.js | 47 + .../checklist/ROB2Checklist/checklist.js | 63 ++ .../checklist/ROB2Checklist/index.js | 22 + .../scoring/decision-diagrams/preliminary.md | 37 + .../useProject/checklists/handlers/rob2.js | 312 +++++++ .../primitives/useProject/checklists/index.js | 19 + .../web/src/primitives/useProject/index.js | 2 + 29 files changed, 4336 insertions(+), 8 deletions(-) create mode 100644 packages/docs/audits/rob2-checklist-implementation.md create mode 100644 packages/shared/src/checklists/__tests__/rob2.test.ts create mode 100644 packages/shared/src/checklists/rob2/answers.ts create mode 100644 packages/shared/src/checklists/rob2/create.ts create mode 100644 packages/shared/src/checklists/rob2/index.ts create mode 100644 packages/shared/src/checklists/rob2/schema.ts create mode 100644 packages/shared/src/checklists/rob2/scoring.ts create mode 100644 packages/web/src/components/checklist/ROB2Checklist/DomainJudgement.jsx create mode 100644 packages/web/src/components/checklist/ROB2Checklist/DomainSection.jsx create mode 100644 packages/web/src/components/checklist/ROB2Checklist/OverallSection.jsx create mode 100644 packages/web/src/components/checklist/ROB2Checklist/PreliminarySection.jsx create mode 100644 packages/web/src/components/checklist/ROB2Checklist/ROB2Checklist.jsx create mode 100644 packages/web/src/components/checklist/ROB2Checklist/ScoringSummary.jsx create mode 100644 packages/web/src/components/checklist/ROB2Checklist/SignallingQuestion.jsx create mode 100644 packages/web/src/components/checklist/ROB2Checklist/checklist-map.js create mode 100644 packages/web/src/components/checklist/ROB2Checklist/checklist.js create mode 100644 packages/web/src/components/checklist/ROB2Checklist/index.js create mode 100644 packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/preliminary.md create mode 100644 packages/web/src/primitives/useProject/checklists/handlers/rob2.js diff --git a/packages/docs/audits/rob2-checklist-implementation.md b/packages/docs/audits/rob2-checklist-implementation.md new file mode 100644 index 000000000..4f199af69 --- /dev/null +++ b/packages/docs/audits/rob2-checklist-implementation.md @@ -0,0 +1,172 @@ +# ROB-2 Checklist Implementation + +**Date:** 2026-01-10 +**Branch:** `262-add-rob-2` +**Status:** Core implementation complete + +## Overview + +Implemented the RoB 2 (Risk of Bias 2) checklist for assessing risk of bias in randomized trials. This is the third checklist type in CoRATES, following AMSTAR2 and ROBINS-I. + +ROB-2 is the Cochrane Collaboration's tool for assessing risk of bias in randomized controlled trials. It evaluates bias across 5 domains with signalling questions that feed into algorithmic judgements. + +## What Was Accomplished + +### Shared Package (`@corates/shared`) + +Created the core ROB-2 logic in `packages/shared/src/checklists/rob2/`: + +| File | Purpose | +|------|---------| +| `schema.ts` | Question definitions, domain structures, response types, constants | +| `scoring.ts` | Decision algorithms for each domain (from official ROB-2 decision diagrams) | +| `create.ts` | Factory function `createROB2Checklist()` | +| `answers.ts` | Utilities: `scoreROB2Checklist`, `isROB2Complete`, `getAnswers`, `getDomainSummary` | +| `index.ts` | Module exports | + +**Key schema elements:** +- 5 domains with signalling questions +- Domain 2 has two variants: 2a (effect of assignment/ITT) and 2b (effect of adhering/per-protocol) +- Response types: Y (Yes), PY (Probably Yes), PN (Probably No), N (No), NI (No Information), NA (Not Applicable) +- Judgement levels: Low, Some concerns, High +- Preliminary section for study metadata and aim selection + +### UI Components (`packages/web`) + +Created components in `packages/web/src/components/checklist/ROB2Checklist/`: + +| Component | Purpose | +|-----------|---------| +| `ROB2Checklist.jsx` | Main orchestrating component | +| `PreliminarySection.jsx` | Study design, aims, interventions, sources | +| `DomainSection.jsx` | Individual domain with questions and auto-scoring | +| `SignallingQuestion.jsx` | Response buttons for each question | +| `DomainJudgement.jsx` | Judgement display badges | +| `ScoringSummary.jsx` | Compact summary strip with domain chips | +| `OverallSection.jsx` | Final overall risk of bias section | +| `checklist.js` | Helper functions and re-exports | +| `checklist-map.js` | Schema re-exports from shared package | +| `index.js` | Module entry point | + +### Yjs Integration + +Created `packages/web/src/primitives/useProject/checklists/handlers/rob2.js`: +- `ROB2Handler` class for real-time collaborative editing +- Methods: `extractAnswersFromTemplate`, `createAnswersYMap`, `serializeAnswers`, `updateAnswer`, `getTextGetter` + +### Registry Integration + +Modified files to register ROB-2: +- `packages/web/src/checklist-registry/types.js` - Added ROB2 type constant and metadata +- `packages/web/src/checklist-registry/index.js` - Registered scoring and creation functions +- `packages/web/src/primitives/useProject/checklists/index.js` - Added handler and `getRob2Text()` +- `packages/web/src/components/checklist/GenericChecklist.jsx` - Added ROB2Checklist rendering +- `packages/shared/package.json` - Added export paths for checklists + +## Key Features + +### Auto-Scoring +Domain judgements are automatically calculated from signalling question responses using the official ROB-2 decision algorithms. The overall risk of bias is then derived from all domain judgements: +- If any domain is "High" -> Overall is "High" +- If any domain is "Some concerns" (and none High) -> Overall is "Some concerns" +- If all domains are "Low" -> Overall is "Low" + +### Domain 2 Variants +The preliminary section includes an "aim" selection that determines which Domain 2 variant to show: +- **Assignment (ITT)**: Shows Domain 2a - Effect of assignment to intervention +- **Adhering (per-protocol)**: Shows Domain 2b - Effect of adhering to intervention + +### Collaborative Editing +Full Yjs integration enables real-time collaboration: +- All text fields (experimental intervention, comparator, numerical result) are Y.Text +- Signalling question responses sync across users +- Domain judgements update automatically as questions are answered + +## File Structure + +``` +packages/shared/src/checklists/rob2/ + schema.ts # Questions, domains, constants + scoring.ts # Decision algorithms + create.ts # Factory function + answers.ts # Answer utilities + index.ts # Exports + +packages/web/src/components/checklist/ROB2Checklist/ + ROB2Checklist.jsx + PreliminarySection.jsx + DomainSection.jsx + SignallingQuestion.jsx + DomainJudgement.jsx + ScoringSummary.jsx + OverallSection.jsx + checklist.js + checklist-map.js + index.js + +packages/web/src/primitives/useProject/checklists/handlers/ + rob2.js # Yjs handler +``` + +## Verification + +- Build passes: `pnpm --filter web build` +- Type check passes: `pnpm --filter @corates/shared typecheck` +- No ROB2-related lint errors +- Unit tests pass: `pnpm --filter @corates/shared test` (64 ROB-2 tests) + +## Testing + +Unit tests are located at `packages/shared/src/checklists/__tests__/rob2.test.ts` and cover: + +- `createROB2Checklist` - Factory function validation and initialization +- `scoreRob2Domain` - All decision tree paths for each domain: + - Domain 1 (Randomization): 8 test cases covering all paths + - Domain 2a (Assignment/ITT): 5 test cases including Part 1/Part 2 combination + - Domain 2b (Adhering): 5 test cases covering major paths + - Domain 3 (Missing data): 5 test cases + - Domain 4 (Measurement): 8 test cases including NI branches + - Domain 5 (Selection): 6 test cases +- `scoreAllDomains` - Overall calculation with different aim selections +- `scoreROB2Checklist` - High-level scoring +- `isROB2Complete` - Completion detection +- `getAnswers` - Answer extraction + +Run tests with: `pnpm --filter @corates/shared test` + +## Next Steps + +### Immediate + +1. **Integration Testing** - Test the full flow in the browser: + - Create a new ROB-2 checklist from the study view + - Complete preliminary section and verify Domain 2 variant switching + - Answer signalling questions and verify auto-scoring + - Test collaborative editing with multiple users + +### Short-term + +2. **Question Notes** - Add support for free-text notes on individual signalling questions (similar to AMSTAR2) +3. **Direction of Bias** - Implement predicted direction of bias per domain (currently only overall) +4. **Export/Import** - Add CSV export functionality (similar to AMSTAR2) +5. **Reconciliation** - Implement checklist comparison and reconciliation for ROB-2 + +### Future Enhancements + +6. **Validation Warnings** - Show warnings for incomplete or inconsistent responses +7. **Conditional Questions** - Some questions should be skipped based on earlier answers (currently all shown) +8. **Help Text** - Add inline help text for signalling questions from the official guidance document +9. **Traffic Light Visualization** - Add the standard ROB-2 traffic light plot for visualizing results + +## Decision Diagram Sources + +The scoring algorithms were implemented from the decision diagrams in: +- `packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/` + +These files contain the official ROB-2 decision algorithms that determine domain judgements based on signalling question responses. + +## References + +- [RoB 2 Tool (Official)](https://www.riskofbias.info/welcome/rob-2-0-tool) +- [Cochrane Handbook Chapter 8](https://training.cochrane.org/handbook/current/chapter-08) +- [RoB 2 Detailed Guidance Document](https://drive.google.com/file/d/19R9savfPdCHC8XLz2iiMvL_71lPJERWK/view) diff --git a/packages/shared/package.json b/packages/shared/package.json index 4b5269dd0..c4da85177 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -18,6 +18,14 @@ "./plans": { "types": "./dist/plans/index.d.ts", "import": "./dist/plans/index.js" + }, + "./checklists": { + "types": "./dist/checklists/index.d.ts", + "import": "./dist/checklists/index.js" + }, + "./checklists/rob2": { + "types": "./dist/checklists/rob2/index.d.ts", + "import": "./dist/checklists/rob2/index.js" } }, "scripts": { diff --git a/packages/shared/src/checklists/__tests__/rob2.test.ts b/packages/shared/src/checklists/__tests__/rob2.test.ts new file mode 100644 index 000000000..0dbad15c9 --- /dev/null +++ b/packages/shared/src/checklists/__tests__/rob2.test.ts @@ -0,0 +1,804 @@ +import { describe, it, expect } from 'vitest'; +import { + createROB2Checklist, + scoreROB2Checklist, + isROB2Complete, + getAnswers, + scoreRob2Domain, + scoreAllDomains, + JUDGEMENTS, +} from '../rob2/index.js'; + +// Helper to create answers object +function makeAnswers(answerMap: Record) { + const result: Record = {}; + for (const [key, value] of Object.entries(answerMap)) { + result[key] = { answer: value, comment: '' }; + } + return result; +} + +describe('ROB-2', () => { + describe('createROB2Checklist', () => { + it('should create a checklist with all required fields', () => { + const checklist = createROB2Checklist({ + name: 'Test Checklist', + id: 'test-123', + reviewerName: 'Alice', + }); + + expect(checklist.name).toBe('Test Checklist'); + expect(checklist.id).toBe('test-123'); + expect(checklist.reviewerName).toBe('Alice'); + expect(checklist.type).toBe('ROB2'); + expect(checklist.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}$/); + + // Check preliminary section exists + expect(checklist.preliminary).toBeDefined(); + expect(checklist.preliminary.aim).toBe(null); + + // Check all domains exist + expect(checklist.domain1).toBeDefined(); + expect(checklist.domain2a).toBeDefined(); + expect(checklist.domain2b).toBeDefined(); + expect(checklist.domain3).toBeDefined(); + expect(checklist.domain4).toBeDefined(); + expect(checklist.domain5).toBeDefined(); + + // Check overall section exists + expect(checklist.overall).toBeDefined(); + }); + + it('should throw if id is missing', () => { + expect(() => + createROB2Checklist({ + name: 'Test', + id: '', + }), + ).toThrow('non-empty string id'); + }); + + it('should throw if name is missing', () => { + expect(() => + createROB2Checklist({ + name: '', + id: 'test-123', + }), + ).toThrow('non-empty string name'); + }); + + it('should initialize domains with empty answers', () => { + const checklist = createROB2Checklist({ + name: 'Test', + id: 'test-123', + }); + + // Domain 1 should have questions d1_1, d1_2, d1_3 + expect(checklist.domain1.answers.d1_1).toEqual({ answer: null, comment: '' }); + expect(checklist.domain1.answers.d1_2).toEqual({ answer: null, comment: '' }); + expect(checklist.domain1.answers.d1_3).toEqual({ answer: null, comment: '' }); + + // Domain 5 should have questions d5_1, d5_2, d5_3 + expect(checklist.domain5.answers.d5_1).toEqual({ answer: null, comment: '' }); + expect(checklist.domain5.answers.d5_3).toEqual({ answer: null, comment: '' }); + }); + }); + + describe('scoreRob2Domain', () => { + describe('Domain 1 (Randomization)', () => { + it('should return null for incomplete answers', () => { + const answers = makeAnswers({ d1_1: null, d1_2: null, d1_3: null }); + const result = scoreRob2Domain('domain1', answers); + expect(result.judgement).toBe(null); + expect(result.isComplete).toBe(false); + }); + + it('should return High when concealment (1.2) is N/PN [D1.R1]', () => { + const answers = makeAnswers({ d1_1: 'Y', d1_2: 'N', d1_3: 'N' }); + const result = scoreRob2Domain('domain1', answers); + expect(result.judgement).toBe(JUDGEMENTS.HIGH); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D1.R1'); + }); + + it('should return Some concerns when concealment Y but random sequence N [D1.R2]', () => { + const answers = makeAnswers({ d1_1: 'N', d1_2: 'Y', d1_3: 'N' }); + const result = scoreRob2Domain('domain1', answers); + expect(result.judgement).toBe(JUDGEMENTS.SOME_CONCERNS); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D1.R2'); + }); + + it('should return Low when Y/Y/N path (all good) [D1.R3]', () => { + const answers = makeAnswers({ d1_1: 'Y', d1_2: 'Y', d1_3: 'N' }); + const result = scoreRob2Domain('domain1', answers); + expect(result.judgement).toBe(JUDGEMENTS.LOW); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D1.R3'); + }); + + it('should return Low when PY/PY/PN path [D1.R3]', () => { + const answers = makeAnswers({ d1_1: 'PY', d1_2: 'PY', d1_3: 'PN' }); + const result = scoreRob2Domain('domain1', answers); + expect(result.judgement).toBe(JUDGEMENTS.LOW); + expect(result.isComplete).toBe(true); + }); + + it('should return Low when random=NI but concealment=Y and no baseline imbalance [D1.R3]', () => { + const answers = makeAnswers({ d1_1: 'NI', d1_2: 'Y', d1_3: 'N' }); + const result = scoreRob2Domain('domain1', answers); + expect(result.judgement).toBe(JUDGEMENTS.LOW); + expect(result.isComplete).toBe(true); + }); + + it('should return Some concerns when baseline imbalances Y [D1.R4]', () => { + const answers = makeAnswers({ d1_1: 'Y', d1_2: 'Y', d1_3: 'Y' }); + const result = scoreRob2Domain('domain1', answers); + expect(result.judgement).toBe(JUDGEMENTS.SOME_CONCERNS); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D1.R4'); + }); + + it('should return Some concerns when concealment NI and no baseline imbalance [D1.R5]', () => { + const answers = makeAnswers({ d1_1: 'Y', d1_2: 'NI', d1_3: 'N' }); + const result = scoreRob2Domain('domain1', answers); + expect(result.judgement).toBe(JUDGEMENTS.SOME_CONCERNS); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D1.R5'); + }); + + it('should return High when concealment NI and baseline imbalance Y [D1.R6]', () => { + const answers = makeAnswers({ d1_1: 'Y', d1_2: 'NI', d1_3: 'Y' }); + const result = scoreRob2Domain('domain1', answers); + expect(result.judgement).toBe(JUDGEMENTS.HIGH); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D1.R6'); + }); + + it('should treat NA as NI for scoring', () => { + const answers = makeAnswers({ d1_1: 'NA', d1_2: 'Y', d1_3: 'N' }); + const result = scoreRob2Domain('domain1', answers); + // NA normalized to NI, so d1_1=NI, d1_2=Y -> goes to 1.3 which is N -> Low + expect(result.judgement).toBe(JUDGEMENTS.LOW); + expect(result.isComplete).toBe(true); + }); + }); + + describe('Domain 2a (Effect of assignment - ITT)', () => { + it('should return null for incomplete answers', () => { + const answers = makeAnswers({ d2a_1: null, d2a_2: null }); + const result = scoreRob2Domain('domain2a', answers); + expect(result.judgement).toBe(null); + expect(result.isComplete).toBe(false); + }); + + it('should return Low when both participants and personnel not aware (Part 1) and analysis appropriate (Part 2)', () => { + const answers = makeAnswers({ + d2a_1: 'N', + d2a_2: 'N', + d2a_3: null, + d2a_4: null, + d2a_5: null, + d2a_6: 'Y', + d2a_7: null, + }); + const result = scoreRob2Domain('domain2a', answers); + expect(result.judgement).toBe(JUDGEMENTS.LOW); + expect(result.isComplete).toBe(true); + }); + + it('should return Some concerns when deviations arise but no impact on outcome', () => { + const answers = makeAnswers({ + d2a_1: 'Y', + d2a_2: 'Y', + d2a_3: 'Y', + d2a_4: 'N', + d2a_5: null, + d2a_6: 'Y', + d2a_7: null, + }); + const result = scoreRob2Domain('domain2a', answers); + expect(result.judgement).toBe(JUDGEMENTS.SOME_CONCERNS); + expect(result.isComplete).toBe(true); + }); + + it('should return High when deviations affect outcome and are not balanced', () => { + const answers = makeAnswers({ + d2a_1: 'Y', + d2a_2: 'Y', + d2a_3: 'Y', + d2a_4: 'Y', + d2a_5: 'N', + d2a_6: 'Y', + d2a_7: null, + }); + const result = scoreRob2Domain('domain2a', answers); + expect(result.judgement).toBe(JUDGEMENTS.HIGH); + expect(result.isComplete).toBe(true); + }); + + it('should take worst rating between Part 1 and Part 2', () => { + // Part 1: Low (N/N path) + // Part 2: High (N/Y path) + const answers = makeAnswers({ + d2a_1: 'N', + d2a_2: 'N', + d2a_3: null, + d2a_4: null, + d2a_5: null, + d2a_6: 'N', + d2a_7: 'Y', + }); + const result = scoreRob2Domain('domain2a', answers); + expect(result.judgement).toBe(JUDGEMENTS.HIGH); + expect(result.isComplete).toBe(true); + }); + + it('should return Some concerns for Part 2 when no substantial impact', () => { + const answers = makeAnswers({ + d2a_1: 'N', + d2a_2: 'N', + d2a_3: null, + d2a_4: null, + d2a_5: null, + d2a_6: 'N', + d2a_7: 'N', + }); + const result = scoreRob2Domain('domain2a', answers); + expect(result.judgement).toBe(JUDGEMENTS.SOME_CONCERNS); + expect(result.isComplete).toBe(true); + }); + }); + + describe('Domain 2b (Effect of adhering - per-protocol)', () => { + it('should return null for incomplete answers', () => { + const answers = makeAnswers({ d2b_1: null, d2b_2: null }); + const result = scoreRob2Domain('domain2b', answers); + expect(result.judgement).toBe(null); + expect(result.isComplete).toBe(false); + }); + + it('should return Low when not aware and no failures/non-adherence [D2B.R1]', () => { + const answers = makeAnswers({ + d2b_1: 'N', + d2b_2: 'N', + d2b_3: null, + d2b_4: 'N', + d2b_5: 'N', + d2b_6: null, + }); + const result = scoreRob2Domain('domain2b', answers); + expect(result.judgement).toBe(JUDGEMENTS.LOW); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D2B.R1'); + }); + + it('should return Some concerns when aware, balanced, no issues, but appropriate analysis [D2B.R5]', () => { + const answers = makeAnswers({ + d2b_1: 'Y', + d2b_2: 'Y', + d2b_3: 'Y', + d2b_4: 'Y', + d2b_5: 'N', + d2b_6: 'Y', + }); + const result = scoreRob2Domain('domain2b', answers); + expect(result.judgement).toBe(JUDGEMENTS.SOME_CONCERNS); + expect(result.isComplete).toBe(true); + }); + + it('should return High when issues exist but no appropriate analysis [D2B.R6]', () => { + const answers = makeAnswers({ + d2b_1: 'Y', + d2b_2: 'Y', + d2b_3: 'Y', + d2b_4: 'Y', + d2b_5: 'N', + d2b_6: 'N', + }); + const result = scoreRob2Domain('domain2b', answers); + expect(result.judgement).toBe(JUDGEMENTS.HIGH); + expect(result.isComplete).toBe(true); + }); + + it('should return High when not balanced and no appropriate analysis [D2B.R8]', () => { + const answers = makeAnswers({ + d2b_1: 'Y', + d2b_2: 'Y', + d2b_3: 'N', + d2b_4: null, + d2b_5: null, + d2b_6: 'N', + }); + const result = scoreRob2Domain('domain2b', answers); + expect(result.judgement).toBe(JUDGEMENTS.HIGH); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D2B.R8'); + }); + }); + + describe('Domain 3 (Missing outcome data)', () => { + it('should return null for incomplete answers', () => { + const answers = makeAnswers({ d3_1: null }); + const result = scoreRob2Domain('domain3', answers); + expect(result.judgement).toBe(null); + expect(result.isComplete).toBe(false); + }); + + it('should return Low when data available for all [D3.R1]', () => { + const answers = makeAnswers({ d3_1: 'Y', d3_2: null, d3_3: null, d3_4: null }); + const result = scoreRob2Domain('domain3', answers); + expect(result.judgement).toBe(JUDGEMENTS.LOW); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D3.R1'); + }); + + it('should return Low when evidence not biased by missing data [D3.R2]', () => { + const answers = makeAnswers({ d3_1: 'N', d3_2: 'Y', d3_3: null, d3_4: null }); + const result = scoreRob2Domain('domain3', answers); + expect(result.judgement).toBe(JUDGEMENTS.LOW); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D3.R2'); + }); + + it('should return Low when missingness could not depend on true value [D3.R3]', () => { + const answers = makeAnswers({ d3_1: 'N', d3_2: 'N', d3_3: 'N', d3_4: null }); + const result = scoreRob2Domain('domain3', answers); + expect(result.judgement).toBe(JUDGEMENTS.LOW); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D3.R3'); + }); + + it('should return Some concerns when could depend but probably not [D3.R4]', () => { + const answers = makeAnswers({ d3_1: 'N', d3_2: 'N', d3_3: 'Y', d3_4: 'N' }); + const result = scoreRob2Domain('domain3', answers); + expect(result.judgement).toBe(JUDGEMENTS.SOME_CONCERNS); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D3.R4'); + }); + + it('should return High when missingness likely depended on true value [D3.R5]', () => { + const answers = makeAnswers({ d3_1: 'N', d3_2: 'N', d3_3: 'Y', d3_4: 'Y' }); + const result = scoreRob2Domain('domain3', answers); + expect(result.judgement).toBe(JUDGEMENTS.HIGH); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D3.R5'); + }); + }); + + describe('Domain 4 (Measurement of the outcome)', () => { + it('should return null for incomplete answers', () => { + const answers = makeAnswers({ d4_1: null }); + const result = scoreRob2Domain('domain4', answers); + expect(result.judgement).toBe(null); + expect(result.isComplete).toBe(false); + }); + + it('should return High when method inappropriate [D4.R1]', () => { + const answers = makeAnswers({ + d4_1: 'Y', + d4_2: null, + d4_3: null, + d4_4: null, + d4_5: null, + }); + const result = scoreRob2Domain('domain4', answers); + expect(result.judgement).toBe(JUDGEMENTS.HIGH); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D4.R1'); + }); + + it('should return High when measurement differs between groups [D4.R2]', () => { + const answers = makeAnswers({ + d4_1: 'N', + d4_2: 'Y', + d4_3: null, + d4_4: null, + d4_5: null, + }); + const result = scoreRob2Domain('domain4', answers); + expect(result.judgement).toBe(JUDGEMENTS.HIGH); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D4.R2'); + }); + + it('should return Low when assessors not aware [D4.R3]', () => { + const answers = makeAnswers({ + d4_1: 'N', + d4_2: 'N', + d4_3: 'N', + d4_4: null, + d4_5: null, + }); + const result = scoreRob2Domain('domain4', answers); + expect(result.judgement).toBe(JUDGEMENTS.LOW); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D4.R3'); + }); + + it('should return Low when aware but could not be influenced [D4.R4]', () => { + const answers = makeAnswers({ + d4_1: 'N', + d4_2: 'N', + d4_3: 'Y', + d4_4: 'N', + d4_5: null, + }); + const result = scoreRob2Domain('domain4', answers); + expect(result.judgement).toBe(JUDGEMENTS.LOW); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D4.R4'); + }); + + it('should return Some concerns when could be influenced but probably not [D4.R5]', () => { + const answers = makeAnswers({ + d4_1: 'N', + d4_2: 'N', + d4_3: 'Y', + d4_4: 'Y', + d4_5: 'N', + }); + const result = scoreRob2Domain('domain4', answers); + expect(result.judgement).toBe(JUDGEMENTS.SOME_CONCERNS); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D4.R5'); + }); + + it('should return High when likely influenced [D4.R6]', () => { + const answers = makeAnswers({ + d4_1: 'N', + d4_2: 'N', + d4_3: 'Y', + d4_4: 'Y', + d4_5: 'Y', + }); + const result = scoreRob2Domain('domain4', answers); + expect(result.judgement).toBe(JUDGEMENTS.HIGH); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D4.R6'); + }); + + it('should return Some concerns on NI branch when assessors not aware [D4.R7]', () => { + const answers = makeAnswers({ + d4_1: 'N', + d4_2: 'NI', + d4_3: 'N', + d4_4: null, + d4_5: null, + }); + const result = scoreRob2Domain('domain4', answers); + expect(result.judgement).toBe(JUDGEMENTS.SOME_CONCERNS); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D4.R7'); + }); + + it('should return High on NI branch when likely influenced [D4.R10]', () => { + const answers = makeAnswers({ + d4_1: 'N', + d4_2: 'NI', + d4_3: 'Y', + d4_4: 'Y', + d4_5: 'Y', + }); + const result = scoreRob2Domain('domain4', answers); + expect(result.judgement).toBe(JUDGEMENTS.HIGH); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D4.R10'); + }); + }); + + describe('Domain 5 (Selection of reported result)', () => { + it('should return null for incomplete answers', () => { + const answers = makeAnswers({ d5_1: null, d5_2: null, d5_3: null }); + const result = scoreRob2Domain('domain5', answers); + expect(result.judgement).toBe(null); + expect(result.isComplete).toBe(false); + }); + + it('should return High when selected from multiple measurements [D5.R1]', () => { + const answers = makeAnswers({ d5_1: null, d5_2: 'Y', d5_3: 'N' }); + const result = scoreRob2Domain('domain5', answers); + expect(result.judgement).toBe(JUDGEMENTS.HIGH); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D5.R1'); + }); + + it('should return High when selected from multiple analyses [D5.R1]', () => { + const answers = makeAnswers({ d5_1: null, d5_2: 'N', d5_3: 'Y' }); + const result = scoreRob2Domain('domain5', answers); + expect(result.judgement).toBe(JUDGEMENTS.HIGH); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D5.R1'); + }); + + it('should return Some concerns when NI for selection questions [D5.R2]', () => { + const answers = makeAnswers({ d5_1: null, d5_2: 'NI', d5_3: 'N' }); + const result = scoreRob2Domain('domain5', answers); + expect(result.judgement).toBe(JUDGEMENTS.SOME_CONCERNS); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D5.R2'); + }); + + it('should return Low when pre-specified plan and no selection [D5.R3]', () => { + const answers = makeAnswers({ d5_1: 'Y', d5_2: 'N', d5_3: 'N' }); + const result = scoreRob2Domain('domain5', answers); + expect(result.judgement).toBe(JUDGEMENTS.LOW); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D5.R3'); + }); + + it('should return Some concerns when no pre-specified plan [D5.R4]', () => { + const answers = makeAnswers({ d5_1: 'N', d5_2: 'N', d5_3: 'N' }); + const result = scoreRob2Domain('domain5', answers); + expect(result.judgement).toBe(JUDGEMENTS.SOME_CONCERNS); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D5.R4'); + }); + + it('should return Some concerns when plan info is NI [D5.R4]', () => { + const answers = makeAnswers({ d5_1: 'NI', d5_2: 'N', d5_3: 'N' }); + const result = scoreRob2Domain('domain5', answers); + expect(result.judgement).toBe(JUDGEMENTS.SOME_CONCERNS); + expect(result.isComplete).toBe(true); + expect(result.ruleId).toBe('D5.R4'); + }); + }); + + describe('Unknown domain', () => { + it('should return null for unknown domain key', () => { + const answers = makeAnswers({ d1_1: 'Y' }); + const result = scoreRob2Domain('unknown', answers); + expect(result.judgement).toBe(null); + expect(result.isComplete).toBe(false); + }); + }); + }); + + describe('scoreAllDomains', () => { + it('should return empty result for null state', () => { + const result = scoreAllDomains(null); + expect(result.domains).toEqual({}); + expect(result.overall).toBe(null); + expect(result.isComplete).toBe(false); + }); + + it('should use domain2a for ASSIGNMENT aim', () => { + const state = { + preliminary: { aim: 'ASSIGNMENT' }, + domain1: { answers: makeAnswers({ d1_1: 'Y', d1_2: 'Y', d1_3: 'N' }) }, + domain2a: { + answers: makeAnswers({ + d2a_1: 'N', + d2a_2: 'N', + d2a_6: 'Y', + }), + }, + domain3: { answers: makeAnswers({ d3_1: 'Y' }) }, + domain4: { answers: makeAnswers({ d4_1: 'N', d4_2: 'N', d4_3: 'N' }) }, + domain5: { answers: makeAnswers({ d5_1: 'Y', d5_2: 'N', d5_3: 'N' }) }, + }; + + const result = scoreAllDomains(state); + expect(result.domains.domain2a).toBeDefined(); + expect(result.domains.domain2b).toBeUndefined(); + }); + + it('should use domain2b for ADHERING aim', () => { + const state = { + preliminary: { aim: 'ADHERING' }, + domain1: { answers: makeAnswers({ d1_1: 'Y', d1_2: 'Y', d1_3: 'N' }) }, + domain2b: { + answers: makeAnswers({ + d2b_1: 'N', + d2b_2: 'N', + d2b_4: 'N', + d2b_5: 'N', + }), + }, + domain3: { answers: makeAnswers({ d3_1: 'Y' }) }, + domain4: { answers: makeAnswers({ d4_1: 'N', d4_2: 'N', d4_3: 'N' }) }, + domain5: { answers: makeAnswers({ d5_1: 'Y', d5_2: 'N', d5_3: 'N' }) }, + }; + + const result = scoreAllDomains(state); + expect(result.domains.domain2b).toBeDefined(); + expect(result.domains.domain2a).toBeUndefined(); + }); + + it('should return overall Low when all domains are Low', () => { + const state = { + preliminary: { aim: 'ASSIGNMENT' }, + domain1: { answers: makeAnswers({ d1_1: 'Y', d1_2: 'Y', d1_3: 'N' }) }, + domain2a: { + answers: makeAnswers({ + d2a_1: 'N', + d2a_2: 'N', + d2a_6: 'Y', + }), + }, + domain3: { answers: makeAnswers({ d3_1: 'Y' }) }, + domain4: { answers: makeAnswers({ d4_1: 'N', d4_2: 'N', d4_3: 'N' }) }, + domain5: { answers: makeAnswers({ d5_1: 'Y', d5_2: 'N', d5_3: 'N' }) }, + }; + + const result = scoreAllDomains(state); + expect(result.overall).toBe(JUDGEMENTS.LOW); + expect(result.isComplete).toBe(true); + }); + + it('should return overall Some concerns when any domain has Some concerns', () => { + const state = { + preliminary: { aim: 'ASSIGNMENT' }, + domain1: { answers: makeAnswers({ d1_1: 'Y', d1_2: 'Y', d1_3: 'Y' }) }, // Some concerns + domain2a: { + answers: makeAnswers({ + d2a_1: 'N', + d2a_2: 'N', + d2a_6: 'Y', + }), + }, + domain3: { answers: makeAnswers({ d3_1: 'Y' }) }, + domain4: { answers: makeAnswers({ d4_1: 'N', d4_2: 'N', d4_3: 'N' }) }, + domain5: { answers: makeAnswers({ d5_1: 'Y', d5_2: 'N', d5_3: 'N' }) }, + }; + + const result = scoreAllDomains(state); + expect(result.overall).toBe(JUDGEMENTS.SOME_CONCERNS); + expect(result.isComplete).toBe(true); + }); + + it('should return overall High when any domain is High', () => { + const state = { + preliminary: { aim: 'ASSIGNMENT' }, + domain1: { answers: makeAnswers({ d1_1: 'Y', d1_2: 'N', d1_3: 'N' }) }, // High + domain2a: { + answers: makeAnswers({ + d2a_1: 'N', + d2a_2: 'N', + d2a_6: 'Y', + }), + }, + domain3: { answers: makeAnswers({ d3_1: 'Y' }) }, + domain4: { answers: makeAnswers({ d4_1: 'N', d4_2: 'N', d4_3: 'N' }) }, + domain5: { answers: makeAnswers({ d5_1: 'Y', d5_2: 'N', d5_3: 'N' }) }, + }; + + const result = scoreAllDomains(state); + expect(result.overall).toBe(JUDGEMENTS.HIGH); + expect(result.isComplete).toBe(true); + }); + + it('should return incomplete when not all domains are scored', () => { + const state = { + preliminary: { aim: 'ASSIGNMENT' }, + domain1: { answers: makeAnswers({ d1_1: 'Y', d1_2: 'Y', d1_3: 'N' }) }, + domain2a: { answers: makeAnswers({ d2a_1: null, d2a_2: null }) }, // Incomplete + domain3: { answers: makeAnswers({ d3_1: 'Y' }) }, + domain4: { answers: makeAnswers({ d4_1: 'N', d4_2: 'N', d4_3: 'N' }) }, + domain5: { answers: makeAnswers({ d5_1: 'Y', d5_2: 'N', d5_3: 'N' }) }, + }; + + const result = scoreAllDomains(state); + expect(result.overall).toBe(null); + expect(result.isComplete).toBe(false); + }); + }); + + describe('scoreROB2Checklist', () => { + it('should return Error for invalid input', () => { + expect(scoreROB2Checklist(null as any)).toBe('Error'); + expect(scoreROB2Checklist(undefined as any)).toBe('Error'); + expect(scoreROB2Checklist('string' as any)).toBe('Error'); + }); + + it('should return Incomplete when domains are not complete', () => { + const checklist = createROB2Checklist({ + name: 'Test', + id: 'test-123', + }); + + expect(scoreROB2Checklist(checklist)).toBe('Incomplete'); + }); + + it('should return Low when all domains score Low', () => { + const checklist = createROB2Checklist({ + name: 'Test', + id: 'test-123', + }); + + checklist.preliminary.aim = 'ASSIGNMENT'; + checklist.domain1.answers = makeAnswers({ d1_1: 'Y', d1_2: 'Y', d1_3: 'N' }); + checklist.domain2a.answers = makeAnswers({ + d2a_1: 'N', + d2a_2: 'N', + d2a_6: 'Y', + }); + checklist.domain3.answers = makeAnswers({ d3_1: 'Y' }); + checklist.domain4.answers = makeAnswers({ d4_1: 'N', d4_2: 'N', d4_3: 'N' }); + checklist.domain5.answers = makeAnswers({ d5_1: 'Y', d5_2: 'N', d5_3: 'N' }); + + expect(scoreROB2Checklist(checklist)).toBe('Low'); + }); + }); + + describe('isROB2Complete', () => { + it('should return false for empty checklist', () => { + const checklist = createROB2Checklist({ + name: 'Test', + id: 'test-123', + }); + + expect(isROB2Complete(checklist)).toBe(false); + }); + + it('should return false when aim is not selected', () => { + const checklist = createROB2Checklist({ + name: 'Test', + id: 'test-123', + }); + + // Fill in all answers but don't select aim + checklist.domain1.answers = makeAnswers({ d1_1: 'Y', d1_2: 'Y', d1_3: 'N' }); + + expect(isROB2Complete(checklist)).toBe(false); + }); + + it('should return true when all domains are complete', () => { + const checklist = createROB2Checklist({ + name: 'Test', + id: 'test-123', + }); + + checklist.preliminary.aim = 'ASSIGNMENT'; + checklist.domain1.answers = makeAnswers({ d1_1: 'Y', d1_2: 'Y', d1_3: 'N' }); + checklist.domain2a.answers = makeAnswers({ + d2a_1: 'N', + d2a_2: 'N', + d2a_6: 'Y', + }); + checklist.domain3.answers = makeAnswers({ d3_1: 'Y' }); + checklist.domain4.answers = makeAnswers({ d4_1: 'N', d4_2: 'N', d4_3: 'N' }); + checklist.domain5.answers = makeAnswers({ d5_1: 'Y', d5_2: 'N', d5_3: 'N' }); + + expect(isROB2Complete(checklist)).toBe(true); + }); + }); + + describe('getAnswers', () => { + it('should return null for invalid input', () => { + expect(getAnswers(null as any)).toBe(null); + }); + + it('should return structured answers object', () => { + const checklist = createROB2Checklist({ + name: 'Test', + id: 'test-123', + reviewerName: 'Alice', + }); + + const answers = getAnswers(checklist); + expect(answers).not.toBe(null); + expect(answers?.metadata.name).toBe('Test'); + expect(answers?.metadata.reviewerName).toBe('Alice'); + expect(answers?.preliminary).toBeDefined(); + expect(answers?.domains).toBeDefined(); + expect(answers?.overall).toBeDefined(); + }); + + it('should include domain answers when filled', () => { + const checklist = createROB2Checklist({ + name: 'Test', + id: 'test-123', + }); + + checklist.preliminary.aim = 'ASSIGNMENT'; + checklist.domain1.answers = makeAnswers({ d1_1: 'Y', d1_2: 'Y', d1_3: 'N' }); + + const answers = getAnswers(checklist); + expect(answers?.domains.domain1).toBeDefined(); + expect(answers?.domains.domain1.questions.d1_1).toBe('Y'); + expect(answers?.domains.domain1.questions.d1_2).toBe('Y'); + expect(answers?.domains.domain1.questions.d1_3).toBe('N'); + }); + }); +}); diff --git a/packages/shared/src/checklists/index.ts b/packages/shared/src/checklists/index.ts index 9d896c8c3..57a78be50 100644 --- a/packages/shared/src/checklists/index.ts +++ b/packages/shared/src/checklists/index.ts @@ -25,6 +25,9 @@ export * as amstar2 from './amstar2/index.js'; // ROBINS-I export * as robinsI from './robins-i/index.js'; +// ROB-2 +export * as rob2 from './rob2/index.js'; + // Re-export key functions at top level for convenience export { createAMSTAR2Checklist, @@ -43,3 +46,12 @@ export { scoreRobinsDomain, scoreAllDomains, } from './robins-i/index.js'; + +export { + createROB2Checklist, + scoreROB2Checklist, + isROB2Complete, + getAnswers as getROB2Answers, + scoreRob2Domain, + scoreAllDomains as scoreAllROB2Domains, +} from './rob2/index.js'; diff --git a/packages/shared/src/checklists/rob2/answers.ts b/packages/shared/src/checklists/rob2/answers.ts new file mode 100644 index 000000000..d3c7ff543 --- /dev/null +++ b/packages/shared/src/checklists/rob2/answers.ts @@ -0,0 +1,175 @@ +/** + * ROB-2 Answer Utilities + * + * Functions for getting and manipulating ROB-2 checklist answers. + */ + +import type { ROB2Checklist } from './create.js'; +import { getActiveDomainKeys, getDomainQuestions } from './schema.js'; +import { scoreAllDomains } from './scoring.js'; + +/** + * Score the overall checklist based on domain judgements + */ +export function scoreROB2Checklist(state: ROB2Checklist): string { + if (!state || typeof state !== 'object') return 'Error'; + + const { overall, isComplete } = scoreAllDomains( + state as unknown as Parameters[0], + ); + + if (!isComplete) { + return 'Incomplete'; + } + + return overall || 'Incomplete'; +} + +/** + * Get the selected answer for a specific question + */ +export function getSelectedAnswer( + domainKey: string, + questionKey: string, + state: ROB2Checklist, +): string | null { + const domain = state?.[domainKey as keyof ROB2Checklist]; + if (!domain || typeof domain !== 'object') return null; + const typedDomain = domain as { answers?: Record }; + return typedDomain.answers?.[questionKey]?.answer || null; +} + +/** + * Get all answers in a flat format for export/display + */ +export function getAnswers(checklist: ROB2Checklist): { + metadata: { + name: string; + reviewerName: string; + createdAt: string; + id: string; + }; + preliminary: ROB2Checklist['preliminary']; + domains: Record< + string, + { + judgement: string | null; + direction: string | null; + questions: Record; + } + >; + overall: ROB2Checklist['overall']; +} | null { + if (!checklist || typeof checklist !== 'object') return null; + + const result = { + metadata: { + name: checklist.name, + reviewerName: checklist.reviewerName, + createdAt: checklist.createdAt, + id: checklist.id, + }, + preliminary: checklist.preliminary, + domains: {} as Record< + string, + { + judgement: string | null; + direction: string | null; + questions: Record; + } + >, + overall: checklist.overall, + }; + + // Domains + const isAdhering = checklist.preliminary?.aim === 'ADHERING'; + const activeDomains = getActiveDomainKeys(isAdhering); + + activeDomains.forEach(domainKey => { + const domain = checklist[domainKey]; + if (!domain) return; + + result.domains[domainKey] = { + judgement: domain.judgement || null, + direction: domain.direction || null, + questions: {}, + }; + + Object.keys(domain.answers || {}).forEach(qKey => { + result.domains[domainKey].questions[qKey] = domain.answers[qKey]?.answer || null; + }); + }); + + return result; +} + +/** + * Get a summary of domain judgements + */ +export function getDomainSummary(checklist: ROB2Checklist): Record< + string, + { + judgement: string | null; + direction: string | null; + complete: boolean; + } +> | null { + if (!checklist) return null; + + const isAdhering = checklist.preliminary?.aim === 'ADHERING'; + const activeDomains = getActiveDomainKeys(isAdhering); + + const summary: Record< + string, + { + judgement: string | null; + direction: string | null; + complete: boolean; + } + > = {}; + + activeDomains.forEach(domainKey => { + const domain = checklist[domainKey]; + summary[domainKey] = { + judgement: domain?.judgement || null, + direction: domain?.direction || null, + complete: isQuestionnaireComplete(domainKey, domain?.answers), + }; + }); + + return summary; +} + +/** + * Check if all questions in a domain are answered + */ +function isQuestionnaireComplete( + domainKey: string, + answers: Record | undefined, +): boolean { + if (!answers) return false; + + const questions = getDomainQuestions(domainKey); + const requiredKeys = Object.keys(questions); + + return requiredKeys.every(key => answers[key]?.answer !== null); +} + +/** + * Check if a ROB-2 checklist is complete (all active domains have judgements and overall is set) + */ +export function isROB2Complete(checklist: ROB2Checklist): boolean { + if (!checklist || typeof checklist !== 'object') return false; + + // Check if aim is selected + if (!checklist.preliminary?.aim) { + return false; + } + + // All active domains must have judgements (from auto-scoring) + const { isComplete } = scoreAllDomains( + checklist as unknown as Parameters[0], + ); + + return isComplete; +} diff --git a/packages/shared/src/checklists/rob2/create.ts b/packages/shared/src/checklists/rob2/create.ts new file mode 100644 index 000000000..92a57ba8f --- /dev/null +++ b/packages/shared/src/checklists/rob2/create.ts @@ -0,0 +1,142 @@ +/** + * ROB-2 Checklist Creation + * + * Creates new ROB-2 checklist objects with proper structure and defaults. + */ + +import { INFORMATION_SOURCES, getDomainQuestions, ROB2_CHECKLIST, type DomainKey } from './schema.js'; + +export interface ROB2Checklist { + id: string; + name: string; + reviewerName: string; + createdAt: string; + type: 'ROB2'; + assignedTo?: string | null; + status?: string; + + preliminary: { + studyDesign: string | null; + experimental: string; + comparator: string; + numericalResult: string; + aim: 'ASSIGNMENT' | 'ADHERING' | null; + deviationsToAddress: string[]; + sources: Record; + }; + + domain1: DomainState; + domain2a: DomainState; + domain2b: DomainState; + domain3: DomainState; + domain4: DomainState; + domain5: DomainState; + + overall: { + judgement: string | null; + direction: string | null; + }; +} + +interface DomainState { + answers: Record; + judgement: string | null; + direction: string | null; +} + +interface CreateChecklistOptions { + name: string; + id: string; + createdAt?: number | Date; + reviewerName?: string; +} + +/** + * Creates a new ROB-2 checklist object with default empty answers. + * + * @param options - Checklist properties. + * @param options.name - The checklist name (required). + * @param options.id - Unique checklist ID (required). + * @param options.createdAt - Timestamp of checklist creation. + * @param options.reviewerName - Name of the reviewer. + * + * @returns A checklist object with all ROB-2 questions initialized to default answers. + * + * @throws Error if `id` or `name` is missing or not a non-empty string. + */ +export function createROB2Checklist({ + name, + id, + createdAt = Date.now(), + reviewerName = '', +}: CreateChecklistOptions): ROB2Checklist { + if (!id || typeof id !== 'string' || !id.trim()) { + throw new Error('ROB-2 Checklist requires a non-empty string id.'); + } + if (!name || typeof name !== 'string' || !name.trim()) { + throw new Error('ROB-2 Checklist requires a non-empty string name.'); + } + + let d = new Date(createdAt); + if (isNaN(d.getTime())) d = new Date(); + + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const formattedDate = `${d.getFullYear()}-${mm}-${dd}`; + + return { + name: name, + reviewerName: reviewerName || '', + createdAt: formattedDate, + id: id, + type: 'ROB2', + + preliminary: { + studyDesign: null, + experimental: '', + comparator: '', + numericalResult: '', + aim: null, + deviationsToAddress: [], + sources: INFORMATION_SOURCES.reduce( + (acc, source) => { + acc[source] = false; + return acc; + }, + {} as Record, + ), + }, + + domain1: createDomainState('domain1'), + domain2a: createDomainState('domain2a'), + domain2b: createDomainState('domain2b'), + domain3: createDomainState('domain3'), + domain4: createDomainState('domain4'), + domain5: createDomainState('domain5'), + + overall: { + judgement: null, + direction: null, + }, + }; +} + +/** + * Creates the initial state for a domain + */ +function createDomainState(domainKey: DomainKey): DomainState { + const questions = getDomainQuestions(domainKey); + const answers: Record = {}; + + Object.keys(questions).forEach(qKey => { + answers[qKey] = { answer: null, comment: '' }; + }); + + const domain = ROB2_CHECKLIST[domainKey]; + + return { + answers, + judgement: null, + direction: domain?.hasDirection ? null : null, + }; +} diff --git a/packages/shared/src/checklists/rob2/index.ts b/packages/shared/src/checklists/rob2/index.ts new file mode 100644 index 000000000..94ef4c90f --- /dev/null +++ b/packages/shared/src/checklists/rob2/index.ts @@ -0,0 +1,18 @@ +/** + * ROB-2 Module + * + * ROB-2 (Risk of Bias 2) is a tool for assessing risk of bias + * in randomized trials. + */ + +// Schema (checklist map) +export * from './schema.js'; + +// Scoring engine +export * from './scoring.js'; + +// Checklist creation +export * from './create.js'; + +// Answer manipulation +export * from './answers.js'; diff --git a/packages/shared/src/checklists/rob2/schema.ts b/packages/shared/src/checklists/rob2/schema.ts new file mode 100644 index 000000000..76253e116 --- /dev/null +++ b/packages/shared/src/checklists/rob2/schema.ts @@ -0,0 +1,403 @@ +/** + * ROB-2 Checklist Schema + * + * Risk of Bias 2 tool for assessing risk of bias in randomized trials. + * Based on the Cochrane RoB 2.0 tool. + */ + +// Response option types +export const RESPONSE_TYPES = { + STANDARD: ['Y', 'PY', 'PN', 'N', 'NI'] as const, + WITH_NA: ['NA', 'Y', 'PY', 'PN', 'N', 'NI'] as const, +} as const; + +export type ResponseType = keyof typeof RESPONSE_TYPES; +export type ResponseValue = (typeof RESPONSE_TYPES)[ResponseType][number]; + +// Human-readable labels for response options +export const RESPONSE_LABELS: Record = { + NA: 'Not Applicable', + Y: 'Yes', + PY: 'Probably Yes', + PN: 'Probably No', + N: 'No', + NI: 'No Information', +}; + +// Risk of bias judgement options +export const JUDGEMENTS = { + LOW: 'Low', + SOME_CONCERNS: 'Some concerns', + HIGH: 'High', +} as const; + +export type Judgement = (typeof JUDGEMENTS)[keyof typeof JUDGEMENTS]; + +// Bias direction options +export const BIAS_DIRECTIONS = [ + 'NA', + 'Favours experimental', + 'Favours comparator', + 'Towards null', + 'Away from null', + 'Unpredictable', +] as const; + +// Study design options +export const STUDY_DESIGNS = [ + 'Individually-randomized parallel-group trial', + 'Cluster-randomized parallel-group trial', + 'Individually randomized cross-over (or other matched) trial', +] as const; + +// Aim options (determines Domain 2 variant) +export const AIM_OPTIONS = { + ASSIGNMENT: 'to assess the effect of assignment to intervention (the intention-to-treat effect)', + ADHERING: 'to assess the effect of adhering to intervention (the per-protocol effect)', +} as const; + +// Deviations to address (for adhering aim) +export const DEVIATION_OPTIONS = [ + 'occurrence of non-protocol interventions', + 'failures in implementing the intervention that could have affected the outcome', + 'non-adherence to their assigned intervention by trial participants', +] as const; + +// Information sources +export const INFORMATION_SOURCES = [ + 'Journal article(s)', + 'Trial protocol', + 'Statistical analysis plan (SAP)', + 'Non-commercial trial registry record (e.g. ClinicalTrials.gov record)', + 'Company-owned trial registry record (e.g. GSK Clinical Study Register record)', + 'Grey literature (e.g. unpublished thesis)', + 'Conference abstract(s) about the trial', + 'Regulatory document (e.g. Clinical Study Report, Drug Approval Package)', + 'Research ethics application', + 'Grant database summary (e.g. NIH RePORTER or Research Councils UK Gateway to Research)', + 'Personal communication with trialist', + 'Personal communication with the sponsor', +] as const; + +export interface ROB2Question { + id: string; + number?: string; + text: string; + responseType: ResponseType; + info?: string; +} + +export interface ROB2Domain { + id: string; + name: string; + subtitle?: string; + questions: Record; + hasDirection: boolean; +} + +// Preliminary Section Schema +export const PRELIMINARY_SECTION = { + studyDesign: { + id: 'studyDesign', + label: 'Study design', + options: STUDY_DESIGNS, + }, + experimental: { + id: 'experimental', + label: 'Experimental intervention', + placeholder: 'Specify the experimental intervention...', + }, + comparator: { + id: 'comparator', + label: 'Comparator', + placeholder: 'Specify the comparator intervention...', + }, + numericalResult: { + id: 'numericalResult', + label: 'Specify the numerical result being assessed', + placeholder: 'e.g. RR = 1.52 (95% CI 0.83 to 2.77) or reference to table/figure', + }, + aim: { + id: 'aim', + label: "Is the review team's aim for this result...?", + options: AIM_OPTIONS, + }, + deviationsToAddress: { + id: 'deviationsToAddress', + label: 'Select the deviations from intended intervention that should be addressed', + options: DEVIATION_OPTIONS, + info: 'At least one must be checked when assessing the effect of adhering to intervention', + }, + sources: { + id: 'sources', + label: 'Which sources were obtained to help inform the risk-of-bias assessment?', + options: INFORMATION_SOURCES, + }, +} as const; + +// Domain 1: Bias arising from the randomization process +export const DOMAIN_1: ROB2Domain = { + id: 'domain1', + name: 'Domain 1: Bias arising from the randomization process', + questions: { + d1_1: { + id: 'd1_1', + number: '1.1', + text: 'Was the allocation sequence random?', + responseType: 'STANDARD', + info: "Answer 'Yes' if a random component was used in the sequence generation process (e.g. computer-generated random numbers, random number table, coin tossing). Answer 'No' if no random element was used or the sequence is predictable.", + }, + d1_2: { + id: 'd1_2', + number: '1.2', + text: 'Was the allocation sequence concealed until participants were enrolled and assigned to interventions?', + responseType: 'STANDARD', + info: "Answer 'Yes' if the trial used remote or centrally administered allocation, or if envelopes/drug containers were used appropriately.", + }, + d1_3: { + id: 'd1_3', + number: '1.3', + text: 'Did baseline differences between intervention groups suggest a problem with the randomization process?', + responseType: 'STANDARD', + info: "Answer 'No' if no imbalances are apparent or if any observed imbalances are compatible with chance.", + }, + }, + hasDirection: true, +}; + +// Domain 2a: Deviations from intended interventions (effect of assignment) +export const DOMAIN_2A: ROB2Domain = { + id: 'domain2a', + name: 'Domain 2: Risk of bias due to deviations from the intended interventions', + subtitle: 'Effect of assignment to intervention', + questions: { + d2a_1: { + id: 'd2a_1', + number: '2.1', + text: 'Were participants aware of their assigned intervention during the trial?', + responseType: 'STANDARD', + }, + d2a_2: { + id: 'd2a_2', + number: '2.2', + text: 'Were carers and people delivering the interventions aware of participants\' assigned intervention during the trial?', + responseType: 'STANDARD', + }, + d2a_3: { + id: 'd2a_3', + number: '2.3', + text: 'If Y/PY/NI to 2.1 or 2.2: Were there deviations from the intended intervention that arose because of the trial context?', + responseType: 'WITH_NA', + info: "Answer 'Yes' only if there is evidence that the trial context led to failure to implement the protocol interventions.", + }, + d2a_4: { + id: 'd2a_4', + number: '2.4', + text: 'If Y/PY to 2.3: Were these deviations likely to have affected the outcome?', + responseType: 'WITH_NA', + }, + d2a_5: { + id: 'd2a_5', + number: '2.5', + text: 'If Y/PY/NI to 2.4: Were these deviations from intended intervention balanced between groups?', + responseType: 'WITH_NA', + }, + d2a_6: { + id: 'd2a_6', + number: '2.6', + text: 'Was an appropriate analysis used to estimate the effect of assignment to intervention?', + responseType: 'STANDARD', + info: 'Both ITT and modified ITT analyses should be considered appropriate. Per-protocol and as-treated analyses should be considered inappropriate.', + }, + d2a_7: { + id: 'd2a_7', + number: '2.7', + text: 'If N/PN/NI to 2.6: Was there potential for a substantial impact (on the result) of the failure to analyse participants in the group to which they were randomized?', + responseType: 'WITH_NA', + }, + }, + hasDirection: true, +}; + +// Domain 2b: Deviations from intended interventions (effect of adhering) +export const DOMAIN_2B: ROB2Domain = { + id: 'domain2b', + name: 'Domain 2: Risk of bias due to deviations from the intended interventions', + subtitle: 'Effect of adhering to intervention', + questions: { + d2b_1: { + id: 'd2b_1', + number: '2.1', + text: 'Were participants aware of their assigned intervention during the trial?', + responseType: 'STANDARD', + }, + d2b_2: { + id: 'd2b_2', + number: '2.2', + text: 'Were carers and people delivering the interventions aware of participants\' assigned intervention during the trial?', + responseType: 'STANDARD', + }, + d2b_3: { + id: 'd2b_3', + number: '2.3', + text: '[If applicable:] If Y/PY/NI to 2.1 or 2.2: Were important non-protocol interventions balanced across intervention groups?', + responseType: 'WITH_NA', + }, + d2b_4: { + id: 'd2b_4', + number: '2.4', + text: '[If applicable:] Were there failures in implementing the intervention that could have affected the outcome?', + responseType: 'WITH_NA', + }, + d2b_5: { + id: 'd2b_5', + number: '2.5', + text: '[If applicable:] Was there non-adherence to the assigned intervention regimen that could have affected participants\' outcomes?', + responseType: 'WITH_NA', + }, + d2b_6: { + id: 'd2b_6', + number: '2.6', + text: 'If N/PN/NI to 2.3, or Y/PY/NI to 2.4 or 2.5: Was an appropriate analysis used to estimate the effect of adhering to the intervention?', + responseType: 'WITH_NA', + info: 'Naïve per-protocol and as-treated analyses are usually inappropriate. Appropriate methods include instrumental variable analyses or inverse probability weighting.', + }, + }, + hasDirection: true, +}; + +// Domain 3: Missing outcome data +export const DOMAIN_3: ROB2Domain = { + id: 'domain3', + name: 'Domain 3: Risk of bias due to missing outcome data', + questions: { + d3_1: { + id: 'd3_1', + number: '3.1', + text: 'Were data for this outcome available for all, or nearly all, participants randomized?', + responseType: 'STANDARD', + info: 'For continuous outcomes, availability of data from 95% of participants will often be sufficient.', + }, + d3_2: { + id: 'd3_2', + number: '3.2', + text: 'If N/PN/NI to 3.1: Is there evidence that the result was not biased by missing outcome data?', + responseType: 'WITH_NA', + }, + d3_3: { + id: 'd3_3', + number: '3.3', + text: 'If N/PN to 3.2: Could missingness in the outcome depend on its true value?', + responseType: 'WITH_NA', + }, + d3_4: { + id: 'd3_4', + number: '3.4', + text: 'If Y/PY/NI to 3.3: Is it likely that missingness in the outcome depended on its true value?', + responseType: 'WITH_NA', + }, + }, + hasDirection: true, +}; + +// Domain 4: Measurement of the outcome +export const DOMAIN_4: ROB2Domain = { + id: 'domain4', + name: 'Domain 4: Risk of bias in measurement of the outcome', + questions: { + d4_1: { + id: 'd4_1', + number: '4.1', + text: 'Was the method of measuring the outcome inappropriate?', + responseType: 'STANDARD', + }, + d4_2: { + id: 'd4_2', + number: '4.2', + text: 'Could measurement or ascertainment of the outcome have differed between intervention groups?', + responseType: 'STANDARD', + }, + d4_3: { + id: 'd4_3', + number: '4.3', + text: 'If N/PN/NI to 4.1 and 4.2: Were outcome assessors aware of the intervention received by study participants?', + responseType: 'WITH_NA', + }, + d4_4: { + id: 'd4_4', + number: '4.4', + text: 'If Y/PY/NI to 4.3: Could assessment of the outcome have been influenced by knowledge of intervention received?', + responseType: 'WITH_NA', + }, + d4_5: { + id: 'd4_5', + number: '4.5', + text: 'If Y/PY/NI to 4.4: Is it likely that assessment of the outcome was influenced by knowledge of intervention received?', + responseType: 'WITH_NA', + }, + }, + hasDirection: true, +}; + +// Domain 5: Selection of the reported result +export const DOMAIN_5: ROB2Domain = { + id: 'domain5', + name: 'Domain 5: Risk of bias in selection of the reported result', + questions: { + d5_1: { + id: 'd5_1', + number: '5.1', + text: 'Were the data that produced this result analysed in accordance with a pre-specified analysis plan that was finalized before unblinded outcome data were available for analysis?', + responseType: 'STANDARD', + }, + d5_2: { + id: 'd5_2', + number: '5.2', + text: 'Is the numerical result being assessed likely to have been selected, on the basis of the results, from multiple eligible outcome measurements (e.g. scales, definitions, time points) within the outcome domain?', + responseType: 'STANDARD', + }, + d5_3: { + id: 'd5_3', + number: '5.3', + text: 'Is the numerical result being assessed likely to have been selected, on the basis of the results, from multiple eligible analyses of the data?', + responseType: 'STANDARD', + }, + }, + hasDirection: true, +}; + +// Complete ROB-2 checklist structure +export const ROB2_CHECKLIST = { + domain1: DOMAIN_1, + domain2a: DOMAIN_2A, + domain2b: DOMAIN_2B, + domain3: DOMAIN_3, + domain4: DOMAIN_4, + domain5: DOMAIN_5, +} as const; + +export type DomainKey = 'domain1' | 'domain2a' | 'domain2b' | 'domain3' | 'domain4' | 'domain5'; + +// Get all domain keys +export function getDomainKeys(): DomainKey[] { + return ['domain1', 'domain2a', 'domain2b', 'domain3', 'domain4', 'domain5']; +} + +// Get active domain keys based on aim selection +export function getActiveDomainKeys(isAdhering: boolean): DomainKey[] { + return isAdhering + ? ['domain1', 'domain2b', 'domain3', 'domain4', 'domain5'] + : ['domain1', 'domain2a', 'domain3', 'domain4', 'domain5']; +} + +// Get questions for a domain +export function getDomainQuestions(domainKey: string): Record { + const domain = ROB2_CHECKLIST[domainKey as keyof typeof ROB2_CHECKLIST]; + if (!domain) return {}; + return domain.questions || {}; +} + +// Get response options for a response type +export function getResponseOptions(responseType: ResponseType): readonly string[] { + return RESPONSE_TYPES[responseType] || RESPONSE_TYPES.STANDARD; +} diff --git a/packages/shared/src/checklists/rob2/scoring.ts b/packages/shared/src/checklists/rob2/scoring.ts new file mode 100644 index 000000000..74272d2fd --- /dev/null +++ b/packages/shared/src/checklists/rob2/scoring.ts @@ -0,0 +1,677 @@ +/** + * ROB-2 Smart Scoring Engine + * + * Implements deterministic, table-driven scoring for all ROB-2 domains + * based on the official decision algorithms. + */ + +import { JUDGEMENTS, type Judgement, type DomainKey } from './schema.js'; + +// Helper: check if answer matches any value in a set +const inSet = (answer: string | null | undefined, ...values: string[]): boolean => + values.includes(answer as string); + +// Normalization: treat NA as NI for scoring to avoid "stuck" branches +const normalizeAnswer = (answer: string | null | undefined): string | null => + answer === 'NA' ? 'NI' : (answer ?? null); + +// Helper: check if answer is Yes or Probably Yes +const isYesPY = (answer: string | null): boolean => inSet(answer, 'Y', 'PY'); + +// Helper: check if answer is No or Probably No +const isNoPPN = (answer: string | null): boolean => inSet(answer, 'N', 'PN'); + +// Helper: check if answer is No, Probably No, or No Information +const isNoPPNNI = (answer: string | null): boolean => inSet(answer, 'N', 'PN', 'NI'); + +export interface ScoringResult { + judgement: Judgement | null; + isComplete: boolean; + ruleId: string | null; +} + +export interface DomainAnswers { + [questionKey: string]: { + answer: string | null; + comment?: string; + }; +} + +/** + * Score Domain 1 (Bias arising from the randomization process) + * + * Algorithm from decision diagram: + * - Start at 1.2 (concealment) + * - Y/PY -> 1.1 (random sequence) -> 1.3 (baseline imbalances) + * - N/PN -> High + * - NI -> 1.3 -> outcomes + */ +function scoreDomain1(answers: DomainAnswers): ScoringResult { + const q1 = normalizeAnswer(answers.d1_1?.answer); // random sequence + const q2 = normalizeAnswer(answers.d1_2?.answer); // concealment + const q3 = normalizeAnswer(answers.d1_3?.answer); // baseline imbalances + + // Start at 1.2 (concealment) + if (q2 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 1.2 N/PN -> High + if (isNoPPN(q2)) { + return { judgement: JUDGEMENTS.HIGH, isComplete: true, ruleId: 'D1.R1' }; + } + + // 1.2 Y/PY -> 1.1 + if (isYesPY(q2)) { + if (q1 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 1.1 N/PN -> Some concerns (regardless of 1.3) + if (isNoPPN(q1)) { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D1.R2' }; + } + + // 1.1 Y/PY/NI -> 1.3 + if (isYesPY(q1) || q1 === 'NI') { + if (q3 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 1.3 N/PN/NI -> Low + if (isNoPPNNI(q3)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D1.R3' }; + } + + // 1.3 Y/PY -> Some concerns + if (isYesPY(q3)) { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D1.R4' }; + } + } + } + + // 1.2 NI -> 1.3 + if (q2 === 'NI') { + if (q3 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 1.3 N/PN/NI -> Some concerns + if (isNoPPNNI(q3)) { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D1.R5' }; + } + + // 1.3 Y/PY -> High + if (isYesPY(q3)) { + return { judgement: JUDGEMENTS.HIGH, isComplete: true, ruleId: 'D1.R6' }; + } + } + + return { judgement: null, isComplete: false, ruleId: null }; +} + +/** + * Score Domain 2a Part 1 (Questions 2.1-2.5) + */ +function scoreDomain2aPart1(answers: DomainAnswers): ScoringResult { + const q1 = normalizeAnswer(answers.d2a_1?.answer); // participants aware + const q2 = normalizeAnswer(answers.d2a_2?.answer); // personnel aware + const q3 = normalizeAnswer(answers.d2a_3?.answer); // deviations from trial context + const q4 = normalizeAnswer(answers.d2a_4?.answer); // affect outcome + const q5 = normalizeAnswer(answers.d2a_5?.answer); // balanced + + // Need both 2.1 and 2.2 answered + if (q1 === null || q2 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // Both N/PN -> Low + if (isNoPPN(q1) && isNoPPN(q2)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D2A.P1.R1' }; + } + + // Either Y/PY/NI -> 2.3 + if (isYesPY(q1) || isYesPY(q2) || q1 === 'NI' || q2 === 'NI') { + if (q3 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 2.3 N/PN -> Low + if (isNoPPN(q3)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D2A.P1.R2' }; + } + + // 2.3 NI -> Some concerns + if (q3 === 'NI') { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D2A.P1.R3' }; + } + + // 2.3 Y/PY -> 2.4 + if (isYesPY(q3)) { + if (q4 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 2.4 N/PN -> Some concerns + if (isNoPPN(q4)) { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D2A.P1.R4' }; + } + + // 2.4 Y/PY/NI -> 2.5 + if (isYesPY(q4) || q4 === 'NI') { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 2.5 Y/PY -> Some concerns + if (isYesPY(q5)) { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D2A.P1.R5' }; + } + + // 2.5 N/PN/NI -> High + if (isNoPPNNI(q5)) { + return { judgement: JUDGEMENTS.HIGH, isComplete: true, ruleId: 'D2A.P1.R6' }; + } + } + } + } + + return { judgement: null, isComplete: false, ruleId: null }; +} + +/** + * Score Domain 2a Part 2 (Questions 2.6-2.7) + */ +function scoreDomain2aPart2(answers: DomainAnswers): ScoringResult { + const q6 = normalizeAnswer(answers.d2a_6?.answer); // appropriate analysis + const q7 = normalizeAnswer(answers.d2a_7?.answer); // substantial impact + + if (q6 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 2.6 Y/PY -> Low + if (isYesPY(q6)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D2A.P2.R1' }; + } + + // 2.6 N/PN/NI -> 2.7 + if (isNoPPNNI(q6)) { + if (q7 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 2.7 N/PN -> Some concerns + if (isNoPPN(q7)) { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D2A.P2.R2' }; + } + + // 2.7 Y/PY/NI -> High + if (isYesPY(q7) || q7 === 'NI') { + return { judgement: JUDGEMENTS.HIGH, isComplete: true, ruleId: 'D2A.P2.R3' }; + } + } + + return { judgement: null, isComplete: false, ruleId: null }; +} + +/** + * Score Domain 2a (Effect of assignment to intervention) + * Combines Part 1 and Part 2, taking the worst rating + */ +function scoreDomain2a(answers: DomainAnswers): ScoringResult { + const part1 = scoreDomain2aPart1(answers); + const part2 = scoreDomain2aPart2(answers); + + // If either part is incomplete, return incomplete + if (!part1.isComplete || !part2.isComplete) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // Combine: take worst rating + const rankMap: Record = { + [JUDGEMENTS.LOW]: 0, + [JUDGEMENTS.SOME_CONCERNS]: 1, + [JUDGEMENTS.HIGH]: 2, + }; + + const p1Rank = part1.judgement ? rankMap[part1.judgement] : 0; + const p2Rank = part2.judgement ? rankMap[part2.judgement] : 0; + const worstRank = Math.max(p1Rank, p2Rank); + + const judgement = + worstRank === 2 ? JUDGEMENTS.HIGH + : worstRank === 1 ? JUDGEMENTS.SOME_CONCERNS + : JUDGEMENTS.LOW; + + return { + judgement, + isComplete: true, + ruleId: `D2A.Combined(${part1.ruleId},${part2.ruleId})`, + }; +} + +/** + * Score Domain 2b (Effect of adhering to intervention) + */ +function scoreDomain2b(answers: DomainAnswers): ScoringResult { + const q1 = normalizeAnswer(answers.d2b_1?.answer); // participants aware + const q2 = normalizeAnswer(answers.d2b_2?.answer); // personnel aware + const q3 = normalizeAnswer(answers.d2b_3?.answer); // balanced non-protocol + const q4 = normalizeAnswer(answers.d2b_4?.answer); // failures in implementation + const q5 = normalizeAnswer(answers.d2b_5?.answer); // non-adherence + const q6 = normalizeAnswer(answers.d2b_6?.answer); // appropriate analysis + + // Need both 2.1 and 2.2 answered + if (q1 === null || q2 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // Both N/PN -> go to 2.4/2.5 + if (isNoPPN(q1) && isNoPPN(q2)) { + // Check 2.4 and 2.5 + if (q4 === null || q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // Both NA/N/PN -> Low + if ((isNoPPN(q4) || q4 === 'NA') && (isNoPPN(q5) || q5 === 'NA')) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D2B.R1' }; + } + + // Either Y/PY/NI -> 2.6 + if (isYesPY(q4) || isYesPY(q5) || q4 === 'NI' || q5 === 'NI') { + if (q6 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 2.6 Y/PY -> Some concerns + if (isYesPY(q6)) { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D2B.R2' }; + } + + // 2.6 N/PN/NI -> High + if (isNoPPNNI(q6)) { + return { judgement: JUDGEMENTS.HIGH, isComplete: true, ruleId: 'D2B.R3' }; + } + } + } + + // Either Y/PY/NI -> 2.3 + if (isYesPY(q1) || isYesPY(q2) || q1 === 'NI' || q2 === 'NI') { + if (q3 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 2.3 NA/Y/PY -> go to 2.4/2.5 + if (q3 === 'NA' || isYesPY(q3)) { + if (q4 === null || q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // Both NA/N/PN -> Low + if ((isNoPPN(q4) || q4 === 'NA') && (isNoPPN(q5) || q5 === 'NA')) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D2B.R4' }; + } + + // Either Y/PY/NI -> 2.6 + if (isYesPY(q4) || isYesPY(q5) || q4 === 'NI' || q5 === 'NI') { + if (q6 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 2.6 Y/PY -> Some concerns + if (isYesPY(q6)) { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D2B.R5' }; + } + + // 2.6 N/PN/NI -> High + if (isNoPPNNI(q6)) { + return { judgement: JUDGEMENTS.HIGH, isComplete: true, ruleId: 'D2B.R6' }; + } + } + } + + // 2.3 N/PN/NI -> 2.6 + if (isNoPPNNI(q3)) { + if (q6 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 2.6 Y/PY -> Some concerns + if (isYesPY(q6)) { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D2B.R7' }; + } + + // 2.6 N/PN/NI -> High + if (isNoPPNNI(q6)) { + return { judgement: JUDGEMENTS.HIGH, isComplete: true, ruleId: 'D2B.R8' }; + } + } + } + + return { judgement: null, isComplete: false, ruleId: null }; +} + +/** + * Score Domain 3 (Missing outcome data) + */ +function scoreDomain3(answers: DomainAnswers): ScoringResult { + const q1 = normalizeAnswer(answers.d3_1?.answer); // data available + const q2 = normalizeAnswer(answers.d3_2?.answer); // evidence not biased + const q3 = normalizeAnswer(answers.d3_3?.answer); // could depend on true value + const q4 = normalizeAnswer(answers.d3_4?.answer); // likely depended on true value + + if (q1 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 3.1 Y/PY -> Low + if (isYesPY(q1)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D3.R1' }; + } + + // 3.1 N/PN/NI -> 3.2 + if (isNoPPNNI(q1)) { + if (q2 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 3.2 Y/PY -> Low + if (isYesPY(q2)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D3.R2' }; + } + + // 3.2 N/PN -> 3.3 + if (isNoPPN(q2)) { + if (q3 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 3.3 N/PN -> Low + if (isNoPPN(q3)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D3.R3' }; + } + + // 3.3 Y/PY/NI -> 3.4 + if (isYesPY(q3) || q3 === 'NI') { + if (q4 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 3.4 N/PN -> Some concerns + if (isNoPPN(q4)) { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D3.R4' }; + } + + // 3.4 Y/PY/NI -> High + if (isYesPY(q4) || q4 === 'NI') { + return { judgement: JUDGEMENTS.HIGH, isComplete: true, ruleId: 'D3.R5' }; + } + } + } + } + + return { judgement: null, isComplete: false, ruleId: null }; +} + +/** + * Score Domain 4 (Measurement of the outcome) + */ +function scoreDomain4(answers: DomainAnswers): ScoringResult { + const q1 = normalizeAnswer(answers.d4_1?.answer); // inappropriate method + const q2 = normalizeAnswer(answers.d4_2?.answer); // differ between groups + const q3 = normalizeAnswer(answers.d4_3?.answer); // assessors aware + const q4 = normalizeAnswer(answers.d4_4?.answer); // could be influenced + const q5 = normalizeAnswer(answers.d4_5?.answer); // likely influenced + + if (q1 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 4.1 Y/PY -> High + if (isYesPY(q1)) { + return { judgement: JUDGEMENTS.HIGH, isComplete: true, ruleId: 'D4.R1' }; + } + + // 4.1 N/PN/NI -> 4.2 + if (isNoPPNNI(q1)) { + if (q2 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 4.2 Y/PY -> High + if (isYesPY(q2)) { + return { judgement: JUDGEMENTS.HIGH, isComplete: true, ruleId: 'D4.R2' }; + } + + // 4.2 N/PN -> 4.3 (branch A) + if (isNoPPN(q2)) { + if (q3 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 4.3 N/PN -> Low + if (isNoPPN(q3)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D4.R3' }; + } + + // 4.3 Y/PY/NI -> 4.4 + if (isYesPY(q3) || q3 === 'NI') { + if (q4 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 4.4 N/PN -> Low + if (isNoPPN(q4)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D4.R4' }; + } + + // 4.4 Y/PY/NI -> 4.5 + if (isYesPY(q4) || q4 === 'NI') { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 4.5 N/PN -> Some concerns + if (isNoPPN(q5)) { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D4.R5' }; + } + + // 4.5 Y/PY/NI -> High + if (isYesPY(q5) || q5 === 'NI') { + return { judgement: JUDGEMENTS.HIGH, isComplete: true, ruleId: 'D4.R6' }; + } + } + } + } + + // 4.2 NI -> 4.3 (branch B - leads to Some concerns or High) + if (q2 === 'NI') { + if (q3 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 4.3 N/PN -> Some concerns + if (isNoPPN(q3)) { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D4.R7' }; + } + + // 4.3 Y/PY/NI -> 4.4 + if (isYesPY(q3) || q3 === 'NI') { + if (q4 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 4.4 N/PN -> Some concerns + if (isNoPPN(q4)) { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D4.R8' }; + } + + // 4.4 Y/PY/NI -> 4.5 + if (isYesPY(q4) || q4 === 'NI') { + if (q5 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 4.5 N/PN -> Some concerns + if (isNoPPN(q5)) { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D4.R9' }; + } + + // 4.5 Y/PY/NI -> High + if (isYesPY(q5) || q5 === 'NI') { + return { judgement: JUDGEMENTS.HIGH, isComplete: true, ruleId: 'D4.R10' }; + } + } + } + } + } + + return { judgement: null, isComplete: false, ruleId: null }; +} + +/** + * Score Domain 5 (Selection of the reported result) + */ +function scoreDomain5(answers: DomainAnswers): ScoringResult { + const q1 = normalizeAnswer(answers.d5_1?.answer); // pre-specified plan + const q2 = normalizeAnswer(answers.d5_2?.answer); // selected from measurements + const q3 = normalizeAnswer(answers.d5_3?.answer); // selected from analyses + + // Need 5.2 and 5.3 first + if (q2 === null || q3 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // Either 5.2 or 5.3 Y/PY -> High + if (isYesPY(q2) || isYesPY(q3)) { + return { judgement: JUDGEMENTS.HIGH, isComplete: true, ruleId: 'D5.R1' }; + } + + // At least one NI, but neither Y/PY -> Some concerns + if ((q2 === 'NI' || q3 === 'NI') && !isYesPY(q2) && !isYesPY(q3)) { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D5.R2' }; + } + + // Both N/PN -> check 5.1 + if (isNoPPN(q2) && isNoPPN(q3)) { + if (q1 === null) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + // 5.1 Y/PY -> Low + if (isYesPY(q1)) { + return { judgement: JUDGEMENTS.LOW, isComplete: true, ruleId: 'D5.R3' }; + } + + // 5.1 N/PN/NI -> Some concerns + if (isNoPPNNI(q1)) { + return { judgement: JUDGEMENTS.SOME_CONCERNS, isComplete: true, ruleId: 'D5.R4' }; + } + } + + return { judgement: null, isComplete: false, ruleId: null }; +} + +/** + * Main entry point: score a ROB-2 domain + */ +export function scoreRob2Domain( + domainKey: string, + answers: DomainAnswers | undefined, +): ScoringResult { + if (!answers) { + return { judgement: null, isComplete: false, ruleId: null }; + } + + switch (domainKey) { + case 'domain1': + return scoreDomain1(answers); + case 'domain2a': + return scoreDomain2a(answers); + case 'domain2b': + return scoreDomain2b(answers); + case 'domain3': + return scoreDomain3(answers); + case 'domain4': + return scoreDomain4(answers); + case 'domain5': + return scoreDomain5(answers); + default: + return { judgement: null, isComplete: false, ruleId: null }; + } +} + +export interface DomainState { + answers?: DomainAnswers; + judgement?: Judgement | null; + direction?: string | null; +} + +export interface ChecklistState { + preliminary?: { aim?: string }; + [domainKey: string]: DomainState | unknown; +} + +export interface DomainScoringInfo { + auto: ScoringResult; + judgement: Judgement | null; +} + +export interface AllDomainsResult { + domains: Record; + overall: Judgement | null; + isComplete: boolean; +} + +/** + * Score all active domains and return a summary + */ +export function scoreAllDomains(checklistState: ChecklistState | null): AllDomainsResult { + if (!checklistState) { + return { domains: {}, overall: null, isComplete: false }; + } + + const isAdhering = checklistState.preliminary?.aim === 'ADHERING'; + const activeDomainKeys: DomainKey[] = + isAdhering ? + ['domain1', 'domain2b', 'domain3', 'domain4', 'domain5'] + : ['domain1', 'domain2a', 'domain3', 'domain4', 'domain5']; + + const domains: Record = {}; + const judgements: Judgement[] = []; + + for (const domainKey of activeDomainKeys) { + const domainState = checklistState[domainKey] as DomainState | undefined; + const auto = scoreRob2Domain(domainKey, domainState?.answers); + + domains[domainKey] = { + auto, + judgement: auto.judgement, + }; + + if (auto.judgement) { + judgements.push(auto.judgement); + } + } + + // Calculate overall judgement + let overall: Judgement | null = null; + if (judgements.length === activeDomainKeys.length) { + // All domains complete + if (judgements.includes(JUDGEMENTS.HIGH)) { + overall = JUDGEMENTS.HIGH; + } else if (judgements.includes(JUDGEMENTS.SOME_CONCERNS)) { + overall = JUDGEMENTS.SOME_CONCERNS; + } else { + overall = JUDGEMENTS.LOW; + } + } + + return { + domains, + overall, + isComplete: judgements.length === activeDomainKeys.length, + }; +} diff --git a/packages/shared/src/checklists/types.ts b/packages/shared/src/checklists/types.ts index 3e23084c7..27cbdaf8e 100644 --- a/packages/shared/src/checklists/types.ts +++ b/packages/shared/src/checklists/types.ts @@ -14,7 +14,7 @@ export interface ChecklistMetadata { createdAt: string; assignedTo?: string | null; status?: ChecklistStatus; - type: 'AMSTAR2' | 'ROBINS_I'; + type: 'AMSTAR2' | 'ROBINS_I' | 'ROB2'; } /** @@ -170,3 +170,61 @@ export interface ROBINSIDomainScore { isComplete: boolean; ruleId: string | null; } + +/** + * ROB-2 response types + */ +export type ROB2Response = 'Y' | 'PY' | 'PN' | 'N' | 'NI' | 'NA' | null; + +/** + * ROB-2 question answer structure + */ +export interface ROB2QuestionAnswer { + answer: ROB2Response; + comment: string; +} + +/** + * ROB-2 domain state + */ +export interface ROB2DomainState { + answers: Record; + judgement: string | null; + direction: string | null; +} + +/** + * ROB-2 preliminary section state + */ +export interface ROB2PreliminaryState { + studyDesign: string | null; + experimental: string; + comparator: string; + numericalResult: string; + aim: 'ASSIGNMENT' | 'ADHERING' | null; + deviationsToAddress: string[]; + sources: Record; +} + +/** + * ROB-2 checklist structure + */ +export interface ROB2Checklist extends Omit { + type: 'ROB2'; + preliminary: ROB2PreliminaryState; + domain1: ROB2DomainState; + domain2a: ROB2DomainState; + domain2b: ROB2DomainState; + domain3: ROB2DomainState; + domain4: ROB2DomainState; + domain5: ROB2DomainState; + overall: { + judgement: string | null; + direction: string | null; + }; +} + +/** + * ROB-2 scoring result + */ +export type ROB2Score = 'Low' | 'Some concerns' | 'High' | 'Incomplete' | 'Error'; diff --git a/packages/web/src/checklist-registry/index.js b/packages/web/src/checklist-registry/index.js index 98b23bd46..fc610353e 100644 --- a/packages/web/src/checklist-registry/index.js +++ b/packages/web/src/checklist-registry/index.js @@ -22,6 +22,11 @@ import { scoreChecklist as scoreROBINSI, getAnswers as getROBINSIAnswers, } from '@/components/checklist/ROBINSIChecklist/checklist.js'; +import { + createChecklist as createROB2, + scoreChecklist as scoreROB2, + getAnswers as getROB2Answers, +} from '@/components/checklist/ROB2Checklist/checklist.js'; /** * Registry mapping checklist types to their implementations @@ -43,6 +48,12 @@ export const CHECKLIST_REGISTRY = { scoreChecklist: scoreROBINSI, getAnswers: getROBINSIAnswers, }, + + [CHECKLIST_TYPES.ROB2]: { + createChecklist: createROB2, + scoreChecklist: scoreROB2, + getAnswers: getROB2Answers, + }, }; /** @@ -93,6 +104,10 @@ export function getChecklistTypeFromState(checklistState) { if (checklistState?.type) { return checklistState.type; } + // Detect ROB-2 by structure (has domain2a or domain2b) + if (checklistState?.domain2a || checklistState?.domain2b) { + return CHECKLIST_TYPES.ROB2; + } // Detect ROBINS-I by structure if (checklistState?.sectionB || checklistState?.domain1a || checklistState?.domain1b) { return CHECKLIST_TYPES.ROBINS_I; diff --git a/packages/web/src/checklist-registry/types.js b/packages/web/src/checklist-registry/types.js index 8225854f9..b58db517c 100644 --- a/packages/web/src/checklist-registry/types.js +++ b/packages/web/src/checklist-registry/types.js @@ -13,9 +13,9 @@ import { LANDING_URL } from '@/config/api.js'; export const CHECKLIST_TYPES = { AMSTAR2: 'AMSTAR2', ROBINS_I: 'ROBINS_I', + ROB2: 'ROB2', // Future types: // ROBINS_E: 'ROBINS_E', - // ROB2: 'ROB2', // GRADE: 'GRADE', }; @@ -52,6 +52,20 @@ export const CHECKLIST_METADATA = { Incomplete: { bg: 'bg-gray-100', text: 'text-gray-600' }, }, }, + [CHECKLIST_TYPES.ROB2]: { + name: 'RoB 2', + shortName: 'RoB 2', + description: 'Risk of bias in randomized trials', + version: '2.0', + url: 'https://www.riskofbias.info/welcome/rob-2-0-tool', + scoreLevels: ['Low', 'Some concerns', 'High', 'Incomplete'], + scoreColors: { + Low: { bg: 'bg-green-100', text: 'text-green-800' }, + 'Some concerns': { bg: 'bg-yellow-100', text: 'text-yellow-800' }, + High: { bg: 'bg-red-100', text: 'text-red-800' }, + Incomplete: { bg: 'bg-gray-100', text: 'text-gray-600' }, + }, + }, }; /** diff --git a/packages/web/src/components/checklist/ChecklistWithPdf.jsx b/packages/web/src/components/checklist/ChecklistWithPdf.jsx index 366aaf544..b87083f3d 100644 --- a/packages/web/src/components/checklist/ChecklistWithPdf.jsx +++ b/packages/web/src/components/checklist/ChecklistWithPdf.jsx @@ -45,6 +45,7 @@ export default function ChecklistWithPdf(props) { readOnly={props.readOnly} getQuestionNote={props.getQuestionNote} getRobinsText={props.getRobinsText} + getRob2Text={props.getRob2Text} /> {/* Second panel: PDF Viewer */} diff --git a/packages/web/src/components/checklist/ChecklistYjsWrapper.jsx b/packages/web/src/components/checklist/ChecklistYjsWrapper.jsx index dd8c52bd8..dccb0a539 100644 --- a/packages/web/src/components/checklist/ChecklistYjsWrapper.jsx +++ b/packages/web/src/components/checklist/ChecklistYjsWrapper.jsx @@ -21,6 +21,7 @@ import { IoChevronBack } from 'solid-icons/io'; import ScoreTag from '@/components/checklist/ScoreTag.jsx'; import { isAMSTAR2Complete } from '@/components/checklist/AMSTAR2Checklist/checklist.js'; import { isROBINSIComplete } from '@/components/checklist/ROBINSIChecklist/checklist.js'; +import { isROB2Complete } from '@/components/checklist/ROB2Checklist/checklist.js'; export default function ChecklistYjsWrapper() { const params = useParams(); @@ -52,6 +53,7 @@ export default function ChecklistYjsWrapper() { addPdfToStudy, getQuestionNote, getRobinsText, + getRob2Text, } = projectOps || {}; // Set active project for action store @@ -267,18 +269,29 @@ export default function ChecklistYjsWrapper() { 'domain6', 'overall', ]); + const ROB2_KEYS = new Set([ + 'preliminary', + 'domain1', + 'domain2a', + 'domain2b', + 'domain3', + 'domain4', + 'domain5', + 'overall', + ]); - // Handle partial updates from checklist components (AMSTAR2 or ROBINS-I) - // Both use object-style API: onUpdate({ key: value }) + // Handle partial updates from checklist components (AMSTAR2, ROBINS-I, or ROB2) + // All use object-style API: onUpdate({ key: value }) function handlePartialUpdate(patch) { if (isReadOnly()) return; const type = checklistType(); Object.entries(patch).forEach(([key, value]) => { - // AMSTAR2: keys like q1, q2a, etc. | ROBINS-I: section and domain keys + // Validate key based on checklist type const isValidKey = (type === 'AMSTAR2' && AMSTAR2_KEY_PATTERN.test(key)) || - (type === 'ROBINS_I' && ROBINS_I_KEYS.has(key)); + (type === 'ROBINS_I' && ROBINS_I_KEYS.has(key)) || + (type === 'ROB2' && ROB2_KEYS.has(key)); if (isValidKey) { updateChecklistAnswer(params.studyId, params.checklistId, key, value); } @@ -302,8 +315,8 @@ export default function ChecklistYjsWrapper() { if (!isChecklistValid()) { const type = checklistType(); const message = - type === 'ROBINS_I' ? - 'An overall risk of bias judgement must be set before marking the checklist as complete.' + type === 'ROBINS_I' || type === 'ROB2' ? + 'All domains must be scored before marking the checklist as complete.' : 'All questions must have a final answer before marking the checklist as complete.'; showToast.error('Incomplete Checklist', message); return; @@ -364,6 +377,10 @@ export default function ChecklistYjsWrapper() { return isROBINSIComplete(checklist); } + if (type === 'ROB2') { + return isROB2Complete(checklist); + } + // For other checklist types, allow completion return true; }); @@ -484,6 +501,9 @@ export default function ChecklistYjsWrapper() { getRobinsText={(sectionKey, fieldKey, questionKey) => getRobinsText(params.studyId, params.checklistId, sectionKey, fieldKey, questionKey) } + getRob2Text={(sectionKey, fieldKey, questionKey) => + getRob2Text(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 e2d527474..02f64d6ad 100644 --- a/packages/web/src/components/checklist/GenericChecklist.jsx +++ b/packages/web/src/components/checklist/GenericChecklist.jsx @@ -13,6 +13,7 @@ import { } from '@/checklist-registry'; import AMSTAR2Checklist from '@/components/checklist/AMSTAR2Checklist/AMSTAR2Checklist.jsx'; import { ROBINSIChecklist } from '@/components/checklist/ROBINSIChecklist/index.js'; +import { ROB2Checklist } from '@/components/checklist/ROB2Checklist/ROB2Checklist.jsx'; /** * GenericChecklist Component @@ -59,6 +60,16 @@ export default function GenericChecklist(props) { getRobinsText={props.getRobinsText} /> + + + ); } diff --git a/packages/web/src/components/checklist/ROB2Checklist/DomainJudgement.jsx b/packages/web/src/components/checklist/ROB2Checklist/DomainJudgement.jsx new file mode 100644 index 000000000..d710904a7 --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/DomainJudgement.jsx @@ -0,0 +1,132 @@ +import { For, Show } from 'solid-js'; +import { JUDGEMENTS, BIAS_DIRECTIONS } from './checklist-map.js'; + +/** + * Judgement badge component for displaying risk of bias + */ +export function JudgementBadge(props) { + const getColor = () => { + switch (props.judgement) { + case 'Low': + return 'bg-green-100 text-green-800 border-green-300'; + case 'Some concerns': + return 'bg-yellow-100 text-yellow-800 border-yellow-300'; + case 'High': + return 'bg-red-100 text-red-800 border-red-300'; + default: + return 'bg-gray-100 text-gray-600 border-gray-300'; + } + }; + + return ( + + {props.judgement} + + ); +} + +/** + * Domain judgement selector component + * @param {Object} props + * @param {string} props.domainId - Domain identifier + * @param {string|null} props.judgement - Current judgement value + * @param {string|null} props.direction - Current direction of bias + * @param {Function} props.onJudgementChange - Callback when judgement changes + * @param {Function} props.onDirectionChange - Callback when direction changes + * @param {boolean} [props.showDirection] - Whether to show direction selector + * @param {boolean} [props.disabled] - Whether the selector is disabled + * @param {boolean} [props.isAutoMode] - Whether in auto-scoring mode (read-only display) + */ +export function DomainJudgement(props) { + const judgementOptions = Object.values(JUDGEMENTS); + + const getJudgementColor = (judgement, isSelected) => { + if (!isSelected) { + return props.isAutoMode ? + 'border-gray-200 bg-gray-50 text-gray-400 cursor-not-allowed' + : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 cursor-pointer'; + } + + switch (judgement) { + case 'Low': + return 'bg-green-100 border-green-400 text-green-800'; + case 'Some concerns': + return 'bg-yellow-100 border-yellow-400 text-yellow-800'; + case 'High': + return 'bg-red-100 border-red-400 text-red-800'; + default: + return 'bg-gray-50 border-gray-200 text-gray-600'; + } + }; + + return ( +
+ {/* Judgement buttons */} +
+
+ {props.isAutoMode ? 'Auto-calculated judgement' : 'Select judgement'} +
+
+ + {judgement => { + const isSelected = () => props.judgement === judgement; + return ( + + ); + }} + +
+
+ + {/* Direction of bias (optional) */} + +
+
+ Predicted direction of bias + (optional) +
+
+ + {direction => { + const isSelected = () => props.direction === direction; + return ( + + ); + }} + +
+
+
+
+ ); +} + +export default DomainJudgement; diff --git a/packages/web/src/components/checklist/ROB2Checklist/DomainSection.jsx b/packages/web/src/components/checklist/ROB2Checklist/DomainSection.jsx new file mode 100644 index 000000000..46eb07ae2 --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/DomainSection.jsx @@ -0,0 +1,184 @@ +import { For, Show, createMemo } from 'solid-js'; +import { ROB2_CHECKLIST, getDomainQuestions } from './checklist-map.js'; +import { SignallingQuestion } from './SignallingQuestion.jsx'; +import { DomainJudgement, JudgementBadge } from './DomainJudgement.jsx'; +import { scoreRob2Domain } from './checklist.js'; + +/** + * A complete domain section with questions and judgement + * Uses auto-scoring: calculated judgements from the decision algorithm + * + * @param {Object} props + * @param {string} props.domainKey - The domain key (e.g., 'domain1') + * @param {Object} props.domainState - Current domain state { answers, judgement, 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.getRob2Text] - Function to get Y.Text for a ROB-2 free-text field + */ +export function DomainSection(props) { + const domain = () => ROB2_CHECKLIST[props.domainKey]; + const questions = () => getDomainQuestions(props.domainKey); + + // Smart scoring: compute auto judgement from answers + const autoScore = createMemo(() => { + return scoreRob2Domain(props.domainKey, props.domainState?.answers); + }); + + // Early completion: scoring determined before all questions answered + const isEarlyComplete = createMemo(() => { + return autoScore().isComplete && autoScore().judgement !== null; + }); + + // Check if a specific question can be skipped (scoring done, question unanswered) + const isQuestionSkippable = qKey => { + return isEarlyComplete() && !props.domainState?.answers?.[qKey]?.answer; + }; + + // Effective judgement: use auto-calculated value + const effectiveJudgement = createMemo(() => { + return autoScore().judgement; + }); + + function handleQuestionUpdate(questionKey, newAnswer) { + const newAnswers = { + ...props.domainState?.answers, + [questionKey]: newAnswer, + }; + + // Compute what the auto judgement would be with new answers + const newAutoScore = scoreRob2Domain(props.domainKey, newAnswers); + + const newState = { + ...props.domainState, + answers: newAnswers, + judgement: newAutoScore.judgement, + }; + + props.onUpdate(newState); + } + + function handleDirectionChange(direction) { + props.onUpdate({ + ...props.domainState, + direction, + }); + } + + // Get completion status + const completionStatus = () => { + const qs = questions(); + const answered = Object.keys(qs).filter( + k => props.domainState?.answers?.[k]?.answer !== null, + ).length; + const total = Object.keys(qs).length; + return { answered, total }; + }; + + return ( +
+ {/* Domain header */} + + + {/* Domain content */} + +
+ {/* Questions */} +
+ + {([qKey, qDef]) => ( + handleQuestionUpdate(qKey, newAnswer)} + disabled={props.disabled} + showComment={props.showComments} + domainKey={props.domainKey} + questionKey={qKey} + getRob2Text={props.getRob2Text} + isSkippable={isQuestionSkippable(qKey)} + /> + )} + +
+ + {/* Auto judgement section */} +
+ {/* Calculated judgement display */} +
+ Risk of bias judgement + +
+ Calculated: + +
+
+ + (answer more questions) + +
+ + {/* Direction selector */} + + {}} + onDirectionChange={handleDirectionChange} + showDirection={true} + disabled={props.disabled} + isAutoMode={true} + /> + +
+
+
+
+ ); +} + +export default DomainSection; diff --git a/packages/web/src/components/checklist/ROB2Checklist/OverallSection.jsx b/packages/web/src/components/checklist/ROB2Checklist/OverallSection.jsx new file mode 100644 index 000000000..c8c7e5449 --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/OverallSection.jsx @@ -0,0 +1,176 @@ +import { For, Show, createMemo } from 'solid-js'; +import { BIAS_DIRECTIONS } from './checklist-map.js'; +import { getSmartScoring, mapOverallJudgementToDisplay } from './checklist.js'; + +/** + * Overall risk of bias section with final judgement + * Uses auto-scoring: calculated judgement from all domains + * + * @param {Object} props + * @param {Object} props.overallState - Current overall state { judgement, 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) { + // Smart scoring: compute auto judgement from all domains + const smartScoring = createMemo(() => getSmartScoring(props.checklistState)); + + // Calculated overall score + const calculatedScore = () => smartScoring().overall; + + // Calculated overall in display format + const calculatedDisplayJudgement = createMemo(() => { + const score = calculatedScore(); + return mapOverallJudgementToDisplay(score); + }); + + // Effective judgement: use calculated value + const effectiveJudgement = createMemo(() => { + return calculatedDisplayJudgement(); + }); + + function handleDirectionChange(direction) { + props.onUpdate({ + ...props.overallState, + direction, + }); + } + + const getJudgementColor = (judgement, isSelected) => { + if (!isSelected) { + return 'border-gray-200 bg-gray-50 text-gray-400'; + } + + switch (judgement) { + case 'Low': + case 'Low risk of bias': + return 'bg-green-100 border-green-400 text-green-800'; + case 'Some concerns': + return 'bg-yellow-100 border-yellow-400 text-yellow-800'; + case 'High': + case 'High risk of bias': + return 'bg-red-100 border-red-400 text-red-800'; + default: + return 'bg-gray-50 border-gray-200 text-gray-600'; + } + }; + + const getScoreBadgeColor = score => { + switch (score) { + case 'Low': + return 'bg-green-100 text-green-800'; + case 'Some concerns': + return 'bg-yellow-100 text-yellow-800'; + case 'High': + return 'bg-red-100 text-red-800'; + default: + return 'bg-gray-100 text-gray-600'; + } + }; + + const judgementOptions = [ + 'Low risk of bias', + 'Some concerns', + 'High risk of bias', + ]; + + return ( +
+
+
+
+

Overall Risk of Bias

+

+ Final assessment based on all domain judgements +

+
+ + {/* Overall calculated badge in header */} + + + {calculatedScore()} + + + + Incomplete + +
+
+ +
+ {/* Calculated score display */} +
+
+ Calculated judgement: + Complete all domains} + > + + {effectiveJudgement()} + + +
+
+ + {/* Overall risk of bias judgement display (read-only) */} +
+
Overall risk of bias judgement
+
+ + {judgement => { + const isSelected = () => effectiveJudgement() === judgement; + return ( +
+ {judgement} +
+ ); + }} +
+
+
+ + {/* Direction of bias */} +
+
+ Predicted direction of bias + (optional) +
+
+ + {direction => { + const isSelected = () => props.overallState?.direction === direction; + return ( + + ); + }} + +
+
+
+
+ ); +} + +export default OverallSection; diff --git a/packages/web/src/components/checklist/ROB2Checklist/PreliminarySection.jsx b/packages/web/src/components/checklist/ROB2Checklist/PreliminarySection.jsx new file mode 100644 index 000000000..eae4371aa --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/PreliminarySection.jsx @@ -0,0 +1,299 @@ +import { For, Show } from 'solid-js'; +import { + PRELIMINARY_SECTION, + STUDY_DESIGNS, + AIM_OPTIONS, + DEVIATION_OPTIONS, + INFORMATION_SOURCES, +} from './checklist-map.js'; +import NoteEditor from '@/components/checklist/common/NoteEditor.jsx'; + +/** + * Preliminary considerations section for ROB-2 + * + * @param {Object} props + * @param {Object} props.preliminaryState - Current preliminary state + * @param {Function} props.onUpdate - Callback when state changes + * @param {boolean} [props.disabled] - Whether the section is disabled + * @param {Function} [props.getRob2Text] - Function to get Y.Text for free-text fields + */ +export function PreliminarySection(props) { + function handleStudyDesignChange(value) { + props.onUpdate({ + ...props.preliminaryState, + studyDesign: value, + }); + } + + function handleAimChange(aim) { + props.onUpdate({ + ...props.preliminaryState, + aim: props.preliminaryState?.aim === aim ? null : aim, + }); + } + + function handleDeviationToggle(deviation) { + const current = props.preliminaryState?.deviationsToAddress || []; + const updated = current.includes(deviation) + ? current.filter(d => d !== deviation) + : [...current, deviation]; + props.onUpdate({ + ...props.preliminaryState, + deviationsToAddress: updated, + }); + } + + function handleSourceToggle(source) { + const current = props.preliminaryState?.sources || {}; + props.onUpdate({ + ...props.preliminaryState, + sources: { + ...current, + [source]: !current[source], + }, + }); + } + + const experimentalYText = () => props.getRob2Text?.('preliminary', 'experimental'); + const comparatorYText = () => props.getRob2Text?.('preliminary', 'comparator'); + const numericalResultYText = () => props.getRob2Text?.('preliminary', 'numericalResult'); + + return ( +
+
+

Preliminary Considerations

+

+ Complete these sections before assessing the domains +

+
+ +
+ {/* Study Design */} +
+ +
+ + {design => ( + + )} + +
+
+ + {/* Interventions */} +
+
+ + +
+
+ + +
+
+ + {/* Numerical Result */} +
+ + +
+ + {/* Aim Selection */} +
+ +
+ + + +
+
+ + {/* Deviations to Address (only for ADHERING) */} + +
+ +

{PRELIMINARY_SECTION.deviationsToAddress.info}

+
+ + {deviation => { + const isChecked = () => + (props.preliminaryState?.deviationsToAddress || []).includes(deviation); + return ( + + ); + }} + +
+
+
+ + {/* Information Sources */} +
+ +
+ + {source => { + const isChecked = () => props.preliminaryState?.sources?.[source] || false; + return ( + + ); + }} + +
+
+
+
+ ); +} + +export default PreliminarySection; diff --git a/packages/web/src/components/checklist/ROB2Checklist/ROB2Checklist.jsx b/packages/web/src/components/checklist/ROB2Checklist/ROB2Checklist.jsx new file mode 100644 index 000000000..a2a37dfa0 --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/ROB2Checklist.jsx @@ -0,0 +1,149 @@ +import { createSignal, For, Show, createMemo } from 'solid-js'; +import { getActiveDomainKeys } from './checklist-map.js'; +import { PreliminarySection } from './PreliminarySection.jsx'; +import { DomainSection } from './DomainSection.jsx'; +import { OverallSection } from './OverallSection.jsx'; +import { ResponseLegend } from './SignallingQuestion.jsx'; +import { ScoringSummary } from './ScoringSummary.jsx'; + +/** + * Main ROB-2 Checklist Component + * + * @param {Object} props + * @param {Object} props.checklistState - The complete checklist state object + * @param {Function} props.onUpdate - Callback to update checklist state (key, value) + * @param {boolean} [props.showComments] - Whether to show comment fields for each question + * @param {boolean} [props.showLegend] - Whether to show the response legend + * @param {boolean} [props.readOnly] - Whether the checklist is read-only (disables all inputs) + * @param {Function} [props.getRob2Text] - Function to get Y.Text for a ROB-2 free-text field + */ +export function ROB2Checklist(props) { + const isReadOnly = () => !!props.readOnly; + + // Track collapsed state for each domain + const [collapsedDomains, setCollapsedDomains] = createSignal({}); + + // Get active domains based on aim selection (assignment vs. adhering) + const isAdhering = createMemo(() => props.checklistState?.preliminary?.aim === 'ADHERING'); + + const activeDomains = createMemo(() => getActiveDomainKeys(isAdhering())); + + // Check if aim is selected (determines which Domain 2 variant to show) + const hasAimSelected = createMemo(() => !!props.checklistState?.preliminary?.aim); + + // Update handlers - use object-style API + function handlePreliminaryUpdate(newPreliminary) { + props.onUpdate({ preliminary: newPreliminary }); + } + + function handleDomainUpdate(domainKey, newDomainState) { + props.onUpdate({ [domainKey]: newDomainState }); + } + + function handleOverallUpdate(newOverall) { + props.onUpdate({ overall: newOverall }); + } + + function toggleDomainCollapse(domainKey) { + setCollapsedDomains(prev => ({ + ...prev, + [domainKey]: !prev[domainKey], + })); + } + + // Handle domain chip click from summary - expand and scroll to domain + function handleDomainClick(domainKey) { + // Expand the domain if collapsed + setCollapsedDomains(prev => ({ + ...prev, + [domainKey]: false, + })); + + // Scroll to domain section after a short delay for DOM update + setTimeout(() => { + const element = document.getElementById(`domain-section-${domainKey}`); + element?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 100); + } + + return ( +
+
+
+ {props.checklistState?.name || 'RoB 2 Checklist'} +
+ + {/* Scoring Summary Strip - shows overall + domain status */} + +
+ +
+
+ + {/* Response Legend */} + + + + + {/* Preliminary Considerations */} + + + {/* Message when aim not selected */} + +
+
Select Assessment Aim
+

+ Please select the review team's aim in the Preliminary Considerations section above + to proceed with the domain assessment. +

+
+
+ + {/* Domain sections - only show when aim is selected */} + +
+ + {domainKey => ( +
+ handleDomainUpdate(domainKey, newState)} + disabled={isReadOnly()} + showComments={props.showComments} + collapsed={collapsedDomains()[domainKey]} + onToggleCollapse={() => toggleDomainCollapse(domainKey)} + getRob2Text={props.getRob2Text} + /> +
+ )} +
+
+ + +
+
+
+ ); +} + +export default ROB2Checklist; diff --git a/packages/web/src/components/checklist/ROB2Checklist/ScoringSummary.jsx b/packages/web/src/components/checklist/ROB2Checklist/ScoringSummary.jsx new file mode 100644 index 000000000..4e8213e6d --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/ScoringSummary.jsx @@ -0,0 +1,234 @@ +import { For, createMemo, createSignal } from 'solid-js'; +import { Portal } from 'solid-js/web'; +import { ROB2_CHECKLIST, getActiveDomainKeys } from './checklist-map.js'; +import { getSmartScoring } from './checklist.js'; +import { DialogPrimitive as Dialog } from '@corates/ui'; +import { FiExternalLink, FiInfo } from 'solid-icons/fi'; + +/** + * Compact scoring summary strip for ROB-2 checklist + * Shows overall calculated judgement and domain status chips + * + * @param {Object} props + * @param {Object} props.checklistState - Full checklist state + * @param {Function} [props.onDomainClick] - Callback when a domain chip is clicked + */ +export function ScoringSummary(props) { + const [resourcesOpen, setResourcesOpen] = createSignal(false); + + const smartScoring = createMemo(() => getSmartScoring(props.checklistState)); + + const isAdhering = () => props.checklistState?.preliminary?.aim === 'ADHERING'; + const activeDomains = createMemo(() => getActiveDomainKeys(isAdhering())); + + // Count complete domains + const domainStats = createMemo(() => { + const scoring = smartScoring(); + let complete = 0; + let total = activeDomains().length; + + activeDomains().forEach(domainKey => { + const domainInfo = scoring.domains[domainKey]; + if (domainInfo?.effective) { + complete++; + } + }); + + return { complete, total }; + }); + + const getOverallColor = () => { + const overall = smartScoring().overall; + switch (overall) { + case 'Low': + return 'bg-green-500'; + case 'Some concerns': + return 'bg-yellow-500'; + case 'High': + return 'bg-red-500'; + default: + return 'bg-gray-400'; + } + }; + + const getDomainChipColor = domainKey => { + const domainInfo = smartScoring().domains[domainKey]; + if (!domainInfo?.effective) { + return 'bg-gray-100 text-gray-500 border-gray-200'; + } + + const judgement = domainInfo.effective; + + switch (judgement) { + case 'Low': + return 'bg-green-100 text-green-800 border-green-300'; + case 'Some concerns': + return 'bg-yellow-100 text-yellow-800 border-yellow-300'; + case 'High': + return 'bg-red-100 text-red-800 border-red-300'; + default: + return 'bg-gray-100 text-gray-600 border-gray-300'; + } + }; + + const getDomainShortName = domainKey => { + switch (domainKey) { + case 'domain1': + return 'D1'; + case 'domain2a': + return 'D2'; + case 'domain2b': + return 'D2'; + case 'domain3': + return 'D3'; + case 'domain4': + return 'D4'; + case 'domain5': + return 'D5'; + default: + return domainKey; + } + }; + + const getDomainStatusText = domainKey => { + const domainInfo = smartScoring().domains[domainKey]; + if (!domainInfo?.effective) { + return 'Incomplete'; + } + return domainInfo.effective; + }; + + return ( +
+
+ {/* Overall score section */} +
+
+
+ Overall: + + {smartScoring().overall || 'Incomplete'} + +
+ | + + {domainStats().complete}/{domainStats().total} domains + +
+ + {/* Domain chips */} +
+ + {domainKey => ( + + )} + + + {/* Resources button */} + +
+
+ + {/* Resources Dialog */} + setResourcesOpen(false)} /> +
+ ); +} + +/** + * Resources dialog with links to ROB-2 guidance + */ +function ResourcesDialog(props) { + return ( + !details.open && props.onClose()}> + + + + +
+ + ROB-2 Resources + + + Official guidance and documentation for the RoB 2 assessment tool. + +
+ +
+ + + + + + +
+

About Auto Scoring

+

+ This tool automatically calculates domain judgements based on your signalling + question responses, following the official RoB 2 decision algorithms. +

+
+
+ +
+ +
+
+
+
+
+ ); +} + +function ResourceLink(props) { + return ( + +
+
+

{props.title}

+

{props.description}

+
+ +
+
+ ); +} + +export default ScoringSummary; diff --git a/packages/web/src/components/checklist/ROB2Checklist/SignallingQuestion.jsx b/packages/web/src/components/checklist/ROB2Checklist/SignallingQuestion.jsx new file mode 100644 index 000000000..ea5839d19 --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/SignallingQuestion.jsx @@ -0,0 +1,122 @@ +import { For, Show, createEffect } from 'solid-js'; +import { RESPONSE_LABELS, getResponseOptions } from './checklist-map.js'; +import NoteEditor from '@/components/checklist/common/NoteEditor.jsx'; + +/** + * A single signalling question with radio button options + * @param {Object} props + * @param {Object} props.question - Question definition from checklist-map + * @param {Object} props.answer - Current answer state { answer, comment } + * @param {Function} props.onUpdate - Callback when answer changes + * @param {boolean} [props.disabled] - Whether the question is disabled + * @param {boolean} [props.showComment] - Whether to show comment field + * @param {string} [props.domainKey] - Domain key for comment Y.Text lookup + * @param {string} [props.questionKey] - Question key for comment Y.Text lookup + * @param {Function} [props.getRob2Text] - Function to get Y.Text for a ROB-2 free-text field + * @param {boolean} [props.isSkippable] - Whether this question can be skipped + */ +export function SignallingQuestion(props) { + const options = () => getResponseOptions(props.question.responseType); + + createEffect(() => { + // Only coerce NA to NI if NA is not a valid option for this question's response type + if (props.answer?.answer === 'NA' && !options().includes('NA')) { + props.onUpdate({ + ...props.answer, + answer: 'NI', + }); + } + }); + + function handleAnswerChange(value) { + // Toggle off if clicking the already-selected option + const newValue = props.answer?.answer === value ? null : value; + props.onUpdate({ + ...props.answer, + answer: newValue, + }); + } + + const commentYText = () => { + if (!props.showComment || !props.getRob2Text || !props.domainKey || !props.questionKey) { + return null; + } + return props.getRob2Text(props.domainKey, 'comment', props.questionKey); + }; + + return ( +
+
+ {/* Question number and text */} +
+ + {props.question.number} + + {props.question.text} + + (Optional) + +
+ + {/* Response options */} +
+ + {option => ( + + )} + +
+
+ + {/* Comment field (optional) */} + {props.showComment && ( +
+ +
+ )} +
+ ); +} + +/** + * Response legend component showing what each abbreviation means + */ +export function ResponseLegend() { + const commonResponses = ['Y', 'PY', 'PN', 'N', 'NI']; + + return ( +
+
Response Legend
+
+ + {code => ( + + {code} = {RESPONSE_LABELS[code]} + + )} + +
+
+ ); +} + +export default SignallingQuestion; diff --git a/packages/web/src/components/checklist/ROB2Checklist/checklist-map.js b/packages/web/src/components/checklist/ROB2Checklist/checklist-map.js new file mode 100644 index 000000000..17674e416 --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/checklist-map.js @@ -0,0 +1,47 @@ +/** + * ROB-2 Checklist Map + * + * Re-exports schema from shared package for component use. + */ + +import { rob2 } from '@corates/shared/checklists'; + +// Response types and labels +export const RESPONSE_TYPES = rob2.RESPONSE_TYPES; +export const RESPONSE_LABELS = rob2.RESPONSE_LABELS; +export const getResponseOptions = rob2.getResponseOptions; + +// Judgements +export const JUDGEMENTS = rob2.JUDGEMENTS; + +// Bias directions +export const BIAS_DIRECTIONS = rob2.BIAS_DIRECTIONS; + +// Study design options +export const STUDY_DESIGNS = rob2.STUDY_DESIGNS; + +// Aim options +export const AIM_OPTIONS = rob2.AIM_OPTIONS; + +// Deviation options +export const DEVIATION_OPTIONS = rob2.DEVIATION_OPTIONS; + +// Information sources +export const INFORMATION_SOURCES = rob2.INFORMATION_SOURCES; + +// Preliminary section schema +export const PRELIMINARY_SECTION = rob2.PRELIMINARY_SECTION; + +// Domain definitions +export const DOMAIN_1 = rob2.DOMAIN_1; +export const DOMAIN_2A = rob2.DOMAIN_2A; +export const DOMAIN_2B = rob2.DOMAIN_2B; +export const DOMAIN_3 = rob2.DOMAIN_3; +export const DOMAIN_4 = rob2.DOMAIN_4; +export const DOMAIN_5 = rob2.DOMAIN_5; +export const ROB2_CHECKLIST = rob2.ROB2_CHECKLIST; + +// Domain key helpers +export const getDomainKeys = rob2.getDomainKeys; +export const getActiveDomainKeys = rob2.getActiveDomainKeys; +export const getDomainQuestions = rob2.getDomainQuestions; diff --git a/packages/web/src/components/checklist/ROB2Checklist/checklist.js b/packages/web/src/components/checklist/ROB2Checklist/checklist.js new file mode 100644 index 000000000..7d3bcbd0e --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/checklist.js @@ -0,0 +1,63 @@ +/** + * ROB-2 Checklist Utilities + * + * Helper functions for ROB-2 checklist operations. + * Re-exports from @corates/shared while maintaining the expected interface. + */ + +import { rob2 } from '@corates/shared/checklists'; + +// Re-export functions from shared package with original names for registry compatibility +export const createChecklist = rob2.createROB2Checklist; +export const scoreChecklist = rob2.scoreROB2Checklist; +export const isROB2Complete = rob2.isROB2Complete; +export const getAnswers = rob2.getAnswers; +export const getSelectedAnswer = rob2.getSelectedAnswer; +export const getDomainSummary = rob2.getDomainSummary; +export const scoreRob2Domain = rob2.scoreRob2Domain; +export const scoreAllDomains = rob2.scoreAllDomains; +export const createROB2Checklist = rob2.createROB2Checklist; + +/** + * Get smart scoring for a checklist (domain and overall scores) + */ +export function getSmartScoring(checklistState) { + if (!checklistState) { + return { domains: {}, overall: null, isComplete: false }; + } + + const result = scoreAllDomains(checklistState); + + // Convert to expected format + const domains = {}; + Object.entries(result.domains).forEach(([key, info]) => { + domains[key] = { + auto: info.auto?.judgement || null, + effective: info.judgement, + source: 'auto', + isOverridden: false, + }; + }); + + return { + domains, + overall: result.overall, + isComplete: result.isComplete, + }; +} + +/** + * Map overall score to display format + */ +export function mapOverallJudgementToDisplay(score) { + switch (score) { + case 'Low': + return 'Low risk of bias'; + case 'Some concerns': + return 'Some concerns'; + case 'High': + return 'High risk of bias'; + default: + return score; + } +} diff --git a/packages/web/src/components/checklist/ROB2Checklist/index.js b/packages/web/src/components/checklist/ROB2Checklist/index.js new file mode 100644 index 000000000..aca34b9ee --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/index.js @@ -0,0 +1,22 @@ +/** + * ROB-2 Checklist Module + * + * Main entry point for ROB-2 checklist component and utilities. + */ + +export { ROB2Checklist, default } from './ROB2Checklist.jsx'; +export { PreliminarySection } from './PreliminarySection.jsx'; +export { DomainSection } from './DomainSection.jsx'; +export { OverallSection } from './OverallSection.jsx'; +export { SignallingQuestion, ResponseLegend } from './SignallingQuestion.jsx'; +export { DomainJudgement, JudgementBadge } from './DomainJudgement.jsx'; +export { ScoringSummary } from './ScoringSummary.jsx'; + +// Re-export scoring and utilities from checklist.js +export { + createROB2Checklist as createChecklist, + scoreROB2Checklist as scoreChecklist, + getAnswers, + isROB2Complete as isComplete, + getSmartScoring, +} from './checklist.js'; diff --git a/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/preliminary.md b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/preliminary.md new file mode 100644 index 000000000..f2b18b65f --- /dev/null +++ b/packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/preliminary.md @@ -0,0 +1,37 @@ +Preliminary considerations +Study design + Individually-randomized parallel-group trial + Cluster-randomized parallel-group trial + Individually randomized cross-over (or other matched) trial +For the purposes of this assessment, the interventions being compared are defined as +Experimental: Comparator: + +Specify which outcome is being assessed for risk of bias +Specify the numerical result being assessed. In case of multiple alternative +analyses being presented, specify the numeric result (e.g. RR = 1.52 (95% CI +0.83 to 2.77) and/or a reference (e.g. to a table, figure or paragraph) that +uniquely defines the result being assessed. +Is the review team’s aim for this result...? + to assess the effect of assignment to intervention (the ‘intention-to-treat’ effect) + to assess the effect of adhering to intervention (the ‘per-protocol’ effect) +If the aim is to assess the effect of adhering to intervention, select the deviations from intended intervention that should be addressed (at least one must be +checked): + occurrence of non-protocol interventions + failures in implementing the intervention that could have affected the outcome + non-adherence to their assigned intervention by trial participants + +3 + +Which of the following sources were obtained to help inform the risk-of-bias assessment? (tick as many as apply) + Journal article(s) + Trial protocol + Statistical analysis plan (SAP) + Non-commercial trial registry record (e.g. ClinicalTrials.gov record) + Company-owned trial registry record (e.g. GSK Clinical Study Register record) + “Grey literature” (e.g. unpublished thesis) + Conference abstract(s) about the trial + Regulatory document (e.g. Clinical Study Report, Drug Approval Package) + Research ethics application + Grant database summary (e.g. NIH RePORTER or Research Councils UK Gateway to Research) + Personal communication with trialist + Personal communication with the sponsor diff --git a/packages/web/src/primitives/useProject/checklists/handlers/rob2.js b/packages/web/src/primitives/useProject/checklists/handlers/rob2.js new file mode 100644 index 000000000..f4e85b8e4 --- /dev/null +++ b/packages/web/src/primitives/useProject/checklists/handlers/rob2.js @@ -0,0 +1,312 @@ +/** + * ROB-2 checklist type handler + */ + +import * as Y from 'yjs'; +import { ChecklistHandler, yTextToString } from './base.js'; + +export class ROB2Handler extends ChecklistHandler { + /** + * Extract answer structure from ROB-2 checklist template + * @param {Object} template - The checklist template from createChecklistOfType + * @returns {Object} Extracted answers data structure + */ + extractAnswersFromTemplate(template) { + const answersData = {}; + // ROB-2: Extract preliminary and all domain data + const rob2Keys = [ + 'preliminary', + 'domain1', + 'domain2a', + 'domain2b', + 'domain3', + 'domain4', + 'domain5', + 'overall', + ]; + rob2Keys.forEach(key => { + if (template[key] !== undefined) { + answersData[key] = template[key]; + } + }); + return answersData; + } + + /** + * Create Y.Map structure for ROB-2 answers + * @param {Object} answersData - The extracted answers data + * @returns {Y.Map} The answers Y.Map + */ + createAnswersYMap(answersData) { + const answersYMap = new Y.Map(); + + // ROB-2: Store each section/domain as nested Y.Maps + Object.entries(answersData).forEach(([key, value]) => { + const sectionYMap = new Y.Map(); + + if (key.startsWith('domain')) { + // Domain keys have nested 'answers' object with individual questions + sectionYMap.set('judgement', value.judgement ?? null); + if (value.direction !== undefined) { + sectionYMap.set('direction', value.direction ?? null); + } + + // Store each question as a nested Y.Map for concurrent edits + if (value.answers) { + const answersNestedYMap = new Y.Map(); + Object.entries(value.answers).forEach(([qKey, qValue]) => { + const questionYMap = new Y.Map(); + questionYMap.set('answer', qValue.answer ?? null); + questionYMap.set('comment', new Y.Text()); + answersNestedYMap.set(qKey, questionYMap); + }); + sectionYMap.set('answers', answersNestedYMap); + } + } else if (key === 'overall') { + // Overall section has judgement and direction but no nested answers + sectionYMap.set('judgement', value.judgement ?? null); + sectionYMap.set('direction', value.direction ?? null); + } else if (key === 'preliminary') { + // Preliminary section: multiple fields including free text + sectionYMap.set('studyDesign', value.studyDesign ?? null); + sectionYMap.set('experimental', new Y.Text()); + sectionYMap.set('comparator', new Y.Text()); + sectionYMap.set('numericalResult', new Y.Text()); + sectionYMap.set('aim', value.aim ?? null); + sectionYMap.set('deviationsToAddress', value.deviationsToAddress ?? []); + sectionYMap.set('sources', value.sources ?? {}); + } else { + // Other sections: store each field + Object.entries(value).forEach(([fieldKey, fieldValue]) => { + sectionYMap.set(fieldKey, fieldValue); + }); + } + + answersYMap.set(key, sectionYMap); + }); + + return answersYMap; + } + + /** + * Serialize ROB-2 answers Y.Map to plain object + * @param {Y.Map} answersMap - The answers Y.Map + * @returns {Object} Plain object with answers + */ + serializeAnswers(answersMap) { + const answers = {}; + for (const [key, sectionYMap] of answersMap.entries()) { + if (!(sectionYMap instanceof Y.Map)) { + answers[key] = sectionYMap; + continue; + } + + if (key.startsWith('domain')) { + const sectionData = { + judgement: sectionYMap.get('judgement') ?? null, + answers: {}, + }; + const direction = sectionYMap.get('direction'); + if (direction !== undefined) { + sectionData.direction = direction; + } + + // Reconstruct nested answers + const answersNestedYMap = sectionYMap.get('answers'); + if (answersNestedYMap instanceof Y.Map) { + for (const [qKey, questionYMap] of answersNestedYMap.entries()) { + if (questionYMap instanceof Y.Map) { + const commentValue = questionYMap.get('comment'); + sectionData.answers[qKey] = { + answer: questionYMap.get('answer') ?? null, + comment: yTextToString(commentValue), + }; + } else { + sectionData.answers[qKey] = questionYMap; + } + } + } + answers[key] = sectionData; + } else if (key === 'overall') { + // Overall section has judgement and direction but no nested answers + const sectionData = { + judgement: sectionYMap.get('judgement') ?? null, + }; + const direction = sectionYMap.get('direction'); + if (direction !== undefined) { + sectionData.direction = direction; + } + answers[key] = sectionData; + } else if (key === 'preliminary') { + // Preliminary section: convert Y.Text fields to strings + const sectionData = { + studyDesign: sectionYMap.get('studyDesign') ?? null, + experimental: yTextToString(sectionYMap.get('experimental')), + comparator: yTextToString(sectionYMap.get('comparator')), + numericalResult: yTextToString(sectionYMap.get('numericalResult')), + aim: sectionYMap.get('aim') ?? null, + deviationsToAddress: sectionYMap.get('deviationsToAddress') ?? [], + sources: sectionYMap.get('sources') ?? {}, + }; + answers[key] = sectionData; + } else { + // Other sections: convert Y.Map to plain object + const sectionData = {}; + for (const [fieldKey, fieldValue] of sectionYMap.entries()) { + if (fieldValue instanceof Y.Text) { + sectionData[fieldKey] = fieldValue.toString(); + } else { + sectionData[fieldKey] = fieldValue; + } + } + answers[key] = sectionData; + } + } + return answers; + } + + /** + * Set a Y.Text field value, preserving the Y.Text object if it exists + * @param {Y.Map} map - The Y.Map containing the field + * @param {string} fieldKey - The field key + * @param {string|null} value - The string value to set + */ + setYTextField(map, fieldKey, value) { + const str = value ?? ''; + const existing = map.get(fieldKey); + if (existing instanceof Y.Text) { + existing.delete(0, existing.length); + existing.insert(0, str); + } else { + const newText = new Y.Text(); + newText.insert(0, str); + map.set(fieldKey, newText); + } + } + + /** + * Update a single answer/section in ROB-2 checklist + * @param {Y.Map} answersMap - The answers Y.Map + * @param {string} key - The section key (e.g., 'domain1', 'preliminary') + * @param {Object} data - The answer data + */ + updateAnswer(answersMap, key, data) { + let sectionYMap = answersMap.get(key); + + // Create section Y.Map if it doesn't exist + if (!sectionYMap || !(sectionYMap instanceof Y.Map)) { + sectionYMap = new Y.Map(); + answersMap.set(key, sectionYMap); + } + + if (key.startsWith('domain') || key === 'overall') { + // Update judgement and direction at section level + if (data.judgement !== undefined) { + sectionYMap.set('judgement', data.judgement); + } + if (data.direction !== undefined) { + sectionYMap.set('direction', data.direction); + } + + // Update individual questions in answers + if (data.answers) { + let answersNestedYMap = sectionYMap.get('answers'); + if (!answersNestedYMap || !(answersNestedYMap instanceof Y.Map)) { + answersNestedYMap = new Y.Map(); + sectionYMap.set('answers', answersNestedYMap); + } + + Object.entries(data.answers).forEach(([qKey, qValue]) => { + let questionYMap = answersNestedYMap.get(qKey); + if (!questionYMap || !(questionYMap instanceof Y.Map)) { + questionYMap = new Y.Map(); + answersNestedYMap.set(qKey, questionYMap); + } + if (qValue.answer !== undefined) questionYMap.set('answer', qValue.answer); + if (qValue.comment !== undefined) + this.setYTextField(questionYMap, 'comment', qValue.comment); + }); + } + } else if (key === 'preliminary') { + // Preliminary section: update various fields + if (data.studyDesign !== undefined) sectionYMap.set('studyDesign', data.studyDesign); + if (data.aim !== undefined) sectionYMap.set('aim', data.aim); + if (data.deviationsToAddress !== undefined) + sectionYMap.set('deviationsToAddress', data.deviationsToAddress); + if (data.sources !== undefined) sectionYMap.set('sources', data.sources); + // Free text fields + if (data.experimental !== undefined) + this.setYTextField(sectionYMap, 'experimental', data.experimental); + if (data.comparator !== undefined) + this.setYTextField(sectionYMap, 'comparator', data.comparator); + if (data.numericalResult !== undefined) + this.setYTextField(sectionYMap, 'numericalResult', data.numericalResult); + } else { + // Other sections: update individual fields + Object.entries(data).forEach(([fieldKey, fieldValue]) => { + sectionYMap.set(fieldKey, fieldValue); + }); + } + } + + /** + * Get type-specific text getter function for ROB-2 + * @param {Function} getYDoc - Function that returns the Y.Doc + * @returns {Function} getRob2Text function + */ + getTextGetter(getYDoc) { + return (studyId, checklistId, sectionKey, fieldKey, questionKey = null) => { + const ydoc = getYDoc(); + if (!ydoc) return null; + + const studiesMap = ydoc.getMap('reviews'); + const studyYMap = studiesMap.get(studyId); + if (!studyYMap) return null; + + const checklistsMap = studyYMap.get('checklists'); + if (!checklistsMap) return null; + + const checklistYMap = checklistsMap.get(checklistId); + if (!checklistYMap) return null; + + const checklistType = checklistYMap.get('type'); + if (checklistType !== 'ROB2') return null; + + const answersMap = checklistYMap.get('answers'); + if (!answersMap) return null; + + const sectionYMap = answersMap.get(sectionKey); + if (!sectionYMap || !(sectionYMap instanceof Y.Map)) return null; + + // Handle domain questions + if (sectionKey.startsWith('domain') && questionKey) { + const answersNestedYMap = sectionYMap.get('answers'); + if (!answersNestedYMap || !(answersNestedYMap instanceof Y.Map)) return null; + + const questionYMap = answersNestedYMap.get(questionKey); + if (!questionYMap || !(questionYMap instanceof Y.Map)) return null; + + const text = questionYMap.get(fieldKey); + if (text instanceof Y.Text) { + return text; + } + + // Create Y.Text if it doesn't exist + const newText = new Y.Text(); + questionYMap.set(fieldKey, newText); + return newText; + } + + // Handle section-level fields (preliminary, overall) + const text = sectionYMap.get(fieldKey); + if (text instanceof Y.Text) { + return text; + } + + // Create Y.Text if it doesn't exist + const newText = new Y.Text(); + sectionYMap.set(fieldKey, newText); + return newText; + }; + } +} diff --git a/packages/web/src/primitives/useProject/checklists/index.js b/packages/web/src/primitives/useProject/checklists/index.js index 832e89329..9e5f17e3a 100644 --- a/packages/web/src/primitives/useProject/checklists/index.js +++ b/packages/web/src/primitives/useProject/checklists/index.js @@ -9,6 +9,7 @@ import { CHECKLIST_STATUS } from '@/constants/checklist-status.js'; import { createCommonOperations } from './common.js'; import { AMSTAR2Handler } from './handlers/amstar2.js'; import { ROBINSIHandler } from './handlers/robins-i.js'; +import { ROB2Handler } from './handlers/rob2.js'; /** * Creates checklist operations @@ -24,11 +25,13 @@ export function createChecklistOperations(_projectId, getYDoc, _isSynced) { // Initialize type-specific handlers const amstar2Handler = new AMSTAR2Handler(); const robinsIHandler = new ROBINSIHandler(); + const rob2Handler = new ROB2Handler(); // Handler registry const handlers = { [CHECKLIST_TYPES.AMSTAR2]: amstar2Handler, [CHECKLIST_TYPES.ROBINS_I]: robinsIHandler, + [CHECKLIST_TYPES.ROB2]: rob2Handler, }; /** @@ -237,6 +240,21 @@ export function createChecklistOperations(_projectId, getYDoc, _isSynced) { return textGetter(studyId, checklistId, sectionKey, fieldKey, questionKey); } + /** + * Get a Y.Text reference for a ROB-2 free-text field + * @param {string} studyId - The study ID + * @param {string} checklistId - The checklist ID + * @param {string} sectionKey - The section key + * @param {string} fieldKey - The field key + * @param {string} [questionKey] - Optional question key + * @returns {Y.Text|null} The Y.Text reference or null + */ + function getRob2Text(studyId, checklistId, sectionKey, fieldKey, questionKey = null) { + const textGetter = rob2Handler.getTextGetter(getYDoc); + if (!textGetter) return null; + return textGetter(studyId, checklistId, sectionKey, fieldKey, questionKey); + } + return { createChecklist, updateChecklist: commonOps.updateChecklist, @@ -246,5 +264,6 @@ export function createChecklistOperations(_projectId, getYDoc, _isSynced) { updateChecklistAnswer, getQuestionNote, getRobinsText, + getRob2Text, }; } diff --git a/packages/web/src/primitives/useProject/index.js b/packages/web/src/primitives/useProject/index.js index d9a763405..4a9ccb776 100644 --- a/packages/web/src/primitives/useProject/index.js +++ b/packages/web/src/primitives/useProject/index.js @@ -199,6 +199,7 @@ export function useProject(projectId) { updateChecklistAnswer: connectionEntry.checklistOps.updateChecklistAnswer, getQuestionNote: connectionEntry.checklistOps.getQuestionNote, getRobinsText: connectionEntry.checklistOps.getRobinsText, + getRob2Text: connectionEntry.checklistOps.getRob2Text, // PDF operations addPdfToStudy: connectionEntry.pdfOps.addPdfToStudy, removePdfFromStudy: connectionEntry.pdfOps.removePdfFromStudy, @@ -345,6 +346,7 @@ export function useProject(projectId) { connectionEntry?.checklistOps?.updateChecklistAnswer(...args), getQuestionNote: (...args) => connectionEntry?.checklistOps?.getQuestionNote(...args), getRobinsText: (...args) => connectionEntry?.checklistOps?.getRobinsText(...args), + getRob2Text: (...args) => connectionEntry?.checklistOps?.getRob2Text(...args), // PDF operations addPdfToStudy: (...args) => connectionEntry?.pdfOps?.addPdfToStudy(...args), From 7e79e76a444591eb1d259d4156cf23faf3de71aa Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sun, 11 Jan 2026 07:17:08 +0000 Subject: [PATCH 3/8] Apply Prettier formatting --- .../audits/rob2-checklist-implementation.md | 46 ++++++++++------- packages/shared/src/checklists/rob2/create.ts | 7 ++- packages/shared/src/checklists/rob2/schema.ts | 10 ++-- .../ROB2Checklist/DomainJudgement.jsx | 6 ++- .../ROB2Checklist/OverallSection.jsx | 6 +-- .../ROB2Checklist/PreliminarySection.jsx | 51 +++++++++---------- .../checklist/ROB2Checklist/ROB2Checklist.jsx | 4 +- 7 files changed, 71 insertions(+), 59 deletions(-) diff --git a/packages/docs/audits/rob2-checklist-implementation.md b/packages/docs/audits/rob2-checklist-implementation.md index 4f199af69..59ca1eaea 100644 --- a/packages/docs/audits/rob2-checklist-implementation.md +++ b/packages/docs/audits/rob2-checklist-implementation.md @@ -16,15 +16,16 @@ ROB-2 is the Cochrane Collaboration's tool for assessing risk of bias in randomi Created the core ROB-2 logic in `packages/shared/src/checklists/rob2/`: -| File | Purpose | -|------|---------| -| `schema.ts` | Question definitions, domain structures, response types, constants | -| `scoring.ts` | Decision algorithms for each domain (from official ROB-2 decision diagrams) | -| `create.ts` | Factory function `createROB2Checklist()` | +| File | Purpose | +| ------------ | ----------------------------------------------------------------------------------- | +| `schema.ts` | Question definitions, domain structures, response types, constants | +| `scoring.ts` | Decision algorithms for each domain (from official ROB-2 decision diagrams) | +| `create.ts` | Factory function `createROB2Checklist()` | | `answers.ts` | Utilities: `scoreROB2Checklist`, `isROB2Complete`, `getAnswers`, `getDomainSummary` | -| `index.ts` | Module exports | +| `index.ts` | Module exports | **Key schema elements:** + - 5 domains with signalling questions - Domain 2 has two variants: 2a (effect of assignment/ITT) and 2b (effect of adhering/per-protocol) - Response types: Y (Yes), PY (Probably Yes), PN (Probably No), N (No), NI (No Information), NA (Not Applicable) @@ -35,28 +36,30 @@ Created the core ROB-2 logic in `packages/shared/src/checklists/rob2/`: Created components in `packages/web/src/components/checklist/ROB2Checklist/`: -| Component | Purpose | -|-----------|---------| -| `ROB2Checklist.jsx` | Main orchestrating component | -| `PreliminarySection.jsx` | Study design, aims, interventions, sources | -| `DomainSection.jsx` | Individual domain with questions and auto-scoring | -| `SignallingQuestion.jsx` | Response buttons for each question | -| `DomainJudgement.jsx` | Judgement display badges | -| `ScoringSummary.jsx` | Compact summary strip with domain chips | -| `OverallSection.jsx` | Final overall risk of bias section | -| `checklist.js` | Helper functions and re-exports | -| `checklist-map.js` | Schema re-exports from shared package | -| `index.js` | Module entry point | +| Component | Purpose | +| ------------------------ | ------------------------------------------------- | +| `ROB2Checklist.jsx` | Main orchestrating component | +| `PreliminarySection.jsx` | Study design, aims, interventions, sources | +| `DomainSection.jsx` | Individual domain with questions and auto-scoring | +| `SignallingQuestion.jsx` | Response buttons for each question | +| `DomainJudgement.jsx` | Judgement display badges | +| `ScoringSummary.jsx` | Compact summary strip with domain chips | +| `OverallSection.jsx` | Final overall risk of bias section | +| `checklist.js` | Helper functions and re-exports | +| `checklist-map.js` | Schema re-exports from shared package | +| `index.js` | Module entry point | ### Yjs Integration Created `packages/web/src/primitives/useProject/checklists/handlers/rob2.js`: + - `ROB2Handler` class for real-time collaborative editing - Methods: `extractAnswersFromTemplate`, `createAnswersYMap`, `serializeAnswers`, `updateAnswer`, `getTextGetter` ### Registry Integration Modified files to register ROB-2: + - `packages/web/src/checklist-registry/types.js` - Added ROB2 type constant and metadata - `packages/web/src/checklist-registry/index.js` - Registered scoring and creation functions - `packages/web/src/primitives/useProject/checklists/index.js` - Added handler and `getRob2Text()` @@ -66,18 +69,24 @@ Modified files to register ROB-2: ## Key Features ### Auto-Scoring + Domain judgements are automatically calculated from signalling question responses using the official ROB-2 decision algorithms. The overall risk of bias is then derived from all domain judgements: + - If any domain is "High" -> Overall is "High" - If any domain is "Some concerns" (and none High) -> Overall is "Some concerns" - If all domains are "Low" -> Overall is "Low" ### Domain 2 Variants + The preliminary section includes an "aim" selection that determines which Domain 2 variant to show: + - **Assignment (ITT)**: Shows Domain 2a - Effect of assignment to intervention - **Adhering (per-protocol)**: Shows Domain 2b - Effect of adhering to intervention ### Collaborative Editing + Full Yjs integration enables real-time collaboration: + - All text fields (experimental intervention, comparator, numerical result) are Y.Text - Signalling question responses sync across users - Domain judgements update automatically as questions are answered @@ -161,6 +170,7 @@ Run tests with: `pnpm --filter @corates/shared test` ## Decision Diagram Sources The scoring algorithms were implemented from the decision diagrams in: + - `packages/web/src/components/checklist/ROB2Checklist/scoring/decision-diagrams/` These files contain the official ROB-2 decision algorithms that determine domain judgements based on signalling question responses. diff --git a/packages/shared/src/checklists/rob2/create.ts b/packages/shared/src/checklists/rob2/create.ts index 92a57ba8f..165d22f14 100644 --- a/packages/shared/src/checklists/rob2/create.ts +++ b/packages/shared/src/checklists/rob2/create.ts @@ -4,7 +4,12 @@ * Creates new ROB-2 checklist objects with proper structure and defaults. */ -import { INFORMATION_SOURCES, getDomainQuestions, ROB2_CHECKLIST, type DomainKey } from './schema.js'; +import { + INFORMATION_SOURCES, + getDomainQuestions, + ROB2_CHECKLIST, + type DomainKey, +} from './schema.js'; export interface ROB2Checklist { id: string; diff --git a/packages/shared/src/checklists/rob2/schema.ts b/packages/shared/src/checklists/rob2/schema.ts index 76253e116..3590045c6 100644 --- a/packages/shared/src/checklists/rob2/schema.ts +++ b/packages/shared/src/checklists/rob2/schema.ts @@ -180,7 +180,7 @@ export const DOMAIN_2A: ROB2Domain = { d2a_2: { id: 'd2a_2', number: '2.2', - text: 'Were carers and people delivering the interventions aware of participants\' assigned intervention during the trial?', + text: "Were carers and people delivering the interventions aware of participants' assigned intervention during the trial?", responseType: 'STANDARD', }, d2a_3: { @@ -234,7 +234,7 @@ export const DOMAIN_2B: ROB2Domain = { d2b_2: { id: 'd2b_2', number: '2.2', - text: 'Were carers and people delivering the interventions aware of participants\' assigned intervention during the trial?', + text: "Were carers and people delivering the interventions aware of participants' assigned intervention during the trial?", responseType: 'STANDARD', }, d2b_3: { @@ -252,7 +252,7 @@ export const DOMAIN_2B: ROB2Domain = { d2b_5: { id: 'd2b_5', number: '2.5', - text: '[If applicable:] Was there non-adherence to the assigned intervention regimen that could have affected participants\' outcomes?', + text: "[If applicable:] Was there non-adherence to the assigned intervention regimen that could have affected participants' outcomes?", responseType: 'WITH_NA', }, d2b_6: { @@ -385,8 +385,8 @@ export function getDomainKeys(): DomainKey[] { // Get active domain keys based on aim selection export function getActiveDomainKeys(isAdhering: boolean): DomainKey[] { - return isAdhering - ? ['domain1', 'domain2b', 'domain3', 'domain4', 'domain5'] + return isAdhering ? + ['domain1', 'domain2b', 'domain3', 'domain4', 'domain5'] : ['domain1', 'domain2a', 'domain3', 'domain4', 'domain5']; } diff --git a/packages/web/src/components/checklist/ROB2Checklist/DomainJudgement.jsx b/packages/web/src/components/checklist/ROB2Checklist/DomainJudgement.jsx index d710904a7..ae8855e3a 100644 --- a/packages/web/src/components/checklist/ROB2Checklist/DomainJudgement.jsx +++ b/packages/web/src/components/checklist/ROB2Checklist/DomainJudgement.jsx @@ -19,7 +19,9 @@ export function JudgementBadge(props) { }; return ( - + {props.judgement} ); @@ -43,7 +45,7 @@ export function DomainJudgement(props) { const getJudgementColor = (judgement, isSelected) => { if (!isSelected) { return props.isAutoMode ? - 'border-gray-200 bg-gray-50 text-gray-400 cursor-not-allowed' + 'border-gray-200 bg-gray-50 text-gray-400 cursor-not-allowed' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 cursor-pointer'; } diff --git a/packages/web/src/components/checklist/ROB2Checklist/OverallSection.jsx b/packages/web/src/components/checklist/ROB2Checklist/OverallSection.jsx index c8c7e5449..e863f3663 100644 --- a/packages/web/src/components/checklist/ROB2Checklist/OverallSection.jsx +++ b/packages/web/src/components/checklist/ROB2Checklist/OverallSection.jsx @@ -69,11 +69,7 @@ export function OverallSection(props) { } }; - const judgementOptions = [ - 'Low risk of bias', - 'Some concerns', - 'High risk of bias', - ]; + const judgementOptions = ['Low risk of bias', 'Some concerns', 'High risk of bias']; return (
diff --git a/packages/web/src/components/checklist/ROB2Checklist/PreliminarySection.jsx b/packages/web/src/components/checklist/ROB2Checklist/PreliminarySection.jsx index eae4371aa..206b2da09 100644 --- a/packages/web/src/components/checklist/ROB2Checklist/PreliminarySection.jsx +++ b/packages/web/src/components/checklist/ROB2Checklist/PreliminarySection.jsx @@ -34,9 +34,8 @@ export function PreliminarySection(props) { function handleDeviationToggle(deviation) { const current = props.preliminaryState?.deviationsToAddress || []; - const updated = current.includes(deviation) - ? current.filter(d => d !== deviation) - : [...current, deviation]; + const updated = + current.includes(deviation) ? current.filter(d => d !== deviation) : [...current, deviation]; props.onUpdate({ ...props.preliminaryState, deviationsToAddress: updated, @@ -83,9 +82,9 @@ export function PreliminarySection(props) { class={`rounded-lg border-2 px-3 py-2 text-sm transition-colors ${ props.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer' } ${ - props.preliminaryState?.studyDesign === design - ? 'border-blue-400 bg-blue-50 text-blue-800' - : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300' + props.preliminaryState?.studyDesign === design ? + 'border-blue-400 bg-blue-50 text-blue-800' + : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300' }`} > {design} @@ -147,17 +146,17 @@ export function PreliminarySection(props) { class={`flex w-full items-start rounded-lg border-2 p-3 text-left text-sm transition-colors ${ props.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer' } ${ - props.preliminaryState?.aim === 'ASSIGNMENT' - ? 'border-blue-400 bg-blue-50' - : 'border-gray-200 bg-white hover:border-gray-300' + props.preliminaryState?.aim === 'ASSIGNMENT' ? + 'border-blue-400 bg-blue-50' + : 'border-gray-200 bg-white hover:border-gray-300' }`} > -
+
@@ -175,17 +174,17 @@ export function PreliminarySection(props) { class={`flex w-full items-start rounded-lg border-2 p-3 text-left text-sm transition-colors ${ props.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer' } ${ - props.preliminaryState?.aim === 'ADHERING' - ? 'border-blue-400 bg-blue-50' - : 'border-gray-200 bg-white hover:border-gray-300' + props.preliminaryState?.aim === 'ADHERING' ? + 'border-blue-400 bg-blue-50' + : 'border-gray-200 bg-white hover:border-gray-300' }`} > -
+
@@ -218,9 +217,9 @@ export function PreliminarySection(props) { class={`flex w-full items-center rounded-lg border p-3 text-left text-sm transition-colors ${ props.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer' } ${ - isChecked() - ? 'border-blue-300 bg-blue-50' - : 'border-gray-200 bg-white hover:border-gray-300' + isChecked() ? + 'border-blue-300 bg-blue-50' + : 'border-gray-200 bg-white hover:border-gray-300' }`} >
Select Assessment Aim

- Please select the review team's aim in the Preliminary Considerations section above - to proceed with the domain assessment. + Please select the review team's aim in the Preliminary Considerations section above to + proceed with the domain assessment.

From a042ceb64a874a67c6b2d84712cee57938cfb9a4 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sun, 11 Jan 2026 01:39:04 -0600 Subject: [PATCH 4/8] better error boundary handling --- packages/web/src/components/ErrorBoundary.jsx | 41 +++- .../web/src/components/admin/AdminLayout.jsx | 7 +- .../src/components/dashboard/Dashboard.jsx | 15 +- .../src/components/project/ProjectView.jsx | 21 +- .../components/settings/SettingsLayout.jsx | 7 +- packages/web/src/lib/errorLogger.js | 209 ++++++++++++++++++ packages/web/src/lib/formStatePersistence.js | 7 +- packages/web/src/main.jsx | 5 +- .../web/src/primitives/useAdminQueries.js | 87 +++----- 9 files changed, 315 insertions(+), 84 deletions(-) create mode 100644 packages/web/src/lib/errorLogger.js diff --git a/packages/web/src/components/ErrorBoundary.jsx b/packages/web/src/components/ErrorBoundary.jsx index 5586f2789..a5564c103 100644 --- a/packages/web/src/components/ErrorBoundary.jsx +++ b/packages/web/src/components/ErrorBoundary.jsx @@ -7,6 +7,7 @@ import { ErrorBoundary as SolidErrorBoundary } from 'solid-js'; import { normalizeError } from '@corates/shared'; import { FiAlertTriangle, FiRefreshCw, FiHome } from 'solid-icons/fi'; +import { logError } from '@lib/errorLogger.js'; /** * Safe navigation button that works both inside and outside Route context @@ -109,12 +110,11 @@ export default function AppErrorBoundary(props) { // Normalize the error using our error system const normalizedError = normalizeError(error); - // Log unknown/programmer errors for monitoring - if (normalizedError.code?.startsWith('UNKNOWN_')) { - console.error('Error Boundary caught unknown error:', normalizedError); - // TODO: Send to error monitoring service (e.g., Sentry, LogRocket) - // logErrorToService(normalizedError); - } + // Log errors through centralized error logger (Sentry-ready) + logError(normalizedError, { + component: props.name || 'AppErrorBoundary', + action: 'render', + }); // Call custom error handler if provided if (props.onError) { @@ -133,25 +133,44 @@ export default function AppErrorBoundary(props) { } /** - * Simple error boundary wrapper for specific sections - * Use this for smaller scoped error boundaries + * Section error boundary for specific page sections + * Use this for component-level error isolation + * + * @param {Object} props - Component props + * @param {JSX.Element} props.children - Child components to wrap + * @param {string} [props.name] - Section name for logging context + * @param {Function} [props.onError] - Callback when error is caught + * @param {Function} [props.onRetry] - Custom retry handler (e.g., query refetch) + * @param {string} [props.retryLabel] - Custom label for retry button */ export function SectionErrorBoundary(props) { + const handleRetry = reset => { + // Call custom retry handler if provided (e.g., invalidate queries) + if (props.onRetry) { + props.onRetry(); + } + // Always call reset to clear error boundary state + reset(); + }; + return ( (
-

Error

+

+ {props.name ? `Error in ${props.name}` : 'Error'} +

{error.message || 'Something went wrong'}

{reset && ( )}
diff --git a/packages/web/src/components/admin/AdminLayout.jsx b/packages/web/src/components/admin/AdminLayout.jsx index 02aed5ce7..16aebc15d 100644 --- a/packages/web/src/components/admin/AdminLayout.jsx +++ b/packages/web/src/components/admin/AdminLayout.jsx @@ -20,6 +20,7 @@ import { import { A } from '@solidjs/router'; import { isAdmin, isAdminChecked, checkAdminStatus } from '@/stores/adminStore.js'; import { DashboardBody } from './ui/index.js'; +import { SectionErrorBoundary } from '@components/ErrorBoundary.jsx'; const navItems = [ { path: '/admin', label: 'Dashboard', icon: FiShield }, @@ -99,7 +100,11 @@ export default function AdminLayout(props) {
{/* Page Content */} - {props.children} + + + {props.children} + +
diff --git a/packages/web/src/components/dashboard/Dashboard.jsx b/packages/web/src/components/dashboard/Dashboard.jsx index 96b8f04fa..579b97068 100644 --- a/packages/web/src/components/dashboard/Dashboard.jsx +++ b/packages/web/src/components/dashboard/Dashboard.jsx @@ -23,6 +23,7 @@ import ActivityFeed from './ActivityFeed.jsx'; import { ProjectsSection } from './ProjectsSection.jsx'; import { LocalAppraisalsSection } from './LocalAppraisalsSection.jsx'; import { useInitialAnimation } from './useInitialAnimation.js'; +import { SectionErrorBoundary } from '@components/ErrorBoundary.jsx'; // Animation context - allows child components to access animation state export const AnimationContext = createContext({ @@ -174,14 +175,18 @@ export function Dashboard() {
{/* Projects - only for logged in users */} - + + + {/* Local appraisals - always shown */} - + + +
{/* Right sidebar */} diff --git a/packages/web/src/components/project/ProjectView.jsx b/packages/web/src/components/project/ProjectView.jsx index 8caaffa4d..4d95a8242 100644 --- a/packages/web/src/components/project/ProjectView.jsx +++ b/packages/web/src/components/project/ProjectView.jsx @@ -33,6 +33,7 @@ import { AllStudiesTab } from './all-studies-tab/index.js'; import { ToDoTab } from './todo-tab/index.js'; import { ReconcileTab } from './reconcile-tab/index.js'; import { CompletedTab } from './completed-tab/index.js'; +import { SectionErrorBoundary } from '@components/ErrorBoundary.jsx'; export default function ProjectView(props) { const params = useParams(); @@ -299,23 +300,33 @@ export default function ProjectView(props) { {tabValue => ( <> - + + + - + + + - + + + - + + + - + + + )} diff --git a/packages/web/src/components/settings/SettingsLayout.jsx b/packages/web/src/components/settings/SettingsLayout.jsx index ec4378c13..9e72a3b5b 100644 --- a/packages/web/src/components/settings/SettingsLayout.jsx +++ b/packages/web/src/components/settings/SettingsLayout.jsx @@ -1,5 +1,6 @@ import { createSignal } from 'solid-js'; import SettingsSidebar from './SettingsSidebar.jsx'; +import { SectionErrorBoundary } from '@components/ErrorBoundary.jsx'; // Share the same localStorage keys as main sidebar so state is unified const SIDEBAR_MODE_KEY = 'corates-sidebar-mode'; @@ -79,7 +80,11 @@ export default function SettingsLayout(props) { width={sidebarWidth()} onWidthChange={handleWidthChange} /> -
{props.children}
+
+ + {props.children} + +
); } diff --git a/packages/web/src/lib/errorLogger.js b/packages/web/src/lib/errorLogger.js new file mode 100644 index 000000000..9a33bff5f --- /dev/null +++ b/packages/web/src/lib/errorLogger.js @@ -0,0 +1,209 @@ +/** + * Error Logger - Centralized error logging for monitoring integration + * + * Provides a single point of integration for error monitoring services (Sentry, LogRocket, etc.) + * All error logging should go through this module to ensure consistent handling and easy + * integration with monitoring services in the future. + * + * Usage: + * import { logError, logWarning, bestEffort } from '@lib/errorLogger.js'; + * + * // Log an error with context + * logError(error, { component: 'ProjectView', action: 'loadProject' }); + * + * // Log a warning for non-fatal issues + * logWarning('Cache miss for user avatar', { userId: '123' }); + * + * // Wrap best-effort operations that can fail silently + * bestEffort(clearFormState(type), { operation: 'clearFormState' }); + */ + +import { normalizeError } from '@corates/shared'; + +/** + * Log levels for categorizing messages + */ +const LogLevel = { + ERROR: 'error', + WARNING: 'warning', + INFO: 'info', +}; + +/** + * Format error data for logging + * Normalizes different error formats into a consistent structure + */ +function formatErrorData(error) { + const normalized = normalizeError(error); + return { + code: normalized.code || 'UNKNOWN', + message: normalized.message || String(error), + statusCode: normalized.statusCode, + details: normalized.details, + stack: error?.stack, + }; +} + +/** + * Core logging function + * Handles both console output and future monitoring service integration + */ +function log(level, message, context = {}) { + const timestamp = new Date().toISOString(); + const logData = { + level, + message, + timestamp, + ...context, + }; + + // Console output with appropriate method + switch (level) { + case LogLevel.ERROR: + console.error(`[Error] ${message}`, logData); + break; + case LogLevel.WARNING: + console.warn(`[Warning] ${message}`, logData); + break; + default: + console.info(`[Info] ${message}`, logData); + } + + // Future Sentry integration point + // When Sentry is configured, add integration here: + // + // if (typeof window !== 'undefined' && window.Sentry) { + // if (level === LogLevel.ERROR && context.error) { + // window.Sentry.captureException(context.error, { + // tags: { + // component: context.component, + // action: context.action, + // }, + // extra: context, + // }); + // } else { + // window.Sentry.captureMessage(message, { + // level: level === LogLevel.ERROR ? 'error' : level, + // tags: { + // component: context.component, + // action: context.action, + // }, + // extra: context, + // }); + // } + // } +} + +/** + * Log an error with context + * Use this for caught exceptions and error boundary errors + * + * @param {Error|DomainError|TransportError|unknown} error - The error to log + * @param {Object} context - Additional context for debugging + * @param {string} [context.component] - Component where error occurred + * @param {string} [context.action] - Action that triggered the error + * @param {Object} [context.metadata] - Additional metadata + */ +export function logError(error, context = {}) { + const errorData = formatErrorData(error); + const message = context.action + ? `${context.action}: ${errorData.message}` + : errorData.message; + + log(LogLevel.ERROR, message, { + ...context, + error: errorData, + }); +} + +/** + * Log a warning for non-fatal issues + * Use this for degraded functionality, cache misses, etc. + * + * @param {string} message - Warning message + * @param {Object} context - Additional context for debugging + */ +export function logWarning(message, context = {}) { + log(LogLevel.WARNING, message, context); +} + +/** + * Log informational messages + * Use sparingly - mainly for important state transitions + * + * @param {string} message - Info message + * @param {Object} context - Additional context + */ +export function logInfo(message, context = {}) { + log(LogLevel.INFO, message, context); +} + +/** + * Wrap a best-effort operation that can fail silently + * Logs warnings on failure but doesn't throw + * + * Use for cleanup operations, cache updates, and other non-critical tasks + * where failure shouldn't break the user experience. + * + * @param {Promise} promise - The operation to run + * @param {Object} context - Context for logging if operation fails + * @returns {Promise} Resolves to the result or undefined on failure + * + * @example + * // Instead of: clearFormState(type).catch(() => {}); + * bestEffort(clearFormState(type), { operation: 'clearFormState', type }); + */ +export function bestEffort(promise, context = {}) { + return promise.catch(error => { + logWarning(`Best-effort operation failed: ${context.operation || 'unknown'}`, { + ...context, + error: formatErrorData(error), + }); + return undefined; + }); +} + +/** + * Create a logging wrapper for async functions + * Catches errors, logs them, and rethrows + * + * @param {string} component - Component name for context + * @param {string} action - Action name for context + * @returns {Function} Wrapper function that logs and rethrows errors + * + * @example + * async function loadProject(id) { + * return withErrorLogging('ProjectView', 'loadProject')(async () => { + * const data = await apiFetch(`/api/projects/${id}`); + * return data; + * }); + * } + */ +export function withErrorLogging(component, action) { + return async fn => { + try { + return await fn(); + } catch (error) { + logError(error, { component, action }); + throw error; + } + }; +} + +/** + * Log error and rethrow - for catch blocks that need to log but propagate + * + * @param {string} component - Component name for context + * @param {string} action - Action name for context + * @param {Object} metadata - Additional metadata + * @returns {Function} Error handler that logs and rethrows + * + * @example + * fetchData().catch(logAndRethrow('ProjectView', 'fetchData')); + */ +export function logAndRethrow(component, action, metadata = {}) { + return error => { + logError(error, { component, action, ...metadata }); + throw error; + }; +} diff --git a/packages/web/src/lib/formStatePersistence.js b/packages/web/src/lib/formStatePersistence.js index c0093279f..7c59939e8 100644 --- a/packages/web/src/lib/formStatePersistence.js +++ b/packages/web/src/lib/formStatePersistence.js @@ -5,6 +5,7 @@ */ import { db } from '@primitives/db.js'; +import { bestEffort } from '@lib/errorLogger.js'; const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -50,7 +51,11 @@ export async function getFormState(type, projectId) { if (!record) return null; if (Date.now() - record.timestamp > MAX_AGE_MS) { - clearFormState(type, projectId).catch(() => {}); + bestEffort(clearFormState(type, projectId), { + operation: 'clearExpiredFormState', + type, + projectId, + }); return null; } diff --git a/packages/web/src/main.jsx b/packages/web/src/main.jsx index 5b5e54f0b..6a1f9a237 100644 --- a/packages/web/src/main.jsx +++ b/packages/web/src/main.jsx @@ -6,11 +6,10 @@ import { initBfcacheHandler } from '@lib/bfcache-handler.js'; import AppErrorBoundary from './components/ErrorBoundary.jsx'; import { QueryClientProvider } from '@tanstack/solid-query'; import { queryClient } from '@lib/queryClient.js'; +import { bestEffort } from '@lib/errorLogger.js'; // Clean up any expired form state entries from IndexedDB on app load -cleanupExpiredStates().catch(() => { - // Silent fail - cleanup is best-effort -}); +bestEffort(cleanupExpiredStates(), { operation: 'cleanupExpiredStates' }); // Initialize bfcache restoration handler // This detects when Safari (and other browsers) restore pages from bfcache diff --git a/packages/web/src/primitives/useAdminQueries.js b/packages/web/src/primitives/useAdminQueries.js index 6d3be3da3..726553764 100644 --- a/packages/web/src/primitives/useAdminQueries.js +++ b/packages/web/src/primitives/useAdminQueries.js @@ -4,8 +4,8 @@ */ import { useQuery } from '@tanstack/solid-query'; -import { API_BASE } from '@config/api.js'; import { queryKeys } from '@lib/queryKeys.js'; +import { apiFetch } from '@lib/apiFetch.js'; import { fetchOrgs, fetchOrgDetails, @@ -17,21 +17,24 @@ import { /** * Helper for admin fetch calls - * Uses cache: 'no-store' to prevent browser HTTP caching from serving stale data + * Uses apiFetch for proper error handling and normalization */ -async function adminFetch(path, options = {}) { - const response = await fetch(`${API_BASE}/api/admin/${path}`, { - credentials: 'include', - cache: 'no-store', - ...options, +async function adminFetch(path) { + return apiFetch(`/api/admin/${path}`, { + showToast: false, // Admin panel handles its own error display via TanStack Query }); - if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new Error(error.error || `Failed to fetch ${path}`); - } - return response.json(); } +/** + * Default cache config for admin queries + * Admin data should always be fresh - no stale data shown + */ +const ADMIN_QUERY_CONFIG = { + staleTime: 0, + gcTime: 1000 * 60 * 5, + refetchOnMount: 'always', +}; + /** * Hook to fetch admin dashboard stats */ @@ -39,9 +42,7 @@ export function useAdminStats() { return useQuery(() => ({ queryKey: queryKeys.admin.stats, queryFn: () => adminFetch('stats'), - staleTime: 0, // Always consider data stale to force refetch - gcTime: 1000 * 60 * 5, // 5 minutes - refetchOnMount: 'always', // Always refetch on mount, even if data exists + ...ADMIN_QUERY_CONFIG, })); } @@ -65,9 +66,7 @@ export function useAdminUsers(getParams) { if (search) searchParams.set('search', search); return adminFetch(`users?${searchParams.toString()}`); }, - staleTime: 0, // Always consider data stale to force refetch - gcTime: 1000 * 60 * 5, // 5 minutes - refetchOnMount: 'always', // Always refetch on mount, even if data exists + ...ADMIN_QUERY_CONFIG }; }); } @@ -82,9 +81,7 @@ export function useAdminUserDetails(getUserId) { queryKey: queryKeys.admin.userDetails(userId), queryFn: () => adminFetch(`users/${userId}`), enabled: !!userId, - staleTime: 0, // Always consider data stale to force refetch - gcTime: 1000 * 60 * 5, // 5 minutes - refetchOnMount: 'always', // Always refetch on mount, even if data exists + ...ADMIN_QUERY_CONFIG }; }); } @@ -111,9 +108,7 @@ export function useAdminProjects(getParams) { if (orgId) searchParams.set('orgId', orgId); return adminFetch(`projects?${searchParams.toString()}`); }, - staleTime: 0, - gcTime: 1000 * 60 * 5, - refetchOnMount: 'always', + ...ADMIN_QUERY_CONFIG, }; }); } @@ -128,9 +123,7 @@ export function useAdminProjectDetails(getProjectId) { queryKey: queryKeys.admin.projectDetails(projectId), queryFn: () => adminFetch(`projects/${projectId}`), enabled: !!projectId, - staleTime: 0, - gcTime: 1000 * 60 * 5, - refetchOnMount: 'always', + ...ADMIN_QUERY_CONFIG, }; }); } @@ -157,9 +150,7 @@ export function useStorageDocuments(getParams) { if (search) searchParams.set('search', search); return adminFetch(`storage/documents?${searchParams.toString()}`); }, - staleTime: 0, // Always consider data stale to force refetch - gcTime: 1000 * 60 * 5, // 5 minutes - refetchOnMount: 'always', // Always refetch on mount, even if data exists + ...ADMIN_QUERY_CONFIG }; }); } @@ -171,9 +162,7 @@ export function useStorageStats() { return useQuery(() => ({ queryKey: queryKeys.admin.storageStats, queryFn: () => adminFetch('storage/stats'), - staleTime: 0, // Always consider data stale to force refetch - gcTime: 1000 * 60 * 5, // 5 minutes - refetchOnMount: 'always', // Always refetch on mount, even if data exists + ...ADMIN_QUERY_CONFIG })); } @@ -190,9 +179,7 @@ export function useAdminOrgs(getParams) { return { queryKey: queryKeys.admin.orgs(page, limit, search), queryFn: () => fetchOrgs({ page, limit, search }), - staleTime: 0, - gcTime: 1000 * 60 * 5, - refetchOnMount: 'always', + ...ADMIN_QUERY_CONFIG, }; }); } @@ -207,9 +194,7 @@ export function useAdminOrgDetails(getOrgId) { queryKey: queryKeys.admin.orgDetails(orgId), queryFn: () => fetchOrgDetails(orgId), enabled: !!orgId, - staleTime: 0, - gcTime: 1000 * 60 * 5, - refetchOnMount: 'always', + ...ADMIN_QUERY_CONFIG, }; }); } @@ -224,9 +209,7 @@ export function useAdminOrgBilling(getOrgId) { queryKey: queryKeys.admin.orgBilling(orgId), queryFn: () => fetchOrgBilling(orgId), enabled: !!orgId, - staleTime: 0, - gcTime: 1000 * 60 * 5, - refetchOnMount: 'always', + ...ADMIN_QUERY_CONFIG, }; }); } @@ -245,9 +228,7 @@ export function useAdminBillingLedger(getParams) { return { queryKey: queryKeys.admin.billingLedger(queryParams), queryFn: () => fetchBillingLedger(queryParams), - staleTime: 0, - gcTime: 1000 * 60 * 5, - refetchOnMount: 'always', + ...ADMIN_QUERY_CONFIG, }; }); } @@ -265,9 +246,7 @@ export function useAdminBillingStuckStates(getParams) { return { queryKey: queryKeys.admin.billingStuckStates(queryParams), queryFn: () => fetchBillingStuckStates(queryParams), - staleTime: 0, - gcTime: 1000 * 60 * 5, - refetchOnMount: 'always', + ...ADMIN_QUERY_CONFIG, }; }); } @@ -288,9 +267,7 @@ export function useAdminOrgBillingReconcile(orgId, getParams) { queryKey: queryKeys.admin.orgBillingReconcile(orgId, queryParams), queryFn: () => fetchOrgBillingReconcile(orgId, queryParams), enabled: !!orgId, - staleTime: 0, - gcTime: 1000 * 60 * 5, - refetchOnMount: 'always', + ...ADMIN_QUERY_CONFIG, }; }); } @@ -302,9 +279,7 @@ export function useAdminDatabaseTables() { return useQuery(() => ({ queryKey: queryKeys.admin.databaseTables, queryFn: () => adminFetch('database/tables'), - staleTime: 0, - gcTime: 1000 * 60 * 5, - refetchOnMount: 'always', + ...ADMIN_QUERY_CONFIG, })); } @@ -359,9 +334,7 @@ export function useAdminTableRows(getParams) { return adminFetch(`database/tables/${tableName}/rows?${searchParams}`); }, enabled: !!tableName, - staleTime: 0, - gcTime: 1000 * 60 * 5, - refetchOnMount: 'always', + ...ADMIN_QUERY_CONFIG, }; }); } From 98d3febaebc2f92ee48737a53a8bd682106b37dd Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sun, 11 Jan 2026 07:46:47 +0000 Subject: [PATCH 5/8] Apply Prettier formatting --- packages/web/src/components/admin/AdminLayout.jsx | 4 +--- packages/web/src/components/settings/SettingsLayout.jsx | 4 +--- packages/web/src/lib/errorLogger.js | 4 +--- packages/web/src/primitives/useAdminQueries.js | 8 ++++---- 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/web/src/components/admin/AdminLayout.jsx b/packages/web/src/components/admin/AdminLayout.jsx index 16aebc15d..7b7e3ba9b 100644 --- a/packages/web/src/components/admin/AdminLayout.jsx +++ b/packages/web/src/components/admin/AdminLayout.jsx @@ -101,9 +101,7 @@ export default function AdminLayout(props) { {/* Page Content */} - - {props.children} - + {props.children}
diff --git a/packages/web/src/components/settings/SettingsLayout.jsx b/packages/web/src/components/settings/SettingsLayout.jsx index 9e72a3b5b..c17e7259e 100644 --- a/packages/web/src/components/settings/SettingsLayout.jsx +++ b/packages/web/src/components/settings/SettingsLayout.jsx @@ -81,9 +81,7 @@ export default function SettingsLayout(props) { onWidthChange={handleWidthChange} />
- - {props.children} - + {props.children}
); diff --git a/packages/web/src/lib/errorLogger.js b/packages/web/src/lib/errorLogger.js index 9a33bff5f..8362c39cb 100644 --- a/packages/web/src/lib/errorLogger.js +++ b/packages/web/src/lib/errorLogger.js @@ -106,9 +106,7 @@ function log(level, message, context = {}) { */ export function logError(error, context = {}) { const errorData = formatErrorData(error); - const message = context.action - ? `${context.action}: ${errorData.message}` - : errorData.message; + const message = context.action ? `${context.action}: ${errorData.message}` : errorData.message; log(LogLevel.ERROR, message, { ...context, diff --git a/packages/web/src/primitives/useAdminQueries.js b/packages/web/src/primitives/useAdminQueries.js index 726553764..adc2e4728 100644 --- a/packages/web/src/primitives/useAdminQueries.js +++ b/packages/web/src/primitives/useAdminQueries.js @@ -66,7 +66,7 @@ export function useAdminUsers(getParams) { if (search) searchParams.set('search', search); return adminFetch(`users?${searchParams.toString()}`); }, - ...ADMIN_QUERY_CONFIG + ...ADMIN_QUERY_CONFIG, }; }); } @@ -81,7 +81,7 @@ export function useAdminUserDetails(getUserId) { queryKey: queryKeys.admin.userDetails(userId), queryFn: () => adminFetch(`users/${userId}`), enabled: !!userId, - ...ADMIN_QUERY_CONFIG + ...ADMIN_QUERY_CONFIG, }; }); } @@ -150,7 +150,7 @@ export function useStorageDocuments(getParams) { if (search) searchParams.set('search', search); return adminFetch(`storage/documents?${searchParams.toString()}`); }, - ...ADMIN_QUERY_CONFIG + ...ADMIN_QUERY_CONFIG, }; }); } @@ -162,7 +162,7 @@ export function useStorageStats() { return useQuery(() => ({ queryKey: queryKeys.admin.storageStats, queryFn: () => adminFetch('storage/stats'), - ...ADMIN_QUERY_CONFIG + ...ADMIN_QUERY_CONFIG, })); } From 31b9882d39d2704a7b0029652424c93e5f2fa475 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sun, 11 Jan 2026 01:48:31 -0600 Subject: [PATCH 6/8] text should work now --- packages/shared/src/checklists/rob2/create.ts | 11 ++--------- .../components/checklist/ChecklistWithPdf.jsx | 1 + .../components/checklist/GenericChecklist.jsx | 1 + .../checklist/ROB2Checklist/ROB2Checklist.jsx | 19 +++++++++++++++++-- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/checklists/rob2/create.ts b/packages/shared/src/checklists/rob2/create.ts index 165d22f14..5b4772627 100644 --- a/packages/shared/src/checklists/rob2/create.ts +++ b/packages/shared/src/checklists/rob2/create.ts @@ -4,12 +4,7 @@ * Creates new ROB-2 checklist objects with proper structure and defaults. */ -import { - INFORMATION_SOURCES, - getDomainQuestions, - ROB2_CHECKLIST, - type DomainKey, -} from './schema.js'; +import { INFORMATION_SOURCES, getDomainQuestions, type DomainKey } from './schema.js'; export interface ROB2Checklist { id: string; @@ -137,11 +132,9 @@ function createDomainState(domainKey: DomainKey): DomainState { answers[qKey] = { answer: null, comment: '' }; }); - const domain = ROB2_CHECKLIST[domainKey]; - return { answers, judgement: null, - direction: domain?.hasDirection ? null : null, + direction: null, }; } diff --git a/packages/web/src/components/checklist/ChecklistWithPdf.jsx b/packages/web/src/components/checklist/ChecklistWithPdf.jsx index b87083f3d..0ab28cdcf 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.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.getRob2Text - function to get Y.Text for a ROB-2 free-text field // props.pdfUrl - optional PDF URL (for server-hosted PDFs) return ( diff --git a/packages/web/src/components/checklist/GenericChecklist.jsx b/packages/web/src/components/checklist/GenericChecklist.jsx index 02f64d6ad..e05f2316f 100644 --- a/packages/web/src/components/checklist/GenericChecklist.jsx +++ b/packages/web/src/components/checklist/GenericChecklist.jsx @@ -27,6 +27,7 @@ import { ROB2Checklist } from '@/components/checklist/ROB2Checklist/ROB2Checklis * @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 + * @param {Function} [props.getRob2Text] - Function to get Y.Text for a ROB-2 free-text field */ export default function GenericChecklist(props) { // Determine the checklist type from props or state diff --git a/packages/web/src/components/checklist/ROB2Checklist/ROB2Checklist.jsx b/packages/web/src/components/checklist/ROB2Checklist/ROB2Checklist.jsx index fe382212f..005ff2b22 100644 --- a/packages/web/src/components/checklist/ROB2Checklist/ROB2Checklist.jsx +++ b/packages/web/src/components/checklist/ROB2Checklist/ROB2Checklist.jsx @@ -1,4 +1,4 @@ -import { createSignal, For, Show, createMemo } from 'solid-js'; +import { createSignal, For, Show, createMemo, onCleanup } from 'solid-js'; import { getActiveDomainKeys } from './checklist-map.js'; import { PreliminarySection } from './PreliminarySection.jsx'; import { DomainSection } from './DomainSection.jsx'; @@ -23,6 +23,15 @@ export function ROB2Checklist(props) { // Track collapsed state for each domain const [collapsedDomains, setCollapsedDomains] = createSignal({}); + // Ref for domain scroll timeout cleanup + let domainScrollTimeoutId = null; + + onCleanup(() => { + if (domainScrollTimeoutId !== null) { + clearTimeout(domainScrollTimeoutId); + } + }); + // Get active domains based on aim selection (assignment vs. adhering) const isAdhering = createMemo(() => props.checklistState?.preliminary?.aim === 'ADHERING'); @@ -59,10 +68,16 @@ export function ROB2Checklist(props) { [domainKey]: false, })); + // Clear any existing timeout before setting a new one + if (domainScrollTimeoutId !== null) { + clearTimeout(domainScrollTimeoutId); + } + // Scroll to domain section after a short delay for DOM update - setTimeout(() => { + domainScrollTimeoutId = setTimeout(() => { const element = document.getElementById(`domain-section-${domainKey}`); element?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + domainScrollTimeoutId = null; }, 100); } From df509a2d5fb25e99b38baea87f38fb07abac3936 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sun, 11 Jan 2026 02:04:49 -0600 Subject: [PATCH 7/8] error boundary summary assessment and improve error logging --- .../docs/audits/error-boundary-assessment.md | 149 ++++++++ packages/docs/plans/robins-i-smart-flow.md | 327 ------------------ packages/web/src/api/better-auth-store.js | 4 +- .../src/components/admin/AnalyticsSection.jsx | 18 +- .../src/components/admin/ProjectDetail.jsx | 3 +- .../web/src/components/admin/UserDetail.jsx | 3 +- .../AdminBillingLedgerPage.jsx | 3 +- .../billing-observability/StripeToolsPage.jsx | 3 +- .../web/src/components/auth/CheckEmail.jsx | 3 +- .../src/components/billing/InvoicesList.jsx | 4 +- .../src/components/dev/DevImportProject.jsx | 6 +- .../web/src/components/dev/DevJsonEditor.jsx | 3 +- .../src/components/profile/ProfilePage.jsx | 6 +- .../GoogleDrivePickerLauncher.jsx | 8 +- .../components/settings/SettingsLayout.jsx | 8 +- .../settings/pages/MergeAccountsDialog.jsx | 4 +- .../src/components/sidebar/useRecentsNav.js | 8 +- packages/web/src/lib/lastLoginMethod.js | 7 +- packages/web/src/lib/queryClient.js | 8 +- packages/web/src/lib/referenceLookup.js | 3 +- packages/web/src/primitives/avatarCache.js | 4 +- .../primitives/useAddStudies/serialization.js | 4 +- .../web/src/primitives/useNotifications.js | 4 +- .../web/src/primitives/useOnlineStatus.js | 3 +- packages/web/src/stores/adminStore.js | 3 +- .../src/stores/projectActionsStore/pdfs.js | 14 +- packages/web/src/stores/projectStore.js | 7 +- 27 files changed, 233 insertions(+), 384 deletions(-) create mode 100644 packages/docs/audits/error-boundary-assessment.md delete mode 100644 packages/docs/plans/robins-i-smart-flow.md diff --git a/packages/docs/audits/error-boundary-assessment.md b/packages/docs/audits/error-boundary-assessment.md new file mode 100644 index 000000000..268b86f21 --- /dev/null +++ b/packages/docs/audits/error-boundary-assessment.md @@ -0,0 +1,149 @@ +# Error Boundary Implementation Assessment + +## What Was Done + +### 1. Centralized Error Logger (`packages/web/src/lib/errorLogger.js`) + +Created a new centralized error logging module that provides: + +- **`logError(error, context)`** - Logs caught exceptions with component/action context +- **`logWarning(message, context)`** - Logs non-fatal issues (cache misses, degraded functionality) +- **`logInfo(message, context)`** - Logs important state transitions +- **`bestEffort(promise, context)`** - Wraps operations that can fail silently (cleanup, cache updates) +- **`withErrorLogging(component, action)`** - Decorator for async functions +- **`logAndRethrow(component, action)`** - For catch blocks that need to log but propagate errors + +All functions normalize errors through `@corates/shared` and include structured context (component name, action, timestamp). + +### 2. Enhanced Error Boundaries + +**AppErrorBoundary** (main component): +- Now uses `logError` instead of raw `console.error` +- Passes component name context for better debugging + +**SectionErrorBoundary** (new capabilities): +- Added `name` prop for identifying which section failed +- Added `onRetry` callback for custom retry logic (e.g., query invalidation) +- Added `retryLabel` prop for custom button text +- Error message now includes section name: "Error in Projects" + +### 3. Section-Level Error Isolation + +Wrapped major UI sections with `SectionErrorBoundary`: + +| Location | Sections Wrapped | +|----------|------------------| +| `Dashboard.jsx` | Projects, Local Appraisals | +| `ProjectView.jsx` | Overview, All Studies, To-Do, Reconcile, Completed (each tab) | +| `AdminLayout.jsx` | Admin content area | +| `SettingsLayout.jsx` | Settings content area | + +### 4. Best-Effort Operation Cleanup + +Replaced silent `.catch(() => {})` patterns with `bestEffort()`: + +```javascript +// Before +clearFormState(type).catch(() => {}); + +// After +bestEffort(clearFormState(type), { operation: 'clearFormState', type }); +``` + +This ensures failures are logged as warnings rather than completely swallowed. + +### 5. Admin Queries Refactor + +- Switched from raw `fetch` to `apiFetch` for consistent error handling +- Extracted duplicate config into `ADMIN_QUERY_CONFIG` constant +- Removed redundant comments + +--- + +## Why It Was Done + +### Problem 1: Silent Failures +Best-effort operations were using `.catch(() => {})` which completely swallowed errors. If cleanup routines started failing (e.g., IndexedDB quota exceeded), there was no visibility into the issue. + +### Problem 2: No Error Context +When `AppErrorBoundary` caught errors, it logged them with minimal context. Debugging required correlating timestamps with user actions manually. + +### Problem 3: Catastrophic Failures +A single component error (e.g., bad data in one project card) would crash the entire dashboard. Users lost access to all functionality instead of just the affected section. + +### Problem 4: No Monitoring Integration Point +Error logging was scattered across the codebase. Integrating Sentry or similar would require finding and modifying dozens of locations. + +### Problem 5: Inconsistent Error Handling in Admin +Admin queries used raw `fetch` while the rest of the app used `apiFetch`, leading to inconsistent error normalization and handling. + +--- + +## Next Steps + +### 1. Integrate Sentry (or Alternative) + +The `errorLogger.js` module includes commented placeholder code for Sentry integration. When ready: + +```javascript +// In errorLogger.js, uncomment and configure: +if (typeof window !== 'undefined' && window.Sentry) { + window.Sentry.captureException(error, { + tags: { component, action }, + extra: context, + }); +} +``` + +**Why:** Centralized logging is only useful if errors are aggregated somewhere. Sentry provides alerting, deduplication, and release tracking. + +### 2. Add Error Boundaries to Remaining High-Risk Areas + +Current coverage is good for main layouts, but these areas should be considered: + +- Individual study cards in lists (prevent one bad study from hiding all) +- Checklist domain sections (isolate domain rendering failures) +- PDF viewer (already somewhat isolated, but could benefit from explicit boundary) +- Modal/dialog content (prevent modal errors from crashing parent) + +**Why:** Finer-grained boundaries mean smaller blast radius. A corrupted study shouldn't hide the entire study list. + +### 3. Add User-Facing Error Reporting + +The current error UI shows "Try Again" but doesn't let users report issues. Consider: + +- "Report this issue" button that captures error context +- Session replay integration for debugging user-reported issues +- Error ID display so users can reference specific failures in support requests + +**Why:** Users encountering errors are a valuable signal. Making it easy to report helps identify edge cases. + +### 4. Add Error Boundary Recovery Strategies + +`SectionErrorBoundary` now supports `onRetry` for custom recovery. Use this for: + +- Query-backed sections: invalidate and refetch on retry +- WebSocket sections: reconnect on retry +- Form sections: restore from draft state on retry + +**Why:** Generic "reset and re-render" often fails for the same reason. Section-specific recovery can actually fix the issue. + +### 5. Add Error Metrics/Analytics + +Track error rates over time: + +- Errors per session +- Errors by component/section +- Error recovery success rate (did retry work?) + +**Why:** Helps identify regressions. If error rate spikes after a deploy, you know something broke. + +### 6. Consider Suspense Boundaries + +SolidJS supports Suspense for async loading. Combining error boundaries with suspense boundaries would provide: + +- Loading states during data fetch +- Error states on fetch failure +- Smooth transitions between states + +**Why:** Currently some components handle their own loading/error states inconsistently. Suspense + ErrorBoundary provides a unified pattern. diff --git a/packages/docs/plans/robins-i-smart-flow.md b/packages/docs/plans/robins-i-smart-flow.md deleted file mode 100644 index d78a5d27c..000000000 --- a/packages/docs/plans/robins-i-smart-flow.md +++ /dev/null @@ -1,327 +0,0 @@ -# ROBINS-I Smart Flow UI Plan - -This plan outlines the implementation of smart early-exit detection for the ROBINS-I checklist UI. The scoring engine already has deterministic decision trees that can terminate early. This enhancement surfaces that intelligence in the UI to guide users through the checklist more efficiently. - -## Problem Statement - -Currently, ROBINS-I domains require users to answer all signalling questions sequentially, even when the scoring logic has already reached a definitive judgement. This wastes time and creates confusion when certain answer combinations make subsequent questions irrelevant. - -### Example: Domain 5 (Outcome Measurement Bias) - -``` -Q1: "Was outcome measured in a way likely to be influenced by knowledge of intervention?" - -> If Y/PY: Judgement = SERIOUS (complete) -- Q2 and Q3 are irrelevant - -Q2: "Could outcome assessor's awareness of intervention status influence the assessment?" - -> If N/PN: Judgement = LOW (complete) -- Q3 is irrelevant - -Q3: "Were there systematic differences in outcome assessment?" - -> Final path completes based on Q3 answer -``` - -When a user answers Q1 with "Yes", the scoring is complete at "Serious" risk. Questions 2 and 3 are now irrelevant, but the UI shows no indication of this. - ---- - -## Current State Analysis - -### Scoring Engine ([scoring.ts](packages/shared/src/checklists/robins-i/scoring.ts)) - -The scoring engine is deterministic and returns: - -```typescript -interface ScoringResult { - judgement: Judgement | null; // The calculated judgement (or null if incomplete) - isComplete: boolean; // Whether enough answers exist to determine judgement - ruleId: string | null; // Which decision rule was matched (e.g., 'D5.R1') -} -``` - -Key insight: when `isComplete === true`, remaining questions in that domain are not needed for the scoring decision. **We already have this data - no new utilities needed.** - -### UI Components - -| Component | Location | Purpose | -| -------------------- | -------------------------------------------------------------- | -------------------------------------- | -| `ROBINSIChecklist` | `components/checklist/ROBINSIChecklist/ROBINSIChecklist.jsx` | Main checklist container | -| `DomainSection` | `components/checklist/ROBINSIChecklist/DomainSection.jsx` | Domain with questions and judgement | -| `SignallingQuestion` | `components/checklist/ROBINSIChecklist/SignallingQuestion.jsx` | Individual question with radio buttons | - -### Current Flow - -1. User expands a domain section -2. All questions are displayed equally -3. User answers questions top-to-bottom -4. Auto-scoring calculates judgement in real-time via `scoreRobinsDomain()` -5. No indication of which questions are still relevant - ---- - -## Requirements - -### Must Have - -1. **Visual indication when a domain completes early** - Users should clearly see that remaining questions are optional -2. **Gray out / de-emphasize skippable questions** - Reduced opacity or visual treatment for questions that won't affect the score -3. **Allow users to still answer skipped questions** - Questions should remain editable for documentation purposes -4. **Clear messaging explaining why questions are skipped** - Brief explanation when early exit occurs - -### Nice to Have - -1. **Progressive disclosure** - Hide optional questions by default, expandable if user wants to document -2. **Reconciliation view awareness** - Indicate skipped questions in the reconciliation UI - -### Non-Goals - -1. Changing the scoring logic itself -2. Preventing users from answering any questions -3. Different behavior between local and synced checklists - ---- - -## Technical Design - -### Core Approach: Leverage Existing Scoring - -The scoring engine already returns `isComplete: true` when a judgement is determined. We don't need a separate skip-detection module - just use what we have: - -```jsx -// In DomainSection.jsx - already exists: -const autoScore = createMemo(() => { - return scoreRobinsDomain(props.domainKey, props.domainState?.answers); -}); - -// Add these simple derived signals: -const isEarlyComplete = () => autoScore().isComplete && autoScore().judgement !== null; - -const isQuestionSkippable = qKey => { - return isEarlyComplete() && !props.domainState?.answers?.[qKey]?.answer; -}; -``` - -This is ~5 lines of code that reuses the existing scoring infrastructure. - -### UI Component Updates - -#### 1. Update `DomainSection.jsx` - -Add early completion detection and pass to questions: - -```jsx -export function DomainSection(props) { - // Existing auto-score memo - const autoScore = createMemo(() => { - return scoreRobinsDomain(props.domainKey, props.domainState?.answers); - }); - - // NEW: Check if scoring completed early - const isEarlyComplete = () => autoScore().isComplete && autoScore().judgement !== null; - - // NEW: Check if a specific question can be skipped - const isQuestionSkippable = qKey => { - return isEarlyComplete() && !props.domainState?.answers?.[qKey]?.answer; - }; - - return ( -
- {/* NEW: Early completion banner */} - -
-
- - - -
-
Scoring Complete
-
- Remaining questions are optional but can still be answered for documentation. -
-
-
-
-
- - {/* Pass skip status to each question */} - - {([qKey, qDef]) => ( - handleQuestionUpdate(qKey, newAnswer)} - disabled={props.disabled} - isSkippable={isQuestionSkippable(qKey)} - // ... other existing props - /> - )} - -
- ); -} -``` - -#### 2. Update `SignallingQuestion.jsx` - -Add visual states for skippable questions: - -```jsx -export function SignallingQuestion(props) { - // props.isSkippable - NEW prop indicating this question can be skipped - - return ( -
- -
Optional - scoring already determined
-
- {/* ... existing question content unchanged ... */} -
- ); -} -``` - ---- - -## Implementation Phases - -### Phase 1: Core UI Changes (1-2 days) - -1. **Update `DomainSection.jsx`** - - Add `isEarlyComplete()` and `isQuestionSkippable()` derived signals - - Add early completion banner - - Pass `isSkippable` prop to `SignallingQuestion` - -2. **Update `SignallingQuestion.jsx`** - - Accept `isSkippable` prop - - Apply opacity styling when skippable - - Show "Optional" label - -3. **Basic testing** - - Verify banner appears when scoring completes early - - Verify unanswered questions get grayed out - - Verify questions remain editable - -### Phase 2: Polish (1 day) - -1. **CSS transitions** - - Smooth opacity changes when questions become skippable - - Subtle animation for the completion banner - -2. **Header badge update** - - Show "Early" indicator in collapsed domain header when applicable - -### Phase 3: Enhanced UX (Optional, 1-2 days) - -1. **Collapsible skipped questions** - - Option to hide skipped questions by default - - Expand button: "Show X optional questions" - - Remember preference in localStorage - -### Phase 4: Reconciliation View (Optional, 1 day) - -1. **Update reconciliation UI** - - Show skip indicators in question navigation - - Handle cases where reviewers took different paths - ---- - -## Visual Design - -### Skippable Question State - -``` -+---------------------------------------------------------------+ -| [Optional - scoring already determined] | <- Small italic label -| 5.2 Could outcome assessor's awareness of intervention... | <- 50% opacity -| [Y] [PY] [PN] [N] [NI] | <- Still clickable -+---------------------------------------------------------------+ -``` - -### Early Completion Banner - -``` -+---------------------------------------------------------------+ -| [Check Icon] Scoring Complete | -| | -| Remaining questions are optional but can still be answered | -| for documentation purposes. | -+---------------------------------------------------------------+ -``` - ---- - -## Testing Strategy - -### Component Tests - -1. Verify visual opacity change on skippable questions -2. Verify banner appears on early completion -3. Verify questions remain editable when skipped -4. Verify skip info updates reactively as answers change - -### Integration Tests - -1. Complete a domain via early exit and verify UI state -2. Fill in a skipped question and verify it's no longer marked skippable -3. Change an answer that undoes early completion - ---- - -## Open Questions - -1. **Export format changes?** - - Should exported data indicate which questions were skipped? - - Important for audit trails - -2. **Different behavior for reconciliation?** - - If Reviewer 1 skipped Q3 but Reviewer 2 answered it, how to reconcile? - - Current thinking: Show both, let reconciler decide - -3. **Mobile responsiveness** - - Ensure skip indicators work well on small screens - - Banner may need to be more compact - ---- - -## Files to Modify - -| File | Changes | -| ------------------------------------------------------------------------------- | ------------------------------------ | -| `packages/web/src/components/checklist/ROBINSIChecklist/DomainSection.jsx` | Add early complete detection, banner | -| `packages/web/src/components/checklist/ROBINSIChecklist/SignallingQuestion.jsx` | Add skip styling, optional label | - -No new files needed - we're leveraging the existing scoring infrastructure. - ---- - -## Timeline Estimate - -| Phase | Duration | Dependencies | -| ---------------------------------- | -------- | ------------ | -| Phase 1: Core UI Changes | 1-2 days | None | -| Phase 2: Polish | 1 day | Phase 1 | -| Phase 3: Enhanced UX (Optional) | 1-2 days | Phase 2 | -| Phase 4: Reconciliation (Optional) | 1 day | Phase 1 | - -**Total: 2-3 days for core implementation, 4-6 days with all optional phases** - ---- - -## Success Metrics - -1. **Reduced time to complete checklists** - Users should complete domains faster when early exits apply -2. **Reduced confusion** - Fewer support questions about "do I need to answer all questions?" -3. **Maintained data quality** - No increase in incomplete assessments -4. **User satisfaction** - Positive feedback on the guided flow - ---- - -## References - -- [ROBINS-I Scoring Engine](packages/shared/src/checklists/robins-i/scoring.ts) -- [Domain Section Component](packages/web/src/components/checklist/ROBINSIChecklist/DomainSection.jsx) -- [Signalling Question Component](packages/web/src/components/checklist/ROBINSIChecklist/SignallingQuestion.jsx) -- [ROBINS-I Official Documentation](https://www.bristol.ac.uk/population-health-sciences/centres/cresyda/barr/riskofbias/robins-i/) diff --git a/packages/web/src/api/better-auth-store.js b/packages/web/src/api/better-auth-store.js index 266fa3197..24efa25e6 100644 --- a/packages/web/src/api/better-auth-store.js +++ b/packages/web/src/api/better-auth-store.js @@ -783,8 +783,8 @@ function createBetterAuthStore() { if (error) { throw new Error(error.message); } - } catch { - // If Better Auth doesn't have this method, we could call our backend directly + } catch (authClientErr) { + console.warn('Better Auth sendVerificationEmail failed, trying backend:', authClientErr.message); try { const response = await fetch('/api/auth/send-verification-email', { method: 'POST', diff --git a/packages/web/src/components/admin/AnalyticsSection.jsx b/packages/web/src/components/admin/AnalyticsSection.jsx index 5ef25f12a..b62970213 100644 --- a/packages/web/src/components/admin/AnalyticsSection.jsx +++ b/packages/web/src/components/admin/AnalyticsSection.jsx @@ -34,7 +34,8 @@ export default function AnalyticsSection() { async days => { try { return await apiFetch.get(`/api/admin/stats/signups?days=${days}`, { toastMessage: false }); - } catch { + } catch (err) { + console.warn('Failed to fetch signup stats:', err.message); return null; } }, @@ -48,7 +49,8 @@ export default function AnalyticsSection() { return await apiFetch.get(`/api/admin/stats/organizations?days=${days}`, { toastMessage: false, }); - } catch { + } catch (err) { + console.warn('Failed to fetch organization stats:', err.message); return null; } }, @@ -62,7 +64,8 @@ export default function AnalyticsSection() { return await apiFetch.get(`/api/admin/stats/projects?days=${days}`, { toastMessage: false, }); - } catch { + } catch (err) { + console.warn('Failed to fetch project stats:', err.message); return null; } }, @@ -76,7 +79,8 @@ export default function AnalyticsSection() { return await apiFetch.get(`/api/admin/stats/webhooks?days=${days}`, { toastMessage: false, }); - } catch { + } catch (err) { + console.warn('Failed to fetch webhook stats:', err.message); return null; } }, @@ -86,7 +90,8 @@ export default function AnalyticsSection() { const [subscriptionData, { refetch: refetchSubs }] = createResource(async () => { try { return await apiFetch.get('/api/admin/stats/subscriptions', { toastMessage: false }); - } catch { + } catch (err) { + console.warn('Failed to fetch subscription stats:', err.message); return null; } }); @@ -95,7 +100,8 @@ export default function AnalyticsSection() { const [revenueData, { refetch: refetchRevenue }] = createResource(async () => { try { return await apiFetch.get('/api/admin/stats/revenue?months=6', { toastMessage: false }); - } catch { + } catch (err) { + console.warn('Failed to fetch revenue stats:', err.message); return null; } }); diff --git a/packages/web/src/components/admin/ProjectDetail.jsx b/packages/web/src/components/admin/ProjectDetail.jsx index 0647b5cb4..9030a263f 100644 --- a/packages/web/src/components/admin/ProjectDetail.jsx +++ b/packages/web/src/components/admin/ProjectDetail.jsx @@ -88,7 +88,8 @@ export default function ProjectDetail() { setCopiedId(`${label}-${text}`); showToast.success('Copied', `${label} copied to clipboard`); setTimeout(() => setCopiedId(null), 2000); - } catch { + } catch (err) { + console.warn('Clipboard copy failed:', err.message); showToast.error('Error', 'Failed to copy to clipboard'); } }; diff --git a/packages/web/src/components/admin/UserDetail.jsx b/packages/web/src/components/admin/UserDetail.jsx index 74f53fd24..beaf3d7ed 100644 --- a/packages/web/src/components/admin/UserDetail.jsx +++ b/packages/web/src/components/admin/UserDetail.jsx @@ -90,7 +90,8 @@ export default function UserDetail() { setCopiedId(`${label}-${text}`); showToast.success('Copied', `${label} copied to clipboard`); setTimeout(() => setCopiedId(null), 2000); - } catch { + } catch (err) { + console.warn('Clipboard copy failed:', err.message); showToast.error('Error', 'Failed to copy to clipboard'); } }; diff --git a/packages/web/src/components/admin/billing-observability/AdminBillingLedgerPage.jsx b/packages/web/src/components/admin/billing-observability/AdminBillingLedgerPage.jsx index 74a962c35..ba0f63223 100644 --- a/packages/web/src/components/admin/billing-observability/AdminBillingLedgerPage.jsx +++ b/packages/web/src/components/admin/billing-observability/AdminBillingLedgerPage.jsx @@ -94,7 +94,8 @@ export default function AdminBillingLedgerPage() { setCopiedId(`${label}-${text}`); showToast.success('Copied', `${label} copied to clipboard`); setTimeout(() => setCopiedId(null), 2000); - } catch { + } catch (err) { + console.warn('Clipboard copy failed:', err.message); showToast.error('Error', 'Failed to copy to clipboard'); } }; diff --git a/packages/web/src/components/admin/billing-observability/StripeToolsPage.jsx b/packages/web/src/components/admin/billing-observability/StripeToolsPage.jsx index 5924e9e03..c9279117b 100644 --- a/packages/web/src/components/admin/billing-observability/StripeToolsPage.jsx +++ b/packages/web/src/components/admin/billing-observability/StripeToolsPage.jsx @@ -74,7 +74,8 @@ export default function StripeToolsPage() { setCopiedId(`${label}-${text}`); showToast.success('Copied', `${label} copied to clipboard`); setTimeout(() => setCopiedId(null), 2000); - } catch { + } catch (err) { + console.warn('Clipboard copy failed:', err.message); showToast.error('Error', 'Failed to copy to clipboard'); } }; diff --git a/packages/web/src/components/auth/CheckEmail.jsx b/packages/web/src/components/auth/CheckEmail.jsx index 1990161ef..8e43561da 100644 --- a/packages/web/src/components/auth/CheckEmail.jsx +++ b/packages/web/src/components/auth/CheckEmail.jsx @@ -108,7 +108,8 @@ export default function CheckEmail() { await resendVerificationEmail(email()); setResent(true); setTimeout(() => setResent(false), RESENT_TIMEOUT_MS); - } catch { + } catch (err) { + console.warn('Failed to resend verification email:', err.message); setDisplayError('Failed to resend email. Please try again.'); } finally { setResending(false); diff --git a/packages/web/src/components/billing/InvoicesList.jsx b/packages/web/src/components/billing/InvoicesList.jsx index 4cba1f1f9..6bf505337 100644 --- a/packages/web/src/components/billing/InvoicesList.jsx +++ b/packages/web/src/components/billing/InvoicesList.jsx @@ -16,8 +16,8 @@ import { queryKeys } from '@lib/queryKeys.js'; async function fetchInvoices() { try { return await apiFetch.get('/api/billing/invoices', { toastMessage: false }); - } catch { - // API endpoint doesn't exist yet, return empty array + } catch (err) { + console.warn('Failed to fetch invoices:', err.message); return { invoices: [] }; } } diff --git a/packages/web/src/components/dev/DevImportProject.jsx b/packages/web/src/components/dev/DevImportProject.jsx index bea3a1100..94c5ef72c 100644 --- a/packages/web/src/components/dev/DevImportProject.jsx +++ b/packages/web/src/components/dev/DevImportProject.jsx @@ -45,7 +45,8 @@ export default function DevImportProject() { let parsed; try { parsed = JSON.parse(jsonText()); - } catch { + } catch (err) { + console.warn('JSON parse error:', err.message); setResult({ success: false, message: 'Invalid JSON' }); return; } @@ -118,7 +119,8 @@ export default function DevImportProject() { const text = await file.text(); setJsonText(text); setResult(null); - } catch { + } catch (err) { + console.warn('Failed to read file:', err.message); setResult({ success: false, message: 'Failed to read file' }); } }; diff --git a/packages/web/src/components/dev/DevJsonEditor.jsx b/packages/web/src/components/dev/DevJsonEditor.jsx index 0f0e0f7b7..be4a8cbbc 100644 --- a/packages/web/src/components/dev/DevJsonEditor.jsx +++ b/packages/web/src/components/dev/DevJsonEditor.jsx @@ -55,7 +55,8 @@ export default function DevJsonEditor(props) { let parsed; try { parsed = JSON.parse(jsonText()); - } catch { + } catch (err) { + console.warn('JSON parse error:', err.message); setResult({ success: false, message: 'Invalid JSON' }); return; } diff --git a/packages/web/src/components/profile/ProfilePage.jsx b/packages/web/src/components/profile/ProfilePage.jsx index 0b0939089..321bae7a5 100644 --- a/packages/web/src/components/profile/ProfilePage.jsx +++ b/packages/web/src/components/profile/ProfilePage.jsx @@ -109,7 +109,8 @@ export default function ProfilePage() { syncProfileToProjects(); showToast.success('Profile Updated', 'Your name has been updated successfully.'); setIsEditingName(false); - } catch { + } catch (err) { + console.warn('Failed to update profile name:', err.message); showToast.error('Update Failed', 'Failed to update name. Please try again.'); } finally { setSaving(false); @@ -125,7 +126,8 @@ export default function ProfilePage() { }); showToast.success('Profile Updated', 'Your persona has been updated successfully.'); setIsEditingRole(false); - } catch { + } catch (err) { + console.warn('Failed to update profile persona:', err.message); showToast.error('Update Failed', 'Failed to update persona. Please try again.'); } finally { setSaving(false); diff --git a/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.jsx b/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.jsx index b1db35829..a62c91554 100644 --- a/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.jsx +++ b/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.jsx @@ -133,8 +133,8 @@ export default function GoogleDrivePickerLauncher(props) { const handleConnectGoogle = async () => { try { await connect(); - } catch { - // primitive sets error + } catch (err) { + console.warn('Google Drive connect failed:', err.message); } }; @@ -149,8 +149,8 @@ export default function GoogleDrivePickerLauncher(props) { const picked = await openPicker({ multiselect: !!props.multiselect }); if (!picked || picked.length === 0) return; await props.onPick?.(picked, studyId); - } catch { - // primitive sets error + } catch (err) { + console.warn('Google Drive picker failed:', err.message); } }; diff --git a/packages/web/src/components/settings/SettingsLayout.jsx b/packages/web/src/components/settings/SettingsLayout.jsx index c17e7259e..43995d6f3 100644 --- a/packages/web/src/components/settings/SettingsLayout.jsx +++ b/packages/web/src/components/settings/SettingsLayout.jsx @@ -20,8 +20,8 @@ function getInitialSidebarMode() { if (stored === 'expanded' || stored === 'collapsed') { return stored; } - } catch { - // localStorage not available (SSR or private browsing) + } catch (err) { + console.warn('Failed to read sidebar mode from localStorage:', err.message); } return 'collapsed'; } @@ -38,8 +38,8 @@ function getInitialSidebarWidth() { return parsed; } } - } catch { - // localStorage not available + } catch (err) { + console.warn('Failed to read sidebar width from localStorage:', err.message); } return DEFAULT_SIDEBAR_WIDTH; } diff --git a/packages/web/src/components/settings/pages/MergeAccountsDialog.jsx b/packages/web/src/components/settings/pages/MergeAccountsDialog.jsx index 912eb06d0..bbf699207 100644 --- a/packages/web/src/components/settings/pages/MergeAccountsDialog.jsx +++ b/packages/web/src/components/settings/pages/MergeAccountsDialog.jsx @@ -180,8 +180,8 @@ export default function MergeAccountsDialog(props) { if (mergeToken()) { try { await cancelMerge(mergeToken()); - } catch { - // Ignore cancel errors + } catch (err) { + console.warn('Failed to cancel merge:', err.message); } } props.onOpenChange?.(false); diff --git a/packages/web/src/components/sidebar/useRecentsNav.js b/packages/web/src/components/sidebar/useRecentsNav.js index 78fb7331a..3306bae4f 100644 --- a/packages/web/src/components/sidebar/useRecentsNav.js +++ b/packages/web/src/components/sidebar/useRecentsNav.js @@ -22,8 +22,8 @@ export default function useRecentsNav() { setRecents(parsed.slice(0, MAX_RECENTS)); } } - } catch { - // Ignore parse errors + } catch (err) { + console.warn('Failed to parse recents from localStorage:', err.message); } }); @@ -41,8 +41,8 @@ export default function useRecentsNav() { // Persist to localStorage try { localStorage.setItem(RECENTS_STORAGE_KEY, JSON.stringify(updated)); - } catch { - // Ignore storage errors + } catch (err) { + console.warn('Failed to persist recents to localStorage:', err.message); } return updated; }); diff --git a/packages/web/src/lib/lastLoginMethod.js b/packages/web/src/lib/lastLoginMethod.js index b1c334da7..9fdd9e223 100644 --- a/packages/web/src/lib/lastLoginMethod.js +++ b/packages/web/src/lib/lastLoginMethod.js @@ -45,7 +45,8 @@ export function saveLastLoginMethod(method) { export function getLastLoginMethod() { try { return localStorage.getItem(STORAGE_KEY); - } catch { + } catch (err) { + console.warn('Failed to get last login method from localStorage:', err.message); return null; } } @@ -65,7 +66,7 @@ export function getLastLoginMethodLabel() { export function clearLastLoginMethod() { try { localStorage.removeItem(STORAGE_KEY); - } catch { - // ignore + } catch (err) { + console.warn('Failed to clear last login method from localStorage:', err.message); } } diff --git a/packages/web/src/lib/queryClient.js b/packages/web/src/lib/queryClient.js index 7a9bf5235..054c844b0 100644 --- a/packages/web/src/lib/queryClient.js +++ b/packages/web/src/lib/queryClient.js @@ -146,8 +146,8 @@ async function setupPersistence(queryClient) { CACHE_SNAPSHOT_KEY, JSON.stringify({ queries: criticalQueries, timestamp: Date.now() }), ); - } catch { - // Silently fail - localStorage may be full or unavailable + } catch (err) { + console.warn('Failed to save query cache snapshot to localStorage:', err.message); } // Still try async persist (may complete if unload is slow) @@ -177,8 +177,8 @@ async function setupPersistence(queryClient) { // Clear snapshot after restoration localStorage.removeItem(CACHE_SNAPSHOT_KEY); } - } catch { - // Silently fail + } catch (err) { + console.warn('Failed to restore query cache from localStorage:', err.message); } } diff --git a/packages/web/src/lib/referenceLookup.js b/packages/web/src/lib/referenceLookup.js index 96a959fc9..51301cf7d 100644 --- a/packages/web/src/lib/referenceLookup.js +++ b/packages/web/src/lib/referenceLookup.js @@ -394,7 +394,8 @@ export async function fetchReferenceByIdentifier(identifier) { try { ref = await fetchFromDOI(trimmed); ref.importSource = 'doi'; - } catch { + } catch (err) { + console.warn('Reference lookup failed:', err.message); throw new Error( 'Could not identify reference. Please enter a valid DOI (e.g., 10.1234/example) or PubMed ID (e.g., 12345678).', ); diff --git a/packages/web/src/primitives/avatarCache.js b/packages/web/src/primitives/avatarCache.js index 4f4aa0079..ca1233ac3 100644 --- a/packages/web/src/primitives/avatarCache.js +++ b/packages/web/src/primitives/avatarCache.js @@ -223,8 +223,8 @@ export async function getAvatarWithCache(userId, imageUrl) { try { const dataUrl = await fetchAndCacheAvatar(userId, imageUrl); return dataUrl; - } catch { - // If fetch fails, try to return cached version + } catch (err) { + console.warn('Failed to fetch avatar, using cache:', err.message); return getCachedAvatar(userId); } } diff --git a/packages/web/src/primitives/useAddStudies/serialization.js b/packages/web/src/primitives/useAddStudies/serialization.js index 10354266a..61611381c 100644 --- a/packages/web/src/primitives/useAddStudies/serialization.js +++ b/packages/web/src/primitives/useAddStudies/serialization.js @@ -18,8 +18,8 @@ export function cloneArrayBuffer(buffer) { const copy = new ArrayBuffer(buffer.byteLength); new Uint8Array(copy).set(new Uint8Array(buffer)); return copy; - } catch { - // Buffer is likely detached + } catch (err) { + console.warn('Failed to copy ArrayBuffer (likely detached):', err.message); return null; } } diff --git a/packages/web/src/primitives/useNotifications.js b/packages/web/src/primitives/useNotifications.js index 2d157a5c4..6f0d6e238 100644 --- a/packages/web/src/primitives/useNotifications.js +++ b/packages/web/src/primitives/useNotifications.js @@ -115,8 +115,8 @@ export function useNotifications(userId, options = {}) { // Force close so onclose handler runs and triggers reconnection try { if (ws && ws.readyState !== WebSocket.CLOSED) ws.close(); - } catch (_e) { - // ignore + } catch (closeErr) { + console.warn('Failed to close WebSocket after error:', closeErr.message); } }; } diff --git a/packages/web/src/primitives/useOnlineStatus.js b/packages/web/src/primitives/useOnlineStatus.js index 554de510e..8f42361ef 100644 --- a/packages/web/src/primitives/useOnlineStatus.js +++ b/packages/web/src/primitives/useOnlineStatus.js @@ -40,7 +40,8 @@ export default function useOnlineStatus() { } finally { clearTimeout(timeoutId); } - } catch { + } catch (err) { + console.warn('Connectivity check failed:', err.message); return false; } } diff --git a/packages/web/src/stores/adminStore.js b/packages/web/src/stores/adminStore.js index e6eab44ef..58cdb619e 100644 --- a/packages/web/src/stores/adminStore.js +++ b/packages/web/src/stores/adminStore.js @@ -22,7 +22,8 @@ async function checkAdminStatus() { const data = await apiFetch.get('/api/auth/get-session', { toastMessage: false }); // Check if user has admin role setIsAdmin(data?.user?.role === 'admin'); - } catch { + } catch (err) { + console.warn('Failed to check admin status:', err.message); setIsAdmin(false); } finally { setIsAdminChecked(true); diff --git a/packages/web/src/stores/projectActionsStore/pdfs.js b/packages/web/src/stores/projectActionsStore/pdfs.js index f3dbf4347..c9abd22ac 100644 --- a/packages/web/src/stores/projectActionsStore/pdfs.js +++ b/packages/web/src/stores/projectActionsStore/pdfs.js @@ -35,8 +35,14 @@ export function createPdfActions( try { const [extractedTitle, extractedDoi] = await Promise.all([ - extractPdfTitle(arrayBuffer.slice(0)).catch(() => null), - extractPdfDoi(arrayBuffer.slice(0)).catch(() => null), + extractPdfTitle(arrayBuffer.slice(0)).catch(err => { + console.warn('PDF title extraction failed:', err.message); + return null; + }), + extractPdfDoi(arrayBuffer.slice(0)).catch(err => { + console.warn('PDF DOI extraction failed:', err.message); + return null; + }), ]); if (extractedTitle) metadata.title = extractedTitle; @@ -155,8 +161,8 @@ export function createPdfActions( let arrayBuffer = null; try { arrayBuffer = await file.arrayBuffer(); - } catch { - // Ignore cache if arrayBuffer conversion fails + } catch (err) { + console.warn('Failed to convert file to ArrayBuffer:', err.message); } cachePdf(projectId, studyId, uploadResult.fileName, arrayBuffer).catch(err => console.warn('Failed to cache PDF:', err), diff --git a/packages/web/src/stores/projectStore.js b/packages/web/src/stores/projectStore.js index bd6c02ce4..93e931050 100644 --- a/packages/web/src/stores/projectStore.js +++ b/packages/web/src/stores/projectStore.js @@ -21,7 +21,8 @@ function loadPersistedStats() { try { const stored = localStorage.getItem(PROJECT_STATS_KEY); return stored ? JSON.parse(stored) : {}; - } catch { + } catch (err) { + console.warn('Failed to load project stats from localStorage:', err.message); return {}; } } @@ -32,8 +33,8 @@ function loadPersistedStats() { function persistStats(stats) { try { localStorage.setItem(PROJECT_STATS_KEY, JSON.stringify(stats)); - } catch { - // Ignore storage errors (quota exceeded, etc.) + } catch (err) { + console.warn('Failed to persist project stats to localStorage:', err.message); } } From 2e66983bb17fa7f07ff23a1ae6721a5403fac381 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sun, 11 Jan 2026 08:05:31 +0000 Subject: [PATCH 8/8] Apply Prettier formatting --- .../docs/audits/error-boundary-assessment.md | 19 +++++++++++++------ packages/web/src/api/better-auth-store.js | 5 ++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/docs/audits/error-boundary-assessment.md b/packages/docs/audits/error-boundary-assessment.md index 268b86f21..6fab6ba66 100644 --- a/packages/docs/audits/error-boundary-assessment.md +++ b/packages/docs/audits/error-boundary-assessment.md @@ -18,10 +18,12 @@ All functions normalize errors through `@corates/shared` and include structured ### 2. Enhanced Error Boundaries **AppErrorBoundary** (main component): + - Now uses `logError` instead of raw `console.error` - Passes component name context for better debugging **SectionErrorBoundary** (new capabilities): + - Added `name` prop for identifying which section failed - Added `onRetry` callback for custom retry logic (e.g., query invalidation) - Added `retryLabel` prop for custom button text @@ -31,12 +33,12 @@ All functions normalize errors through `@corates/shared` and include structured Wrapped major UI sections with `SectionErrorBoundary`: -| Location | Sections Wrapped | -|----------|------------------| -| `Dashboard.jsx` | Projects, Local Appraisals | -| `ProjectView.jsx` | Overview, All Studies, To-Do, Reconcile, Completed (each tab) | -| `AdminLayout.jsx` | Admin content area | -| `SettingsLayout.jsx` | Settings content area | +| Location | Sections Wrapped | +| -------------------- | ------------------------------------------------------------- | +| `Dashboard.jsx` | Projects, Local Appraisals | +| `ProjectView.jsx` | Overview, All Studies, To-Do, Reconcile, Completed (each tab) | +| `AdminLayout.jsx` | Admin content area | +| `SettingsLayout.jsx` | Settings content area | ### 4. Best-Effort Operation Cleanup @@ -63,18 +65,23 @@ This ensures failures are logged as warnings rather than completely swallowed. ## Why It Was Done ### Problem 1: Silent Failures + Best-effort operations were using `.catch(() => {})` which completely swallowed errors. If cleanup routines started failing (e.g., IndexedDB quota exceeded), there was no visibility into the issue. ### Problem 2: No Error Context + When `AppErrorBoundary` caught errors, it logged them with minimal context. Debugging required correlating timestamps with user actions manually. ### Problem 3: Catastrophic Failures + A single component error (e.g., bad data in one project card) would crash the entire dashboard. Users lost access to all functionality instead of just the affected section. ### Problem 4: No Monitoring Integration Point + Error logging was scattered across the codebase. Integrating Sentry or similar would require finding and modifying dozens of locations. ### Problem 5: Inconsistent Error Handling in Admin + Admin queries used raw `fetch` while the rest of the app used `apiFetch`, leading to inconsistent error normalization and handling. --- diff --git a/packages/web/src/api/better-auth-store.js b/packages/web/src/api/better-auth-store.js index 24efa25e6..06b50265c 100644 --- a/packages/web/src/api/better-auth-store.js +++ b/packages/web/src/api/better-auth-store.js @@ -784,7 +784,10 @@ function createBetterAuthStore() { throw new Error(error.message); } } catch (authClientErr) { - console.warn('Better Auth sendVerificationEmail failed, trying backend:', authClientErr.message); + console.warn( + 'Better Auth sendVerificationEmail failed, trying backend:', + authClientErr.message, + ); try { const response = await fetch('/api/auth/send-verification-email', { method: 'POST',