Repository: UK-SBCoA/uniform-data-set-dotnet-web
Pull request #272: B4 CDRSUM and CDRGLOB auto-calculate 
Author: login: ahmedhaly7777
Created: 2025-02-11T14:33:58Z | Merged: 2025-04-22T14:41:17Z

Fixes #266 
Fixes #351 
 
### Initial Test Cases:
- [ ] 3 categories are 1 or higher, m= 0.5., then CDR = 1 
- [ ] if M = 0.5, CDR has to be either 1 or 0.5 
- [ ] If M = 1 or greater, CDR cannot be 0; in this circumstance, CDR = 0.5 when the majority of secondary categories are 0.
- [ ] CDR = M if at least three secondary categories are given the same score as M
- [ ] If M = 0, then CDR = 0 (no other categories chosen) 


 
### Majority Rule Test Cases:
- [ ] case1: 3 or more categories on greater (right )side of M, CDR = categories  
- [ ] case2: 3 or more categories on lesser (left) side of M, CDR = categories  
- [ ] case3: 3 or more categories on a side, 2 other categories on the opposite side. CDR = M

### Tie Rule Test Cases:
- [ ] When there is a tie and on both sides of M, CDR = the the side closest to M  (e.g., if two secondary categories = 3, and another two secondary categories = 0.5, then CDR = M)
- [ ] When only one or two secondary categories are given the same score as M, CDR = M as long as no more than two secondary categories are on either side of M

Linked issue #266: B4 Autocalculate CDRSUM and autoselect CDRGLOB
Autocalculate `CDRSUM` and auto-select `CDRGLOB` for the end-user.

![Image](https://github.com/user-attachments/assets/80286e98-b8aa-4690-a648-bbdf08665ebe)

- [ ] `CDRSUM` - the sum for all the answers MEMORY, ORIENT, JUDGMENT, COMMUN, HOMEHOBB, PERSCARE
- [ ] `CDRGLOB` - radio button option is auto-selected according to rules below (also see references)
- [ ] Allowed values for `CDRSUM` 0.0, 0.5, 1.0, 1.5, ..., 18.0 (except scores of 16.5 and 17.5 not possible), 99 = Not applicable


### CDR Sum of Boxes

Calculate the sum of values for all answers and enter the total score in the space provided. 

### Global CDR

The global CDR is derived from the scores in each of the six categories (“box score”) as follows:

- Memory (M) is the primary category, and all others are secondary.
- CDR = M if at least three secondary categories are given the same score as memory. Whenever three or more secondary
categories are given a score greater or less than the memory score, CDR = score of majority of secondary categories on whichever
side of M has the greater number of secondary categories. However, when three secondary categories are scored on one side of
M and two secondary categories are scored on the other side of M, then CDR = M.
- When M = 0.5, CDR = 1 if at least three of the other categories are scored 1 or greater. If M = 0.5, then CDR cannot be 0; it can only
be 0.5 or 1.
- If M = 0, then CDR = 0 unless there is impairment (0.5 or greater) in two more secondary categories, in which case CDR = 0.5.

Although applicable to most Alzheimer’s disease situations, these rules do not cover all possible scoring combinations. Unusual circumstances that occasionally occur in AD, and may be expected in non-Alzheimer’s dementia as well, are scored as follows:

1. With ties in the secondary categories on one side of M, choose the tied scores closest to M for CDR (e.g., if M and another
secondary category = 3, two secondary categories = 2, and two secondary categories = 1, then CDR = 2).
2. When only one or two secondary categories are given the same score as M, CDR = M as long as no more than two secondary
categories are on either side of M.
3. When M = 1 or greater, CDR cannot be 0; in this circumstance, CDR = 0.5 when the majority of secondary categories are 0.

References:

- [UDSv4 coding guidebook page 47 has instructions for CDR](https://files.alz.washington.edu/documentation/uds4-ivp-coding-guidebook.pdf)
- [Working implementation of CDR scoring algorithm] (https://knightadrc.wustl.edu/professionals-clinicians/cdr-dementia-staging-instrument/cdr-scoring-algorithm/)
- [CDR Scoring Rules] (https://knightadrc.wustl.edu/professionals-clinicians/cdr-dementia-staging-instrument/cdr-scoring-rules/)
- [UDSv4 specifications] (https://github.com/naccdata/uniform-data-set/blob/main/forms/uds/b4/form_b4_ivp_questions_and_vars.csv)

Linked issue comments: user: login: ashleybot
created_at: 2025-04-16T13:05:39Z
body: @mlan225 and Josh heard back about the 99 codes, they are only for the Down Syndrome module.
author_association: MEMBER

Diff:
diff --git a/src/UDS.Net.Forms/Pages/UDS4/B4.cshtml b/src/UDS.Net.Forms/Pages/UDS4/B4.cshtml
index 7ad62a21d..6ff6933ca 100644
--- a/src/UDS.Net.Forms/Pages/UDS4/B4.cshtml
+++ b/src/UDS.Net.Forms/Pages/UDS4/B4.cshtml
@@ -10,7 +10,7 @@
         INSTRUCTIONS: For information on the required online CDR training, see <guidebook kind="@Model.Visit.PACKET"></guidebook>, Form B4. This form is to be completed by the clinician or other trained health professional, based on co-participant report and behavioral and neurological exam of the participant. In the extremely rare instances when no co-participant is available, the clinician or other trained health professional must complete this form using all other available information and his/her best clinical judgment. Score only as decline from previous level due to <span class="underline">cognitive loss</span>, not impairment due to other factors, such as physical disability. For further information, see <guidebook kind="@Model.Visit.PACKET"></guidebook>, Form B4.
     </p>
 </div>
-<div>
+<div data-controller="b4">
     <div class="mt-10 space-y-8 border-b border-gray-900/10 pb-12 sm:space-y-0 sm:divide-y sm:divide-gray-900/10 sm:border-t sm:pb-0">
         <div class="sm:grid sm:grid-cols-3 sm:items-start sm:gap-4 sm:py-6">
             <h2>SECTION 1: CDR DEMENTIA STAGING INSTRUMENT<sup>1</sup></h2>
@@ -44,8 +44,8 @@
                     <td class="mt-1 p-2 text-sm text-gray-500">Severe memory loss; only highly learned material retained; new material rapidly lost</td>
                     <td class="mt-1 p-2 text-sm text-gray-500">Severe memory loss; only fragments remain</td>
                 </tr>
-                <tr>
-                    <table-cell-radio-buttons id="@Html.IdFor(m => m.B4.MEMORY)" for="@Model.B4.MEMORY" items="Model.ImpairmentListItems"></table-cell-radio-buttons>
+                <tr >
+                    <table-cell-radio-buttons id="@Html.IdFor(m => m.B4.MEMORY)" for="@Model.B4.MEMORY" items="Model.ImpairmentListItems" data-b4-target="MEMORY" data-action="change->b4#updateCDRSUM"></table-cell-radio-buttons>
                 </tr>
                 <tr class="divide-y divide-gray-200">
                     <td rowspan="2">
@@ -60,7 +60,7 @@
                     <td class="mt-1 p-2 text-sm text-gray-500">Oriented to person only</td>
                 </tr>
                 <tr>
-                    <table-cell-radio-buttons id="@Html.IdFor(m => m.B4.ORIENT)" for="@Model.B4.ORIENT" items="Model.ImpairmentListItems"></table-cell-radio-buttons>
+                    <table-cell-radio-buttons id="@Html.IdFor(m => m.B4.ORIENT)" for="@Model.B4.ORIENT" items="Model.ImpairmentListItems" data-b4-target="ORIENT" data-action="change->b4#updateCDRSUM"></table-cell-radio-buttons>
                 </tr>
                 <tr class="divide-y divide-gray-200">
                     <td rowspan="2">
@@ -75,7 +75,7 @@
                     <td class="mt-1 p-2 text-sm text-gray-500">Unable to make judgments or solve problems</td>
                 </tr>
                 <tr>
-                    <table-cell-radio-buttons id="@Html.IdFor(m => m.B4.JUDGMENT)" for="@Model.B4.JUDGMENT" items="Model.ImpairmentListItems"></table-cell-radio-buttons>
+                    <table-cell-radio-buttons id="@Html.IdFor(m => m.B4.JUDGMENT)" for="@Model.B4.JUDGMENT" items="Model.ImpairmentListItems" data-b4-target="JUDGMENT" data-action="change->b4#updateCDRSUM"></table-cell-radio-buttons>
                 </tr>
                 <tr class="divide-y divide-gray-200">
                     <td rowspan="2">
@@ -90,7 +90,7 @@
                     <td class="mt-1 p-2 text-sm text-gray-500">No pretense of independent function outside the home; appears too ill to be taken to functions outside the family home</td>
                 </tr>
                 <tr>
-                    <table-cell-radio-buttons id="@Html.IdFor(m => m.B4.COMMUN)" for="@Model.B4.COMMUN" items="Model.ImpairmentListItems"></table-cell-radio-buttons>
+                    <table-cell-radio-buttons id="@Html.IdFor(m => m.B4.COMMUN)" for="@Model.B4.COMMUN" items="Model.ImpairmentListItems" data-b4-target="COMMUN" data-action="change->b4#updateCDRSUM"></table-cell-radio-buttons>
                 </tr>
                 <tr class="divide-y divide-gray-200">
                     <td rowspan="2">
@@ -105,7 +105,7 @@
                     <td class="mt-1 p-2 text-sm text-gray-500">No significant function in the home</td>
                 </tr>
                 <tr>
-                    <table-cell-radio-buttons id="@Html.IdFor(m => m.B4.HOMEHOBB)" for="@Model.B4.HOMEHOBB" items="Model.ImpairmentListItems"></table-cell-radio-buttons>
+                    <table-cell-radio-buttons id="@Html.IdFor(m => m.B4.HOMEHOBB)" for="@Model.B4.HOMEHOBB" items="Model.ImpairmentListItems" data-b4-target="HOMEHOBB" data-action="change->b4#updateCDRSUM"></table-cell-radio-buttons>
                 </tr>
                 <tr class="divide-y divide-gray-200">
                     <td rowspan="2">
@@ -118,14 +118,14 @@
                     <td class="mt-1 p-2 text-sm text-gray-500">Requires assistance in dressing, hygiene, keeping of personal effects</td>
                     <td class="mt-1 p-2 text-sm text-gray-500">Requires much help with personal care; frequent incontinence</td>
                 </tr>
+
                 <tr>
-                    <!--// Only 4 options for Personal Care (PERSCARE) so the first two cells need to be merged //-->
                     @for (int i = 0; i < Model.ImpairmentSimpleListItems.Count; i++)
                     {
                         <td @(i == 0 ? "colspan=2" : "")>
                             <div class="relative flex items-start">
                                 <div class="flex items-center">
-                                    <input class="h-4 border-gray-400 text-indigo-600 focus:ring-indigo-600" id="B4_PERSCARE[@i]" name="B4.PERSCARE" type="radio" value="@Model.ImpairmentSimpleListItems[i].Value">
+                                    <input class="h-4 border-gray-400 text-indigo-600 focus:ring-indigo-600" id="B4_PERSCARE[@i]" name="B4.PERSCARE" type="radio" value="@Model.ImpairmentSimpleListItems[i].Value" @(Model.B4.PERSCARE != null && Model.B4.PERSCARE.ToString() == Model.ImpairmentSimpleListItems[i].Value ? "checked" : "") data-b4-target="PERSCARE" data-action="change->b4#updateCDRSUM">
                                     <label class="ml-3 block text-sm font-medium leading-6 text-gray-900" for="B4_PERSCARE[@i]">
                                         <span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800">@Model.ImpairmentSimpleListItems[i].Value</span>
                                     </label>
@@ -141,7 +141,7 @@
             <label asp-for="B4.CDRSUM"><span class="counter"></span> @Html.DisplayNameFor(m => m.B4.CDRSUM)</label>
             <div class="mt-4 sm:col-span-2 sm:mt-0">
                 <p class="text-sm text-gray-500" asp-description-for="@Model.B4.CDRSUM"></p>
-                <input asp-for="@Model.B4.CDRSUM" />
+                <input asp-for="@Model.B4.CDRSUM" data-b4-target="CDRSUM" readonly/>
                 <span class="mt-2 text-sm text-red-600" asp-validation-for="B4.CDRSUM"></span>
             </div>
         </div>
@@ -150,7 +150,8 @@
             <label asp-for="B4.CDRGLOB"><span class="counter"></span> @Html.DisplayNameFor(m => m.B4.CDRGLOB)</label>
             <div class="mt-4 sm:col-span-2 sm:mt-0">
                 <p class="text-sm text-gray-500" asp-description-for="@Model.B4.CDRGLOB"></p>
-                <radio-button-group id="@Html.IdFor(m => m.B4.CDRGLOB)" for="@Model.B4.CDRGLOB" items="Model.ImpairmentListItems"></radio-button-group>
+                <radio-button-group id="@Html.IdFor(m => m.B4.CDRGLOB)" for="@Model.B4.CDRGLOB" items="Model.ImpairmentListItems" data-b4-target="CDRGLOB" data-action="change->b4#updateCDRSUM">
+                </radio-button-group>
                 <span class="mt-2 text-sm text-red-600" asp-validation-for="B4.CDRGLOB"></span>
             </div>
         </div>
diff --git a/src/UDS.Net.Forms/TagHelpers/TableCellRadioButtonsTagHelper.cs b/src/UDS.Net.Forms/TagHelpers/TableCellRadioButtonsTagHelper.cs
index 4816116d6..a09727651 100644
--- a/src/UDS.Net.Forms/TagHelpers/TableCellRadioButtonsTagHelper.cs
+++ b/src/UDS.Net.Forms/TagHelpers/TableCellRadioButtonsTagHelper.cs
@@ -46,11 +46,14 @@ public override void Process(TagHelperContext context, TagHelperOutput output)
             if (!String.IsNullOrWhiteSpace(prefix))
                 prefix = prefix + ".";
 
-            var cellsWithName = GenerateTableCellRadioInputs(items, expression);
+            var cellsWithName = GenerateTableCellRadioInputs(items, expression, output.Attributes);
             output.PostContent.SetHtmlContent(cellsWithName);
+
+            output.Attributes.Clear();
+            output.Attributes.SetAttribute("class", "mt-4 space-y-2");
         }
 
-        private IHtmlContent GenerateTableCellRadioInputs(IEnumerable<RadioListItem> items, string name)
+        private IHtmlContent GenerateTableCellRadioInputs(IEnumerable<RadioListItem> items, string name, TagHelperAttributeList? parentAttributes = null)
         {
             if (!(items is IList<RadioListItem> itemsList))
             {
@@ -67,7 +70,7 @@ private IHtmlContent GenerateTableCellRadioInputs(IEnumerable<RadioListItem> ite
             for (var i = 0; i < itemsList.Count; i++)
             {
                 var item = itemsList[i];
-                var radio = GenerateRadioInput(item, i, name);
+                var radio = GenerateRadioInput(item, i, name, parentAttributes);
                 var label = GenerateLabel(item, i);
 
                 var cell = new TagBuilder("td");
@@ -98,7 +101,7 @@ private IHtmlContent GenerateTableCellRadioInputs(IEnumerable<RadioListItem> ite
             return radioButtonListBuilder;
         }
 
-        private TagBuilder GenerateRadioInput(RadioListItem item, int index, string name)
+        private TagBuilder GenerateRadioInput(RadioListItem item, int index, string name, TagHelperAttributeList? parentAttributes = null)
         {
             var selected = false;
             var modelValue = "";
@@ -115,6 +118,14 @@ private TagBuilder GenerateRadioInput(RadioListItem item, int index, string name
             tagBuilder.Attributes["value"] = item.Value;
             tagBuilder.Attributes["class"] = "h-4 border-gray-400 text-indigo-600 focus:ring-indigo-600 disabled:bg-slate-50 disabled:text-slate-500 disabled:border-slate-200 disabled:shadow-none";
 
+            if (parentAttributes != null)
+            {
+                foreach (var attribute in parentAttributes)
+                {
+                    tagBuilder.Attributes.Add(new KeyValuePair<string, string?>(attribute.Name, attribute.Value.ToString()));
+                }
+            }
+
             if (!String.IsNullOrWhiteSpace(name))
             {
                 tagBuilder.Attributes["name"] = name;
diff --git a/src/UDS.Net.Forms/wwwroot/js/js_controllers/b4_controller.js b/src/UDS.Net.Forms/wwwroot/js/js_controllers/b4_controller.js
new file mode 100644
index 000000000..30071a45e
--- /dev/null
+++ b/src/UDS.Net.Forms/wwwroot/js/js_controllers/b4_controller.js
@@ -0,0 +1,146 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+    static targets = ["CDRSUM", "CDRGLOB", "MEMORY", "ORIENT", "JUDGMENT", "COMMUN", "HOMEHOBB", "PERSCARE"];
+
+    updateCDRSUM() {
+        const memoryValue = this.getFloatValue("MEMORY");
+        const orientValue = this.getFloatValue("ORIENT");
+        const judgmentValue = this.getFloatValue("JUDGMENT");
+        const communValue = this.getFloatValue("COMMUN");
+        const homehobbValue = this.getFloatValue("HOMEHOBB");
+        const perscareValue = this.getFloatValue("PERSCARE");
+
+        let result = null;
+        const secondaryScores = [orientValue, judgmentValue, communValue, homehobbValue, perscareValue];
+        let sum = memoryValue + orientValue + judgmentValue + communValue + homehobbValue + perscareValue;
+
+        // set value of CDRSUM
+        this.CDRSUMTarget.value = sum;
+
+        if (memoryValue === 0.5 && secondaryScores.every(score => score === 0)) {
+            result = 0.5;
+            this.updateCRGLOB(result);
+            return;
+        }
+
+        if (memoryValue === 0.5) {
+            const countScoresGreaterThanEqual1 = secondaryScores.filter(score => score >= 1).length;
+            const result = countScoresGreaterThanEqual1 >= 3 ? 1 : 0.5;
+            this.updateCRGLOB(result);
+            return;
+        } else if (memoryValue === 0) {
+            const countScoresGreaterThanEqual05 = secondaryScores.filter(score => score >= 0.5).length;
+
+            // CDR = 0.5 if there are at least 2 scores >= 0.5
+            if (countScoresGreaterThanEqual05 >= 2) {
+                result = 0.5;
+            } else {
+                result = 0;
+            }
+        }
+
+        // When 3 categories = MEMORY, then CDR = MEMORY
+        if (memoryValue >= 0) {
+            // If Memory is 1 or greater, CDR has to be at least 0.5
+            if (memoryValue >= 1) {
+                const zeroCount = secondaryScores.filter(score => score === 0).length;
+                if (zeroCount >= 3) {
+                    result = 0.5;
+                    this.updateCRGLOB(result);
+                    return;
+                }
+            }
+            const scoresSimilarToM = secondaryScores.filter(score => score === memoryValue);
+
+            if (scoresSimilarToM.length >= 3) {
+                result = memoryValue;
+                this.updateCRGLOB(result);
+                return;
+            }
+
+            // Majority rule (greater/less than memory).
+            else {
+                const greaterThanMemoryMap = {};
+                const lessThanMemoryMap = {};
+
+                secondaryScores.forEach(score => {
+                    if (score === 0) return;
+                    if (score > memoryValue) {
+                        greaterThanMemoryMap[score] = (greaterThanMemoryMap[score] || 0) + 1;
+                    } else if (score < memoryValue) {
+                        lessThanMemoryMap[score] = (lessThanMemoryMap[score] || 0) + 1;
+                    }
+                });
+
+                const greaterThanCount = Object.values(greaterThanMemoryMap).reduce((acc, count) => acc + count, 0);
+                const lessThanCount = Object.values(lessThanMemoryMap).reduce((acc, count) => acc + count, 0);
+
+                // Case 1: 3 or more on the greater side of M, then CDR = secondary categories.
+                if (greaterThanCount >= 3 && lessThanCount != 2) {
+                    const majorityGreaterScore = Object.entries(greaterThanMemoryMap).reduce((prev, current) =>
+                        prev[1] > current[1] ? prev : current
+                    );
+                    result = parseFloat(majorityGreaterScore[0]);
+                }
+
+                // Case 2: 3 or more on the lesser side of M, then CDR = secondary categories.
+                else if (lessThanCount >= 3) {
+                    const majorityLessScore = Object.entries(lessThanMemoryMap).reduce((prev, current) =>
+                        prev[1] > current[1] ? prev : current
+                    );
+                    result = parseFloat(majorityLessScore[0]);
+                }
+
+                // Case 3: 3 on one side and 2 on the other side, then CDR = MEMORY.
+                if ((greaterThanCount === 3 && lessThanCount === 2) ||
+                    (lessThanCount === 3 && greaterThanCount === 2)) {
+                    result = memoryValue;
+                    this.updateCRGLOB(result);
+                    return;
+                }
+
+                // Tied scores cases
+                // If we have 2 tie blocks (two categories each) on both sides of M, then CDR = M.
+                if (greaterThanCount <= 2 && lessThanCount <= 2) {
+                    result = memoryValue; 
+                }
+                else if (greaterThanCount >= 3 && lessThanCount != 2) {
+                    // Case 1: Majority rule on the greater side of M.
+                    const majorityGreaterScore = Object.entries(greaterThanMemoryMap).reduce((prev, current) =>
+                        prev[1] > current[1] ? prev : current
+                    );
+                    result = parseFloat(majorityGreaterScore[0]);
+                }
+                else if (lessThanCount >= 3) {
+                    // Case 2: Majority rule on the lesser side of M.
+                    const majorityLessScore = Object.entries(lessThanMemoryMap).reduce((prev, current) =>
+                        prev[1] > current[1] ? prev : current
+                    );
+                    result = parseFloat(majorityLessScore[0]);
+                }
+                else {
+                    // If none of the above applies, default to M.
+                    result = memoryValue;
+                }
+            }
+        }
+        this.updateCRGLOB(result);
+    }
+
+    updateCRGLOB(result) {
+        this.CDRGLOBTargets.forEach((radioButton) => {
+            radioButton.checked = parseFloat(radioButton.value) === result;
+        });
+        console.log(`CDRGLOB set to: ${result}`);
+    }
+
+    getFloatValue(targetName) {
+        const targetArray = this[`${targetName}Targets`];
+        if (!targetArray || !targetArray.length) {
+            return 0;
+        }
+        const selectedRadio = targetArray.find(el => el.checked);
+        return selectedRadio ? parseFloat(selectedRadio.value) : 0;
+    }
+}
diff --git a/src/UDS.Net.Forms/wwwroot/js/stimulus_controllers.js b/src/UDS.Net.Forms/wwwroot/js/stimulus_controllers.js
index 4f1456e6f..93f8e686b 100644
--- a/src/UDS.Net.Forms/wwwroot/js/stimulus_controllers.js
+++ b/src/UDS.Net.Forms/wwwroot/js/stimulus_controllers.js
@@ -8,6 +8,7 @@ import selectendpoint_controller from "./js_controllers/selectendpoint_controlle
 import checkboxSelectAll from "./js_controllers/checkboxSelectAll_controller.js"
 import rxNormDisplayNames from "./js_controllers/rxNormDisplayNames_controller.js"
 import autocomplete from "./js_controllers/autocomplete_controller.js"
+import b4 from "./js_controllers/b4_controller.js"
 import b6 from "./js_controllers/b6_controller.js"
 
 window.Stimulus = Application.start()
@@ -21,4 +22,5 @@ Stimulus.register("selectendpoint", selectendpoint_controller)
 Stimulus.register("checkboxSelectAll", checkboxSelectAll)
 Stimulus.register("rxNormDisplayNames", rxNormDisplayNames)
 Stimulus.register("autocomplete", autocomplete)
+Stimulus.register("b4", b4)
 Stimulus.register("b6", b6)


Reviews:
user: login: ashleybot
body: @ahmedhaly7777 Nice work! I know implementing this formula was intense!

We will be autocalculating a lot of different fields, so it would be good for us to name this Stimulus controller a little more specifically to this form like `b4_controller`.

- [x] Rename stimulus controller to b4

This is another place where we should use Stimulus conventions for event handling.

- [ ] Replace `.addEventListener` with Stimulus action data attributes

And let's create more consistency and readability in the stimulus method naming

- [x] Rename `autoSelectRadioButtons` to `updateCRGLOB`

We can simplify and return a float from `getSelectedValue` since that is the only functionality where the method is used and the naming of the method can be made more immediately readable as well.

- [x] Rename `getSelectedValue` to `getFloatValue` and simplify by returning a float from the method


state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-02-21T13:52:14Z
user: login: ashleybot
body: @ahmedhaly7777 When I change PERSCARE it does not update CDRSUM. Evidence (see CDRSUM does not change as the radio buttons are changed):

![Screenshot 2025-03-28 at 3 02 37 PM](https://github.com/user-attachments/assets/e19210a1-2258-442e-86b5-0729e8dd6de2)

![Screenshot 2025-03-28 at 3 02 42 PM](https://github.com/user-attachments/assets/8ec5762c-9492-46ae-b0e2-b0a31aced645)

![Screenshot 2025-03-28 at 3 02 47 PM](https://github.com/user-attachments/assets/a40d449e-5402-4411-b67d-fb858c1015b0)

![Screenshot 2025-03-28 at 3 02 51 PM](https://github.com/user-attachments/assets/f836316b-119f-49b5-9376-57f98b66617c)

state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-28T19:04:55Z
user: login: mlan225
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T12:18:51Z
user: login: mlan225
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T12:25:59Z
user: login: mlan225
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T12:43:02Z
user: login: mlan225
body: After resolving some discrepancies with the rules text and functionality, looks like we've got all the test cases working as intended! 🙌

Looks like we can merge in main to update PR branch and get ready to merge ✅
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-22T13:07:53Z
user: login: ashleybot
body: @ahmedhaly7777 Good to merge to main! 👍 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-22T14:32:00Z

Review comments:
diff_hunk: @@ -0,0 +1,136 @@
+// wwwroot/js/js_controllers/autocalculate_controller.js
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+    static targets = ["CDRSUM", "CDRGLOB", "MEMORY", "ORIENT", "JUDGMENT", "COMMUN", "HOMEHOBB", "PERSCARE"];
+
+    updateCDRSUM() {
+        const memoryValue = this.getFloatValueFor("MEMORY");
+        const orientValue = this.getFloatValueFor("ORIENT");
+        const judgmentValue = this.getFloatValueFor("JUDGMENT");
+        const communValue = this.getFloatValueFor("COMMUN");
+        const homehobbValue = this.getFloatValueFor("HOMEHOBB");
+        const perscareValue = this.getFloatValueFor("PERSCARE");
+
+        let result = null;
+        const secondaryScores = [orientValue, judgmentValue, communValue, homehobbValue, perscareValue];
+        let sum = memoryValue + orientValue + judgmentValue + communValue + homehobbValue + perscareValue;
+
+        // set value of CDRSUM
+        this.CDRSUMTarget.value = sum;
+
+        // Handling M = 0.5 or M = 0
+        if (memoryValue === 0.5) {
+            const countScoresGreaterThanEqual1 = secondaryScores.filter(score => score >= 1).length;
+
+            if (countScoresGreaterThanEqual1 >= 3) {
+                result = 1;
+            } else {
+                result = 0.5;
+            }
+        } else if (memoryValue === 0) {
+            const countScoresGreaterThanEqual05 = secondaryScores.filter(score => score >= 0.5).length;
+
+            // CDR = 0.5 if there are at least 2 scores >= 0.5
+            if (countScoresGreaterThanEqual05 >= 2) {
+                result = 0.5;
+            } else {
+                result = 0;
+            }
+        }
+
+        // When 3 categories = MEMORY, then CDR = MEMORY
+        else if (memoryValue >= 0) {
+            const scoresSimilarToM = secondaryScores.filter(score => score === memoryValue);
+
+            if (scoresSimilarToM.length >= 3) {
+                result = memoryValue;
+            }
+
+            // Majority rule (greater/less than memory)
+            else {
+                const greaterThanMemoryMap = {};
+                const lessThanMemoryMap = {};
+
+                secondaryScores.forEach(score => {
+                    if (score > memoryValue) {
+                        greaterThanMemoryMap[score] = (greaterThanMemoryMap[score] || 0) + 1;
+                    } else if (score < memoryValue) {
+                        lessThanMemoryMap[score] = (lessThanMemoryMap[score] || 0) + 1;
+                    }
+                });
+
+                // Count total entries on each side
+                const greaterThanCount = Object.values(greaterThanMemoryMap).reduce((acc, count) => acc + count, 0);
+                const lessThanCount = Object.values(lessThanMemoryMap).reduce((acc, count) => acc + count, 0);
+
+                // Case 1: 3 or more on the greater side of M, then CDR = secondary categories
+                if (greaterThanCount >= 3) {
+                    const majorityGreaterScore = Object.entries(greaterThanMemoryMap).reduce((prev, current) =>
+                        prev[1] > current[1] ? prev : current
+                    );
+                    result = parseFloat(majorityGreaterScore[0]);
+                    console.log("case 1 met");
+                }
+                // Case 2: 3 or more on the lesser side of M, then CDR = secondary categories
+                else if (lessThanCount >= 3) {
+                    const majorityLessScore = Object.entries(lessThanMemoryMap).reduce((prev, current) =>
+                        prev[1] > current[1] ? prev : current
+                    );
+                    result = parseFloat(majorityLessScore[0]);
+                    console.log("case 2 met");
+                }
+                // Case 3: 3 on one side and 2 on the other side, then CDR = MEMORY
+                else if ((greaterThanCount === 3 && lessThanCount === 2) ||
+                    (lessThanCount === 3 && greaterThanCount === 2)) {
+                    console.log("case 3 met");
path: src/UDS.Net.Forms/wwwroot/js/js_controllers/b4_controller.js
user: login: mlan225
body: While the console logs are great for testing development, we probably don't want to have them pushed through to production. When looking back at the code, the comments you have above the conditional statements for case 1, 2 and 3 should be more than enough to assist in debugging if we were ever to come back to this file in the future 👍
created_at: 2025-04-03T12:18:51Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,136 @@
+// wwwroot/js/js_controllers/autocalculate_controller.js
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+    static targets = ["CDRSUM", "CDRGLOB", "MEMORY", "ORIENT", "JUDGMENT", "COMMUN", "HOMEHOBB", "PERSCARE"];
+
+    updateCDRSUM() {
+        const memoryValue = this.getFloatValueFor("MEMORY");
+        const orientValue = this.getFloatValueFor("ORIENT");
+        const judgmentValue = this.getFloatValueFor("JUDGMENT");
+        const communValue = this.getFloatValueFor("COMMUN");
+        const homehobbValue = this.getFloatValueFor("HOMEHOBB");
+        const perscareValue = this.getFloatValueFor("PERSCARE");
+
+        let result = null;
+        const secondaryScores = [orientValue, judgmentValue, communValue, homehobbValue, perscareValue];
+        let sum = memoryValue + orientValue + judgmentValue + communValue + homehobbValue + perscareValue;
+
+        // set value of CDRSUM
+        this.CDRSUMTarget.value = sum;
+
+        // Handling M = 0.5 or M = 0
+        if (memoryValue === 0.5) {
+            const countScoresGreaterThanEqual1 = secondaryScores.filter(score => score >= 1).length;
+
+            if (countScoresGreaterThanEqual1 >= 3) {
+                result = 1;
+            } else {
+                result = 0.5;
+            }
+        } else if (memoryValue === 0) {
+            const countScoresGreaterThanEqual05 = secondaryScores.filter(score => score >= 0.5).length;
+
+            // CDR = 0.5 if there are at least 2 scores >= 0.5
+            if (countScoresGreaterThanEqual05 >= 2) {
+                result = 0.5;
+            } else {
+                result = 0;
+            }
+        }
+
+        // When 3 categories = MEMORY, then CDR = MEMORY
+        else if (memoryValue >= 0) {
+            const scoresSimilarToM = secondaryScores.filter(score => score === memoryValue);
+
+            if (scoresSimilarToM.length >= 3) {
+                result = memoryValue;
+            }
+
+            // Majority rule (greater/less than memory)
+            else {
+                const greaterThanMemoryMap = {};
+                const lessThanMemoryMap = {};
+
+                secondaryScores.forEach(score => {
+                    if (score > memoryValue) {
+                        greaterThanMemoryMap[score] = (greaterThanMemoryMap[score] || 0) + 1;
+                    } else if (score < memoryValue) {
+                        lessThanMemoryMap[score] = (lessThanMemoryMap[score] || 0) + 1;
+                    }
+                });
+
+                // Count total entries on each side
+                const greaterThanCount = Object.values(greaterThanMemoryMap).reduce((acc, count) => acc + count, 0);
+                const lessThanCount = Object.values(lessThanMemoryMap).reduce((acc, count) => acc + count, 0);
+
+                // Case 1: 3 or more on the greater side of M, then CDR = secondary categories
+                if (greaterThanCount >= 3) {
+                    const majorityGreaterScore = Object.entries(greaterThanMemoryMap).reduce((prev, current) =>
+                        prev[1] > current[1] ? prev : current
+                    );
+                    result = parseFloat(majorityGreaterScore[0]);
+                    console.log("case 1 met");
+                }
+                // Case 2: 3 or more on the lesser side of M, then CDR = secondary categories
+                else if (lessThanCount >= 3) {
+                    const majorityLessScore = Object.entries(lessThanMemoryMap).reduce((prev, current) =>
+                        prev[1] > current[1] ? prev : current
+                    );
+                    result = parseFloat(majorityLessScore[0]);
+                    console.log("case 2 met");
+                }
+                // Case 3: 3 on one side and 2 on the other side, then CDR = MEMORY
+                else if ((greaterThanCount === 3 && lessThanCount === 2) ||
+                    (lessThanCount === 3 && greaterThanCount === 2)) {
+                    console.log("case 3 met");
+                    result = memoryValue;
+                }
+
+                // Tied scores closest to M
+                else {
+                    // For the greater side, find the closest scores to M
path: src/UDS.Net.Forms/wwwroot/js/js_controllers/b4_controller.js
user: login: mlan225
body: Just like we talked about the other day, maybe we should use the full name `Memory` when referring to the field in the comments, since having a single M character can be a bit confusing for anyone without the context of what M is supposed to be. It looks like you have memory listed in other comments, so it would be helpful to follow that idea with the rest
created_at: 2025-04-03T12:25:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,136 @@
+// wwwroot/js/js_controllers/autocalculate_controller.js
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+    static targets = ["CDRSUM", "CDRGLOB", "MEMORY", "ORIENT", "JUDGMENT", "COMMUN", "HOMEHOBB", "PERSCARE"];
+
+    updateCDRSUM() {
+        const memoryValue = this.getFloatValueFor("MEMORY");
+        const orientValue = this.getFloatValueFor("ORIENT");
+        const judgmentValue = this.getFloatValueFor("JUDGMENT");
+        const communValue = this.getFloatValueFor("COMMUN");
+        const homehobbValue = this.getFloatValueFor("HOMEHOBB");
+        const perscareValue = this.getFloatValueFor("PERSCARE");
+
+        let result = null;
+        const secondaryScores = [orientValue, judgmentValue, communValue, homehobbValue, perscareValue];
+        let sum = memoryValue + orientValue + judgmentValue + communValue + homehobbValue + perscareValue;
+
+        // set value of CDRSUM
+        this.CDRSUMTarget.value = sum;
+
+        // Handling M = 0.5 or M = 0
+        if (memoryValue === 0.5) {
+            const countScoresGreaterThanEqual1 = secondaryScores.filter(score => score >= 1).length;
+
+            if (countScoresGreaterThanEqual1 >= 3) {
+                result = 1;
+            } else {
+                result = 0.5;
+            }
+        } else if (memoryValue === 0) {
+            const countScoresGreaterThanEqual05 = secondaryScores.filter(score => score >= 0.5).length;
+
+            // CDR = 0.5 if there are at least 2 scores >= 0.5
+            if (countScoresGreaterThanEqual05 >= 2) {
+                result = 0.5;
+            } else {
+                result = 0;
+            }
+        }
+
+        // When 3 categories = MEMORY, then CDR = MEMORY
+        else if (memoryValue >= 0) {
+            const scoresSimilarToM = secondaryScores.filter(score => score === memoryValue);
+
+            if (scoresSimilarToM.length >= 3) {
+                result = memoryValue;
+            }
+
+            // Majority rule (greater/less than memory)
+            else {
+                const greaterThanMemoryMap = {};
+                const lessThanMemoryMap = {};
+
+                secondaryScores.forEach(score => {
+                    if (score > memoryValue) {
+                        greaterThanMemoryMap[score] = (greaterThanMemoryMap[score] || 0) + 1;
+                    } else if (score < memoryValue) {
+                        lessThanMemoryMap[score] = (lessThanMemoryMap[score] || 0) + 1;
+                    }
+                });
+
+                // Count total entries on each side
+                const greaterThanCount = Object.values(greaterThanMemoryMap).reduce((acc, count) => acc + count, 0);
+                const lessThanCount = Object.values(lessThanMemoryMap).reduce((acc, count) => acc + count, 0);
+
+                // Case 1: 3 or more on the greater side of M, then CDR = secondary categories
+                if (greaterThanCount >= 3) {
+                    const majorityGreaterScore = Object.entries(greaterThanMemoryMap).reduce((prev, current) =>
+                        prev[1] > current[1] ? prev : current
+                    );
+                    result = parseFloat(majorityGreaterScore[0]);
+                    console.log("case 1 met");
+                }
+                // Case 2: 3 or more on the lesser side of M, then CDR = secondary categories
+                else if (lessThanCount >= 3) {
+                    const majorityLessScore = Object.entries(lessThanMemoryMap).reduce((prev, current) =>
+                        prev[1] > current[1] ? prev : current
+                    );
+                    result = parseFloat(majorityLessScore[0]);
+                    console.log("case 2 met");
+                }
+                // Case 3: 3 on one side and 2 on the other side, then CDR = MEMORY
path: src/UDS.Net.Forms/wwwroot/js/js_controllers/b4_controller.js
user: login: mlan225
body: In addition, it would probably serve better for documentation to have a more verbose description on the case comments so we can refer to them in the future, for example:

> "Case 3: 3 on one side and 2 on the other side, then CDR = MEMORY"

could be

> "Case 3: 
3 radio values are > the Memory radio value AND 2 radio values are < the Memory radio value OR 3 radio values are < the Memory radio value AND 2 radio values are > the Memory radio value, THEN CDR radio value = Memory radio Value" 

Let me know if you would like to pair up to reword the case descriptions, since the rules are a bit tricky 

created_at: 2025-04-03T12:43:01Z
author_association: MEMBER

PR comments:
user: login: ahmedhaly7777
created_at: 2025-02-25T19:59:27Z
body: @ashleybot Ready for review!
author_association: MEMBER
user: login: ashleybot
created_at: 2025-03-05T18:48:02Z
body: @ahmedhaly7777 Could you merge main into this feature branch? I think we have the same scenario that we saw previously with rendering data attributes in the correct place. 

- [x] The data attributes need to be on the `<table-cell-radio-buttons>` element not the `<tr>`, and 
- [x] The TableCellRadioButtonsTagHelper custom tag helper needs to be updated to render the data attributes on each child radio input
- [x] The js will need to be adjusted again afterward

When main is merged into this branch you'll be able to see how the RadioButtonGroupTagHelper has been updated, and you'll be able to do the same thing in TableCellRadioButtonsTagHelper.
author_association: MEMBER
user: login: ashleybot
created_at: 2025-04-02T13:10:08Z
body: @mlan225 Can you review this?
author_association: MEMBER
user: login: mlan225
created_at: 2025-04-03T12:11:16Z
body: @ahmedhaly7777 wanted to confirm this functionality with you about one of the `Initial test cases`

> If M = 1 or greater, CDR cannot be 0; in this circumstance, CDR = 0.5 when the majority of secondary categories are 0.

With this test case in mind, should the radio button value for Global CDR still be valid when set to 0? Should it have been set to 0.5 if a majority of categories other than Memory are set to 0? 

![image](https://github.com/user-attachments/assets/83b87453-0c7b-40cc-ac08-00078eab0f0b)


author_association: MEMBER
user: login: mlan225
created_at: 2025-04-03T12:48:24Z
body: ## Marvin review checklist

### Initial Test Cases:
- [x] 3 categories are 1 or higher, m= 0.5., then CDR = 1 
- [x] if M = 0.5, CDR has to be either 1 or 0.5 
- [x] If M = 1 or greater, CDR cannot be 0; in this circumstance, CDR = 0.5 when the majority of secondary categories are 0.
- [x] CDR = M if at least three secondary categories are given the same score as M
- [x] If M = 0, then CDR = 0 (no other categories chosen) 
 
### Majority Rule Test Cases:
- [x] case1: 3 or more categories on greater (right )side of M, CDR = categories  
- [x] case2: 3 or more categories on lesser (left) side of M, CDR = categories  
- [x] case3: 3 or more categories on a side, 2 other categories on the opposite side. CDR = M

### Tie Rule Test Cases:
- [x] When there is a tie and on both sides of M, CDR = the the side closest to M (e.g., if two secondary categories = 3, and another two secondary categories = 0.5, then CDR = M)
- [x] When only one or two secondary categories are given the same score as M, CDR = M as long as no more than two secondary categories are on either side of M

### Ash review changes requested:
- [x]  Rename stimulus controller to b4
- [x] Replace .addEventListener with Stimulus action data attributes
- [x]  Rename autoSelectRadioButtons to updateCRGLOB
- [x] Rename getSelectedValue to getFloatValue and simplify by returning a float from the method

### Ash review changes request:
- [x] The data attributes need to be on the `<table-cell-radio-buttons>` element not the `<tr>`
- [x]  The TableCellRadioButtonsTagHelper custom tag helper needs to be updated to render the data attributes on each child radio input
- [x]  The js will need to be adjusted again afterward

### Ash review change request:

- [x] When I change PERSCARE it does not update CDRSUM. Evidence (see CDRSUM does not change as the radio buttons are changed)

#### Personal care extra radio
- [x] Remove the extra 0.5 option on personal care
author_association: MEMBER
user: login: mlan225
created_at: 2025-04-04T12:14:50Z
body: @ahmedhaly7777 , got a list with some questions on some test case results, test case instructions, and a small coding adjustment. Just let me know if you want to meet up and go over these in some more detail 👍 

## Marvin review comments
> Refer to the "Marvin review checklist" above to know what questions relate to which comment.

### Initial test case checbox 3:
- [x] posted about this further up. Am I getting the intended value here? 
  - Confirmed with Ahmed that this was a bug

### Initial test case checkbox 5:
- [x] Just making sure the wording on the rules and the expectations are aligning. For the ruling, when you say "none are selected" do you mean the value is 0? Since all radios in the CDR section are required for finalize?
  - The wording was incorrect in the text, it is correct to say that "none are selected" should be "the rest are 0"

### Majority rule test case checkbox 3:
- [x] Wouldn't checkox 3 satisfy any of the 2 previous conditions and force CDR = categories? For example, in this case? 
- ![image](https://github.com/user-attachments/assets/55ece54c-1526-47c8-8481-873221c0cb0a)

- Spoke with Ahmed about this
  - Majority rule test case 1: The 3 on the greater side are the same, but the 2 on the other side are not the same
  - Majority rule test case 2: The 3 on the lesser side are the same, but the 2 on the other side are not the same
  - Ahmed said he applied the fix to this, test case 3 was a bug and should have equaled M here.

### Tie rule test case checkbox 1:
- [x] The rules state that there is a tie on BOTH sides of M, but in the example you are saying M and another category are 3, wouldn't this eliminate the ability to have both sides of M if M = 3 and 3 is the farthest to the right you can go? 
  - The initial example written was incorrect and adjusted, the new example should be:
    > When there is a tie and on both sides of M, CDR = the the side closest to M (e.g., if two secondary categories = 3, and another two secondary categories = 0.5, then CDR = 2)
    - He is currently working on this.

### Tie rule test case checkbox 2:
- [x] Here I have this result, is  the correct CDR given the steps below?
  - one or 2 categories with the same value as M
  - No more than 2 categories on either side of M 
  - My CDR = 0
- ![image](https://github.com/user-attachments/assets/69660fbe-8abd-4560-b29e-2661819c6734)

### Ash review changes:
- [x] `GetSelectedValue` was changed to `GetFloatValueFor`. It seems Ash was wanting it to be changed to `GetFloatValue`. 
  - The wording to remove `For` makes sense because the thing we are getting a float value for is described by having the argument for the method

### Personal Care extra radio
- [x] Noticed that Personal Care category does not have a 0.5 option. In the PR, there is no text for personal care, but there is a radio button 
author_association: MEMBER
user: login: ahmedhaly7777
created_at: 2025-04-16T17:39:34Z
body: 1. If M = 1 or greater, CDR cannot be 0.
2.  If M = 0, then CDR = 0 (all secondary categories are 0).
3.  If 3 or more categories on a side, 2 other categories on the opposite side, then CDR = M.
4. `GetSelectedValue` function name changed
5.  `PERSCARE `category does not have a 0.5 option.
6. Tie test case 1 & 2.


author_association: MEMBER
user: login: ashleybot
created_at: 2025-04-17T19:03:27Z
body: @ahmedhaly7777 PERSCARE is correctly rendered if value already persisted - thanks!

@mlan225 can you review the rules you outlined in test cases? I think you have a better understanding of how it should calculate.
author_association: MEMBER
user: login: mlan225
created_at: 2025-04-21T17:45:44Z
body: The new changes look to be implimented, double checking the previous checklist and comparing some results with NACC value calculator before approving PR 
author_association: MEMBER
user: login: mlan225
created_at: 2025-04-21T18:10:10Z
body: The rules seem to check out, there were two things I wanted to check over with ya @ahmedhaly7777 before we get this approved 👍

These rules:
- [x] if M = 0.5, CDR has to be either 1 or 0.5
  - Ahmed will be fixing this. This rule should override all other, CDR MUST be 1 or 0.5 if M = 0.5

- [x] When there is a tie and on both sides of M, CDR = the the side closest to M (e.g., if two secondary categories = 3, and another two secondary categories = 0.5, then CDR = 2)
  - The rule should state:
  > if two secondary categories = 3, and another two secondary categories = 0.5, then CDR = M


author_association: MEMBER