Skip to content

Commit febfef8

Browse files
Merge pull request #728 from akvo/feature/727-feedback-automatic-segmentation-ui
[#727] feat: implement manual range authority and refined segmentation UI
2 parents ced1ec8 + 19a925e commit febfef8

File tree

10 files changed

+486
-65
lines changed

10 files changed

+486
-65
lines changed

GEMINI.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ Income Driver Calculator (IDC) is a web application designed to help companies t
1616
- **CI/CD**: Automated deployment to test cluster on push to `main`.
1717

1818
## Recent Changes
19+
- **Segmentation UI Refinement (Issue #727)**:
20+
- Implemented "Manual Range Authority" model: user-defined Min and Max values are strictly respected during recalculation and final segment generation.
21+
* Updated backend Pydantic models to support explicit `min` and `max` fields in segmentation requests.
22+
* Enhanced `recalculate_numerical_segments` and `process_confirmed_segmentation` to prioritize manual boundaries over automatic cascading logic.
23+
* Decoupled "Segment range" display from live form fields, ensuring it remains static/calculated until an explicitly triggered update.
24+
* Prioritized manual segment addition by swapping button order in `DataUploadSegmentForm.js`.
25+
* Implemented full bi-directional cascading helpers for numerical range editing (Segment N Min updates Segment N-1 Max, and Segment N Max updates Segment N+1 Min) using a robust 0.01 precision offset.
26+
* Refined "Adjust" button logic to send both boundaries simultaneously, preventing range reversion bugs.
27+
* Resolved a "Save Case" React crash by implementing robust error stringification for Pydantic/FastAPI validation errors and sanitizing segment payloads to exclude redundant `min`/`max` fields for categorical data.
28+
* Fixed missing segment answer values in Step 2 by implementing inclusive lower bounds (`>=`) for the starting segment of each variable, ensuring all farmers are captured.
29+
* Fixed `UnboundLocalError` in `process_confirmed_segmentation` by resolving a scoping bug in numerical segment processing.
30+
* Enforced manual, unique naming for numerical segments with real-time frontend validation and empty-by-default inputs.
31+
* Simplified Step 2 layout: adjusted grid spans (12/12), added "Min/Max" input prefixes, and made the "Segment range" display optional via toggle.
32+
* Resolved frontend lint warnings by replacing `undefined` checks with `typeof` checks.
1933
- **Authentication Improvements (PR #705)**:
2034
- Fixed infinite redirect loop for unauthenticated users accessing protected routes.
2135
- Implemented "Redirect After Login" feature to return users to their originally requested page.
@@ -223,7 +237,15 @@ Income Driver Calculator (IDC) is a web application designed to help companies t
223237
- Consolidated workflow rules for Git, PRs, and activity logging.
224238
- **Analytics**: Migrated from Piwik Pro to Matomo (Issue #idc-analytics) with environment-based site selection.
225239
- **Time Analysis**: Enhanced `analyze_time.py` with issue grouping and idle time analysis; updated `/check_time` workflow to be interactive, proactively prompting for analysis criteria.
226-
- **Documentation**: Created `INCOME_CALCULATION.md` and reorganized `docs/` folder.
240+
- **Segmentation UI Refinement (Issue #727)**:
241+
- Defined UAC and TAC for improving farmer visibility and manual adjustment flow.
242+
- Implemented colorized IDC Green tag style (`.farmer-count-tag`) for farmer counts for better visibility.
243+
- Standardized tag sizes for both farmer count and segment range (`.segment-info-tag`) for visual balance.
244+
- Implemented segment name initialization: numerical segments start with blank names and a `"Please specify the segment name"` placeholder.
245+
- Reorganized documentation into `docs/segmentation_ui_refinement` and provided technical context in `docs/segmentation_fix`.
246+
- Refactored Numerical Range UI using Ant Design `Space` and `InputNumber`, centering Min/Max labels below inputs and adding an IDC Green "Adjust" button.
247+
- Prioritized the manual flow by making the "Add segment manually" button primary and moving it before the generator button.
248+
- **Documentation**: Created `INCOME_CALCULATION.md` and reorganized `docs/` folder.
227249

228250
## Codebase Structure
229251
- `backend/`: FastAPI application code.

backend/models/case_import.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ class SegmentDefinition(BaseModel):
4141
value: Union[int, float, str]
4242
number_of_farmers: Optional[int] = 0
4343
is_manual: bool = False
44+
min: Optional[float] = None
45+
max: Optional[float] = None
4446
segmentation_variable: Optional[str] = None
4547
variable_type: Optional[Literal["categorical", "numerical"]] = None
4648

@@ -78,6 +80,8 @@ class SegmentationPreviewResponse(BaseModel):
7880
class SegmentValueInput(BaseModel):
7981
index: int = Field(..., ge=1)
8082
value: float | str
83+
min: Optional[float] = None
84+
max: Optional[float] = None
8185
segmentation_variable: Optional[str] = None
8286
variable_type: Optional[Literal["categorical", "numerical"]] = None
8387

backend/utils/case_import_process_confirmed_segmentation.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,11 @@ def process_confirmed_segmentation(
145145
pd.DataFrame()
146146
) # Empty DF for manual segments, will skip aggregation
147147
elif seg_type == "numerical" and seg_is_numeric:
148-
# Find the previous segment of the SAME variable for lower bound
148+
# Use explicit min/max if provided (Manual Authority)
149+
explicit_min = seg.min
150+
explicit_max = seg.max if seg.max is not None else float(seg.value)
151+
152+
# Find previous segment of same variable for lower bound logic
149153
prev_seg_same_var = next(
150154
(
151155
s
@@ -158,15 +162,29 @@ def process_confirmed_segmentation(
158162
),
159163
None,
160164
)
161-
lower = (
162-
float(prev_seg_same_var.value) if prev_seg_same_var else None
163-
)
164-
upper = float(seg.value)
165+
166+
if explicit_min is not None:
167+
lower = float(explicit_min)
168+
else:
169+
# Fallback to previous segment logic
170+
lower = (
171+
float(prev_seg_same_var.value)
172+
if prev_seg_same_var
173+
else None
174+
)
175+
176+
upper = float(explicit_max)
165177

166178
if lower is None:
167179
mask = seg_series <= upper
168180
else:
169-
mask = (seg_series > lower) & (seg_series <= upper)
181+
# Standard (Lower, Upper] interval for subsequent segments.
182+
# Use [Lower, Upper] for the very first segment of a variable
183+
# to ensure the minimum value is included.
184+
if prev_seg_same_var is None:
185+
mask = (seg_series >= lower) & (seg_series <= upper)
186+
else:
187+
mask = (seg_series > lower) & (seg_series <= upper)
170188

171189
seg_df = data_df[mask]
172190
number_of_farmers = int(len(seg_df))

backend/utils/case_import_processing.py

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ def calculate_numerical_segments_from_cuts(
210210
segments.append(
211211
{
212212
"index": idx,
213-
"name": column,
213+
"name": "", # Empty by default for numerical segments
214214
"operator": "<=",
215215
"value": float(cut),
216216
"number_of_farmers": int(count),
@@ -301,26 +301,47 @@ def recalculate_numerical_segments(
301301
None,
302302
)
303303

304-
lower_bound = (
305-
float(prev_seg_same_var["value"]) if prev_seg_same_var else None
306-
)
307-
upper_bound = float(seg["value"])
304+
# Use explicit min/max if provided (Manual Authority)
305+
explicit_min = seg.get("min")
306+
explicit_max = seg.get("max") or float(seg.get("value"))
307+
308+
if explicit_min is not None:
309+
lower_bound = float(explicit_min)
310+
# When using manual min, we want to respect it EXACTLY.
311+
# However, to avoid overlapping counts
312+
# (if user sets Min = Prev Max),
313+
# we must decide on the interval. Standard is (Min, Max].
314+
# If we use > lower_bound, then a value of exactly 1.0
315+
# (with Min=1.0) would be excluded.
316+
# This is consistent with (1.0, 2.0].
317+
seg_min = lower_bound
318+
elif prev_seg_same_var:
319+
lower_bound = float(prev_seg_same_var["value"])
320+
seg_min = lower_bound + 0.01
321+
else:
322+
lower_bound = None
323+
# min is min of data or upper_bound (to avoid inversion)
324+
data_min = np.min(series) if series.size > 0 else 0
325+
seg_min = min(data_min, explicit_max)
326+
327+
upper_bound = float(explicit_max)
308328

309329
# Calculate count
310330
if seg_type == "numerical" and is_numeric:
311331
if lower_bound is None:
312332
count = np.sum(series <= upper_bound)
313-
# min is min of data or upper_bound (to avoid inversion)
314-
data_min = np.min(series) if series.size > 0 else 0
315-
seg_min = min(data_min, upper_bound)
316333
else:
317-
count = np.sum(
318-
(series > lower_bound) & (series <= upper_bound)
319-
)
320-
if is_integer_data:
321-
seg_min = np.floor(lower_bound) + 1
334+
# Standard (Lower, Upper] interval for subsequent segments.
335+
# Use [Lower, Upper] for the very first segment of a variable
336+
# to ensure the minimum value is included.
337+
if prev_seg_same_var is None:
338+
count = np.sum(
339+
(series >= lower_bound) & (series <= upper_bound)
340+
)
322341
else:
323-
seg_min = lower_bound + 0.01
342+
count = np.sum(
343+
(series > lower_bound) & (series <= upper_bound)
344+
)
324345

325346
seg_max = np.floor(upper_bound) if is_integer_data else upper_bound
326347
if is_integer_data:
@@ -329,7 +350,7 @@ def recalculate_numerical_segments(
329350
processed_segments.append(
330351
{
331352
"index": seg["index"],
332-
"name": seg_var,
353+
"name": seg.get("name") or "",
333354
"operator": "<=",
334355
"value": float(upper_bound),
335356
"number_of_farmers": int(count),
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Segmentation UI and Flow Improvements
2+
3+
This task aims to improve the visibility of farmer counts per segment and refine the manual segment adjustment process to make it more intuitive and prioritized.
4+
5+
## User Review Required
6+
7+
> [!IMPORTANT]
8+
> **Cascading Exclusivity**: As requested in the feedback, adjusting the **Maximum** of Segment N will automatically update the **Minimum** of Segment N+1. This ensures segments remain exclusive without manual entry for both sides.
9+
10+
> [!NOTE]
11+
> **Scope Clarification**: This plan focuses strictly on the UI refinement (visibility, layout, and cascading boundaries). The separate technical issue of "Inverted Ranges" (Auto-Sorting) is documented in [docs/segmentation_fix/implementation_plan.md](file:///Users/galihpratama/Sites/IDH-IDC/docs/segmentation_fix/implementation_plan.md) and is considered out of scope for *this* UI task unless explicitly combined.
12+
13+
> [!NOTE]
14+
> **Manual Preference**: I will move the "Add segment manually" button to be more prominent, suggesting it as the primary way to define segments if automatic generation isn't used.
15+
16+
## User Acceptance Criteria (UAC)
17+
18+
1. **Enhanced Farmer Visibility**: [x]
19+
* Each segment card displays the farmer count in a prominent box.
20+
* **Style**: IDC Green background (`#00605A`), white text, bold font.
21+
* **Label**: `Number of Farmers: [Count]`.
22+
2. **Segment Name Initialization**: [x]
23+
* New segments have blank names by default for numerical variables.
24+
* Placeholder: `"Please specify the segment name"`.
25+
3. **Numerical Range Configuration UI**: [x]
26+
* Minimum and Maximum thresholds are displayed in two clearly labeled white boxes.
27+
* An **"Adjust"** button (IDC Green) is placed next to these boxes.
28+
* The thresholds are editable upon clicking "Adjust" or directly (as per existing logic, but with improved styling).
29+
4. **Bi-directional Segment Exclusivity**: [x]
30+
* Modifying the **Maximum** of Segment N automatically updates the **Minimum** of Segment N+1.
31+
* Modifying the **Minimum** of Segment N+1 automatically updates the **Maximum** of Segment N.
32+
* This ensures segments remain exclusive and continuous regardless of which side is adjusted.
33+
5. **Prioritized Manual Flow**: [x]
34+
* The option to adjust segments manually is presented as the preferred/first method.
35+
6. **Recalculation Trigger**: [x]
36+
* Inputting values should not auto-trigger recalculation until "Adjust" is clicked.
37+
7. **Static Calculated Range Display**: [x]
38+
* The "Segment range: X - Y" label should show the *calculated* range from the backend.
39+
* It should NOT update when the user is typing into the Min or Max boxes.
40+
* It should only update when the "Adjust" operation is completed and new backend data is received.
41+
42+
## Numerical Range Behavior: Manual Authority
43+
44+
To ensure the UI respects user intent exactly as discussed, we will adopt the **Manual Authority** model:
45+
46+
1. **Ground Truth**: The values currently visible in the **Min** and **Max** input boxes are the only values used to filter data for a segment.
47+
2. **Recalculation**: When "Adjust" is clicked, the backend will count farmers where `Min < Value <= Max`.
48+
3. **Full Bi-directional Cascading**:
49+
* **Backward**: Adjusting Segment N **Min** to `V` updates Segment N-1 **Max** to `V - offset`.
50+
* **Forward**: Adjusting Segment N **Max** to `V` updates Segment N+1 **Min** to `V + offset`.
51+
* *Note*: `offset` is constant at `0.01` for all numerical data types to ensure maximum mathematical precision and prevent data gaps.
52+
4. **Payload Synchronization**: Both `min` and `max` fields must be explicitly synchronized in the API payload to prevent stale values from overriding the new `value` (reversion bug).
53+
5. **No Reversion**: The backend must accept the provided `Min` and `Max` and return them as-is, rather than recalculating the `Min` based on a previous segment.
54+
55+
## Bug Fixes
56+
57+
### Answer Value Generation Fix
58+
- [x] **Inclusive Lower Bound**: Modified `recalculate_numerical_segments` and `process_confirmed_segmentation` to use `>=` for the first segment of each variable. This ensures the minimum value (e.g., 0) is included and answer values are generated.
59+
60+
### Save Case React Crash Fix
61+
- [ ] **Error Detail Stringification**: Update `CaseSettings.js` to handle `detail` objects (422 errors) from FastAPI, preventing React from crashing when an object is rendered as a child.
62+
- [ ] **Payload Sanitization**: Ensure `min` and `max` are only sent as numbers for numerical segments. For categorical segments, these should be `null` to avoid Pydantic validation errors (422).
63+
64+
### UnboundLocalError Fix
65+
- [x] **Scoping Fix**: Move `prev_seg_same_var` lookup outside the `if/else` block in `process_confirmed_segmentation.py` to ensure it is always defined before being referenced.
66+
67+
## Technical Acceptance Criteria (TAC)
68+
69+
1. **Frontend Component Updates**:
70+
* Modify `renderSegmentCard` in [DataUploadSegmentForm.js](file:///Users/galihpratama/Sites/IDH-IDC/frontend/src/pages/cases/components/DataUploadSegmentForm.js).
71+
* Implement the horizontal layout for thresholds: Range Boxes + Adjust Button.
72+
* Update [SegmentConfigurationForm.js](file:///Users/galihpratama/Sites/IDH-IDC/frontend/src/pages/cases/components/SegmentConfigurationForm.js) to handle both `min` and `max` cascading range updates in `handleOnChangeFieldValue`.
73+
2. **Styling**:
74+
* Define new utility classes in `steps.scss` for the "Threshold Box" styles.
75+
* Apply IDC branding colors: `$primary-color` (`#00605A`) for backgrounds and buttons.
76+
3. **Data Logic**:
77+
* Ensure the `segments` array in the form state correctly initializes `name` to `null` or `""` for numerical variables.
78+
* Implement boundary checks to prevent overlapping logic in the frontend before sending to the backend for recalculation.
79+
4. **Verification**:
80+
* Verify cascading logic for at least 3 segments.
81+
* Verify farmer count visibility across different screen sizes.
82+
83+
## Estimation
84+
85+
| Component | Task | Effort |
86+
| :--- | :--- | :--- |
87+
| **Frontend UI** | Refactoring `renderSegmentCard` layout and implementing new boxes. | 4h |
88+
| **Logic** | Implementing cascading range updates and name initialization. | 2h |
89+
| **Styling** | SCSS updates for IDC branding and alignment. | 2h |
90+
| **Total** | | **8h (~1 Story Point)** |
91+
92+
## Proposed Changes
93+
94+
### [Frontend]
95+
96+
#### [MODIFY] [DataUploadSegmentForm.js](file:///Users/galihpratama/Sites/IDH-IDC/frontend/src/pages/cases/components/DataUploadSegmentForm.js)
97+
- Update `renderSegmentCard` to include Min/Max threshold boxes with "Adjust" button.
98+
99+
#### [MODIFY] [SegmentConfigurationForm.js](file:///Users/galihpratama/Sites/IDH-IDC/frontend/src/pages/cases/components/SegmentConfigurationForm.js)
100+
- Update `handleOnChangeFieldValue` to implement bi-directional cascading adjustment logic (Min updates previous Max).
101+
102+
#### [MODIFY] [steps.scss](file:///Users/galihpratama/Sites/IDH-IDC/frontend/src/pages/cases/styles/steps.scss)
103+
- Add styles for `.threshold-box-container` and `.threshold-adjust-btn`.
104+
105+
## Verification Plan
106+
107+
### Automated Tests
108+
- Run existing segmentation tests to ensure no regressions:
109+
`./dc.sh test backend/tests/test_unit_segmentation_strategy.py`
110+
111+
### Manual Verification
112+
1. Open segment configuration in Step 1.
113+
2. Select a numerical variable and set 3 segments.
114+
3. Verify the "Number of Farmers" is clearly visible in green.
115+
4. Adjust the Maximum of Segment 1.
116+
5. Verify Segment 2's Minimum updates automatically.
117+
6. Verify segment names are blank with the correct placeholder.

frontend/src/pages/cases/components/CaseSettings.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -517,18 +517,22 @@ const CaseSettings = ({ open = false, handleCancel = () => {} }) => {
517517
const findSegment = data.segments.find(
518518
(s) => s.name === seg.name
519519
);
520+
const sVarType =
521+
seg.variable_type || values.data_upload_variable_type;
522+
const isNumerical = sVarType === "numerical";
520523
return {
521524
id: findSegment ? findSegment.id : null,
522525
index: idx,
523526
name: seg.name,
524527
value: seg.value || 0,
528+
min: isNumerical ? seg.min : null,
529+
max: isNumerical ? seg.value : null,
525530
number_of_farmers: seg.number_of_farmers || 0,
526531
is_manual: !!seg.is_manual,
527532
segmentation_variable:
528533
seg.segmentation_variable ||
529534
values.data_upload_segmentation_variable,
530-
variable_type:
531-
seg.variable_type || values.data_upload_variable_type,
535+
variable_type: sVarType,
532536
};
533537
}),
534538
};
@@ -560,12 +564,19 @@ const CaseSettings = ({ open = false, handleCancel = () => {} }) => {
560564
})
561565
.catch((e) => {
562566
console.error(e);
563-
const detail =
564-
e?.response?.data?.detail ||
565-
"Failed to generate segment values from import data.";
567+
const detail = e?.response?.data?.detail;
568+
const errorMessage =
569+
typeof detail === "string"
570+
? detail
571+
: Array.isArray(detail)
572+
? detail.map((d) => d.msg || JSON.stringify(d)).join(", ")
573+
: typeof detail === "object" && detail !== null
574+
? JSON.stringify(detail)
575+
: "Failed to generate segment values from import data.";
576+
566577
messageApi.open({
567578
type: "error",
568-
content: detail,
579+
content: errorMessage,
569580
duration: 5,
570581
});
571582
});

0 commit comments

Comments
 (0)