feat(#10676): store geolocation when submitting contact forms#11136
feat(#10676): store geolocation when submitting contact forms#11136cliftonmcintosh wants to merge 92 commits into
Conversation
cc8b8f0 to
69f2ec3
Compare
4a93b1a to
20b0fbc
Compare
| geolocation.capture = Capturar ubicación GPS | ||
| geolocation.failure = Fallido | ||
| geolocation.permission.denied = El acceso a la ubicación está desactivado. Verifique la configuración de su dispositivo para habilitarlo. | ||
| geolocation.progress = Progreso | ||
| geolocation.result.label = Resultado | ||
| geolocation.retry = Reintentar | ||
| geolocation.skip = Continuar sin seguimiento de ubicación | ||
| geolocation.success = Exitoso | ||
| geolocation.unavailable = El GPS no está disponible en este dispositivo. |
There was a problem hiding this comment.
I added Spanish, French, Nepali and Swahili translations to pass the linter. I did this with the help of Claude. These should be verified.
|
@dianabarsan Would you be available to review this PR? If not, would you be able to suggest a reviewer? |
|
I'll move this back into draft state so that we can make updates based on the conversation from today. |
122b3d0 to
0209a61
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tart GPS watching when widget is present Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lity detection Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-location options Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…resent Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…et save pipeline Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…, and sw Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When geolocation capture succeeds, the geo_context field value (from a companion .or-appearance-geolocation-context input in the same form group) is now attached to the geolocation_log entry. Context is omitted when capture fails or the field is absent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ext question Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…method Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… geolocation service Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…e capture Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tants in geolocation widget specs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tants in geolocation widget unit spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ing to CouchDB geo_capture is widget-internal state (captured/skipped/kept/denied/unavailable) used for Enketo validation gating. It is never read from CouchDB for any functional purpose, so there is no reason to persist it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…GPS acquires position When complete() was called with no GPS result yet, it created a fresh deferred and orphaned the original currentPromise, leaving the widget's .then() callback permanently pending. Fix by forwarding the new deferred's result to the original promise so the widget receives the outcome regardless of when form save fires. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…et and e2e spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…d in a report form Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… paths call geolocationService.init() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… a contact Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nfirmation
Remove the "Press here to capture" button. Selecting Home or Other now
immediately starts the GPS capture process in both the create flow and
the edit flow when no prior location exists. Contacts being edited with
no prior location see an informational message above the context options.
After selection, a confirmation label ("GPS Location at: Home/Other")
replaces the context options above the progress bar so the user can see
their choice throughout capture.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ate flow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ng capture new in edit mode Selecting "Capture new location" intentionally clears the field value so Enketo blocks submission until GPS capture completes. Enketo validates asynchronously via Promise, so the invalid-required class is added after the synchronous trigger returns. A deferred removeClass ensures the validation indicator only appears if the user actually tries to submit, not the moment they select the radio. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
This is ready for re-review. Screen recordings and sample documents showing what the data look like for different scenarios are available in the "Recordings and Data" section of the PR description. Changes include:
|
dianabarsan
left a comment
There was a problem hiding this comment.
Thanks for the updates!
Due to time constraints, I didn't end up doing a code review, but a functionality review and found some inconsistencies. I left comments inline with what I found. Please let me know if anything is unclear.
|
|
||
| await selectHomeContext(); | ||
|
|
||
| expect(await $(SELECTORS.CONTEXT_OPTIONS).isDisplayed()).to.be.false; |
There was a problem hiding this comment.
So what would happen if the user wants to change their mind about the context? if they missclicked?
There was a problem hiding this comment.
This follows the design that @kunwarLB provided in #10676 (comment). I have started a follow-up discussion at #10676 (comment). I have also made an update that adds a "Change selection" button/link and requested feedback from Lal. You can see an example of that change in a screen recording in #10676 (comment)
| require('enketo-core/src/js/plugins'); | ||
|
|
||
| const WEAK_SIGNAL_CODES = new Set([2, 3, -2]); | ||
|
|
There was a problem hiding this comment.
Not sure if this was the intended behavior, but what I am seeing is that clicking submit without getting successful geolocation is possible without clicking any of the other options (the checkbox "continue without geolocation"). I had set the field as required in my forms.
I think that, as it is now, that checkbox is pointless and can be removed. if we want to keep it, it should serve a purpose.
There was a problem hiding this comment.
I cannot reproduce the bug you describe. Here are screen recordings of me trying to submit without clicking the checkbox and the "Continue without location" button.
In the household create flow
Screen.Recording.2026-06-30.at.10.32.17.mov
In the household edit flow
Screen.Recording.2026-06-30.at.10.34.51.mov
Here is what my forms look like
Did I do them differently than you did them? Did I do them correctly?
clinic-create.xml
Instance fields
...
<notes/>
<contact/>
<geolocation/>
<geo_capture/>
<meta tag="hidden">
...Bindings
...
<bind nodeset="/data/clinic/notes" type="string"/>
<bind nodeset="/data/clinic/contact" type="string" calculate="..."/>
<bind nodeset="/data/clinic/geolocation" type="string" calculate="concat(../../inputs/meta/location/lat, concat(' ', ../../inputs/meta/location/long))"/>
<bind nodeset="/data/clinic/geo_capture" type="string" required="true()"/>
<bind nodeset="/data/clinic/meta/created_by" type="string" calculate="..."/>
...Body
...
<group appearance="field-list" ref="/data/clinic">
<input ref="/data/clinic/geo_capture" appearance="geolocation-capture">
<label>Capture GPS Location</label>
</input>
</group>
<group appearance="field-list" ref="/data/clinic">
...clinic-edit.xml
Instance fields
...
<external_id/>
<notes/>
<geolocation/>
<geo_capture/>
<meta tag="hidden">
...Bindings
...
<bind nodeset="/data/clinic/notes" type="string"/>
<bind nodeset="/data/clinic/geolocation" type="string" calculate="concat(../../inputs/meta/location/lat, concat(' ', ../../inputs/meta/location/long))"/>
<bind nodeset="/data/clinic/geo_capture" type="string" required="true()"/>
<bind nodeset="/data/clinic/meta/created_by" type="string" readonly="true()"/>
...Body
...
<input ref="/data/clinic/notes" appearance="multiline">
<label ref="jr:itext('/data/clinic/notes:label')"/>
</input>
<input ref="/data/clinic/geo_capture" appearance="geolocation-capture">
<label>Capture GPS Location</label>
</input>
<group appearance="hidden" ref="/data/clinic/meta"/>
...| require('enketo-core/src/js/plugins'); | ||
|
|
||
| const WEAK_SIGNAL_CODES = new Set([2, 3, -2]); | ||
|
|
There was a problem hiding this comment.
It also seems that the "capture new location" checkbox doesn't work as expected. I get a recorded home geolocation when submitting the form even if I didn't check the "i am home checkbox".
There was a problem hiding this comment.
I cannot reproduce this.
When I am in the edit flow and select "Capture new location", I cannot submit without checking the "I am currently at this household..." checkbox.
Is there a flow or step that you might be taking that I am not taking?
To try to reproduce, I
- created a new household and captured a home location for it
- started editing that household
- selected "Capture new location"
- tried to click "Submit" without checking the "I am currently at this household..." checkbox
- received a validation error
I was only able to proceed after checking the "I am currently at this household..." checkbox. My forms were set up in the same way that I describe in #11136 (comment).
Here is a screen recording.
Screen.Recording.2026-06-30.at.10.55.54.mov
| require('enketo-core/src/js/plugins'); | ||
|
|
||
| const WEAK_SIGNAL_CODES = new Set([2, 3, -2]); | ||
|
|
There was a problem hiding this comment.
I had a household that I registered home geolocation for. I used the android app to disable geolocation permissions. Then I edited the household and the widget showed the expected message, that i disabled permissions as expected, but on submit it set my geolocation field as empty string. That was not expected. On failed geo due to permissions, old geolocations should not be overwrittten.
There was a problem hiding this comment.
I was not able to reproduce this. Here are the steps I took.
- Created a new household with a home location captured
- Disabled location in my browser
- Edited the household
- Tried to "Capture new location"
- Received the warning that capture had failed
- Selected continue without capture
- Submit the edited household
- Checked the database
The geolocation was still present and was not overwritten.
Screen recording
Screen.Recording.2026-06-30.at.11.06.00.mov
Household record after editing
Here is the record for the household after the sequence you see in that screen recording.
{
"_id": "019f1947-66ad-7559-9a2c-c0adab4b452a",
"_rev": "4-396e1938f061185065967bced55814c2",
"parent": {
"_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3",
"parent": {
"_id": "0490ad32-f955-4171-9d95-e951db7a90ec"
}
},
"type": "clinic",
"contact": {
"_id": "019f1947-66ad-7559-9a2c-c97ec22cc535",
"parent": {
"_id": "019f1947-66ad-7559-9a2c-c0adab4b452a",
"parent": {
"_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3",
"parent": {
"_id": "0490ad32-f955-4171-9d95-e951db7a90ec"
}
}
}
},
"is_name_generated": "false",
"generated_name": "Geo Maintain Existing After Update Failure's Household",
"name": "Geo Maintain Existing After Update Failure",
"external_id": "",
"notes": "",
"geolocation": {
"latitude": 43.067544125892496,
"longitude": -89.45010267927833,
"altitude": 0,
"accuracy": 35,
"altitudeAccuracy": null,
"heading": null,
"speed": null
},
"meta": {
"created_by": "noble.hirthe",
"created_by_person_uuid": "c22ef015-4233-4af4-9636-ac2fbffb4a15",
"created_by_place_uuid": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3",
"last_edited_by": "noble.hirthe",
"last_edited_by_person_uuid": "c22ef015-4233-4af4-9636-ac2fbffb4a15",
"last_edited_by_place_uuid": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3"
},
"reported_date": 1782835537581,
"form_version": {
"time": 1781740967855,
"sha256": "2a52627c6226e9a7f642754f29dba55ecbf203e9293e981244dde3bb485d1e82"
},
"geolocation_log": [
{
"timestamp": 1782835537622,
"recording": {
"latitude": 43.067544125892496,
"longitude": -89.45010267927833,
"altitude": 0,
"accuracy": 35,
"altitudeAccuracy": null,
"heading": null,
"speed": null
},
"is_home": true
}
],
"place_id": "65675"
}| require('enketo-core/src/js/plugins'); | ||
|
|
||
| const WEAK_SIGNAL_CODES = new Set([2, 3, -2]); | ||
|
|
There was a problem hiding this comment.
It also seems that even though my geolocation field is an empty string, the widget still says that the last capture was at home.
"geolocation": "",
"geolocation_log": [
{
"timestamp": 1782737548017,
"recording": {
"latitude": 45.7608843,
"longitude": 21.2704046,
"altitude": 134.50001525878906,
"accuracy": 15.600000381469727,
"altitudeAccuracy": null,
"heading": null,
"speed": null
},
"is_home": true
},
{
"timestamp": 1782737558732,
"recording": {
"latitude": 45.7608843,
"longitude": 21.2704046,
"altitude": 134.50001525878906,
"accuracy": 15.600000381469727,
"altitudeAccuracy": null,
"heading": null,
"speed": null
},
"is_home": true
},
{
"timestamp": 1782737718952,
"recording": {
"code": 2,
"message": "application does not have sufficient geolocation permissions."
}
}
]
The app says:
Location already saved
Captured at: Home
What would you like to do?
Keep existing location
Capture new location1
There was a problem hiding this comment.
Since I cannot reproduce your
on submit it set my geolocation field as empty string
then I don't have this sort of data.
I believe the main problem that you were reporting is the empty geolocation. I assume we should never reach this state.
| $(this.element).hide(); | ||
| const $question = $(this.question); | ||
|
|
||
| if (this._isPermissionDenied()) { |
There was a problem hiding this comment.
I'm not sure what is happening here.
I am using the android app, and set my location permission to "ask every time". When I opened the contact form, the permission popup appeared, and I clicked to turn on (ask every time), however the widget says "location access is turned off".
I added geolocation for a household, then turned off location on my device. I edited this household. I never the message that geo was off, I only saw the re-record option. I checked re-record, didn;t set the home checkbox, and the old geolocation was deleted from the household:
"geolocation": "",
"geolocation_log": [
{
"timestamp": 1782738232900,
"recording": {
"code": 2,
"message": "application does not have sufficient geolocation permissions."
}
},
{
"timestamp": 1782739680884,
"recording": {
"latitude": 45.7608854,
"longitude": 21.2704035,
"altitude": 134.50001525878906,
"accuracy": 5.807000160217285,
"altitudeAccuracy": null,
"heading": null,
"speed": 0.001937792869284749
},
"is_home": true
},
{
"timestamp": 1782739733571,
"recording": {
"code": -1,
"message": "Geolocation not yet acquired"
},
"is_home": true
}
]
There was a problem hiding this comment.
I am using the android app, and set my location permission to "ask every time". When I opened the contact form, the permission popup appeared, and I clicked to turn on (ask every time), however the widget says "location access is turned off".
I wonder if this is a timing bug between the Android permission dialog and widget initialization? Is this scenario accurate?
Possible timing issue: When the device location permission is set to "Ask every time", Android's getLocationPermissions() returns false because the permission has not been granted yet. The widget's _init() runs at form load, calls _isPermissionDenied(), sees false, and immediately renders the "location access is turned off" error state. When the user taps Allow in the native dialog, permissionRequestResolved() is called and GPS starts — but the widget is already frozen in the error state and never reacts to that callback.
Proposed fix: Hook the widget into geolocationService's permissionRequestResolved lifecycle. When that callback fires, if the widget is currently showing the permission-denied state, clear it and re-run the initialization path (effectively calling _init() again). This way the widget displays a neutral "waiting for permission…" state initially, and recovers correctly when the user grants access.
This is a targeted Android-only fix — the _isPermissionDenied() guard that short-circuits the widget always returns false on desktop browsers, so the timing race cannot occur outside of cht-android.
Does that make sense as an approach to take?
…x edit-mode guard - Adds a "Change selection" button in the create flow after context is chosen, allowing the user to back out and pick a different context - Adds translation key `geolocation.change.context` across 5 languages - Fixes `injectGeoEditContext` to guard on `contact._id` instead of `contact`, so new (unsaved) contact forms are not treated as edit mode and do not show the "No GPS location recorded" message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
I was not able to reproduce some of the problems that you saw, @dianabarsan. For each one, I have provided a screen recording showing what I did and, where appropriate, an example of the CouchDB document. Can you review those and see how the pathways you tested differ? Is there a substantial difference between the way that geolocation in cht-core behaves in the browser and the way it behaves in cht-android? I can try to load it in cht-android and test that way. |
…capture Removes the hide/show toggle on the context radio buttons so users can change their selection at any time without needing a separate "Change selection" button. Also removes the confirmation text that appeared after selection, which is now redundant. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n edit flow When capture fails during an edit and the user chooses to continue without location, replace the edit UI with a message indicating the home location will be removed, and set geo_capture to 'skipped' so the save path clears the geolocation field and logs the failure. Also tightens the geoHasLocation logic to use only the most recent log entry as the signal for whether a location is saved, so the edit badge does not appear after home removal or GPS corruption. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…capture begins Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements a geolocation capture widget for contact forms that records GPS coordinates at
the time a contact is created or edited, along with a context question that lets the CHW
record whether they are capturing at the beneficiary's home or another location.
Resolves #10676
What this adds
A new Enketo widget (
appearance="geolocation-capture") that replaces a plain textfield with an interactive GPS capture experience:
context option is selected ("prepare then capture" flow)
animated progress bar
"captured"value to
"skipped". When signal is weak (GPS unavailable or timed out), a messageappears above the Retry button: "GPS signal is weak. Please move to an open area and
try again."
GeolocationServicecheckbox before the button activates
Edit mode: when a contact already has a location captured, the widget renders
differently on the edit form:
the date of the last capture
an acknowledgement checkbox; checking the checkbox begins capture automatically
choice instead of proceeding. The existing location is never discarded on a failed attempt.
same as a contact with no location; the widget shows in create mode.
Geolocation saved to the contact document at submit time:
{ "geolocation": { "latitude": 1.23, "longitude": 4.56, "accuracy": 10, ... }, "geolocation_log": [ { "timestamp": 1234567890000, "recording": { "latitude": 1.23, "longitude": 4.56, "accuracy": 10, ... }, "is_home": true } ] }geolocationis only written when GPS capture succeeds and the context is home.Other-context captures are recorded in the log only.
geolocation_logis append-only. Subsequent edits push new entries without removingprevious ones. Each entry carries an
is_homeboolean derived from the context selected.geolocationandgeolocation_logareleft unchanged.
Form author contract
A form author adds one field per
field-listgroup:The
appearance="geolocation-capture"value is the only contract between the form andCHT. The home/other context question is embedded in the widget and does not require a
separate XForm field. The field name (
geo_capture) is arbitrary.See the documentation added in medic/cht-docs#2221.
Widget limitation: The widget is designed for contact forms only. Placing it in a
report form is unsupported and may produce unexpected behaviour. This limitation is
documented and is under discussion for future work.
Changes
webapp/src/js/enketo/widgets/geolocation-widget.js: new widget (create mode + editmode; refactored into helper functions per UI section)
webapp/src/js/enketo/widgets.js: widget registeredwebapp/src/ts/services/geolocation.service.ts:init()/retry()/currentHandle/currentPromisewebapp/src/ts/services/integration-api.service.ts: exposesGeolocationonwindow.CHTCorewebapp/src/ts/services/form.service.ts:injectGeoEditContext()reads existinglocation from contact and sets dataset attributes on the widget input before the form
renders;
getGeoContext()reads context from DOM at save time;saveGeo()writesgeolocationandgeolocation_log;restoreGeoFieldsIfKept()fetches the originaldoc and restores
geolocationfields when the CHW keeps the existing locationwebapp/src/ts/modules/contacts/contacts-edit.component.ts: detects widget, callsGeolocationService.init(), passes geo handle through save pipelinewebapp/src/css/enketo/medic.less: widget styles (progress bar animation, buttonlayout, badge, warning box, success/failure colours)
messages-en.properties; translations for es, fr, ne, sw(ne and sw translations are machine-generated and flagged for native speaker review)
tests/integration/cht-form/: integration tests for the geolocation widget save pipelinetests/e2e/default/contacts/: e2e tests for create and edit flowsA note on PR size
This PR is large, and I apologize for that. The feature required a new widget and changes to the form service. Breaking it into smaller PRs would have left each one in an incomplete or untestable state.
Of the approximately 2,860 new lines, 69% are tests and test fixtures. The production code, including CSS, is under 800 lines:
The production code breaks down as:
Will reports break if they include this widget?
No. I verified that the widget behaves correctly when embedded in a report form. All CHT
form paths (reports, tasks, contacts, training cards) call
geolocationService.init()before the form renders, so
currentPromiseis always available and GPS capture worksas expected. I added a null guard in
_startCapture()as a defensive safety net forembeddings outside standard CHT infrastructure, and this is covered by an integration test
that explicitly clears
currentPromiseto exercise that path.AI disclosure
This PR was developed with Claude Code (Anthropic) as a coding
assistant throughout the implementation. Specifically, Claude Code was used to:
GeolocationService,form.service.tschanges,contacts-edit.component.tschanges, CSS, and translationstrings
code was written to make them pass
field-listgroups assection.or-group-data(not.or-group) by inspecting the live DOM/code-review ultracommandAll code was reviewed and approved by the author before committing.
Recordings and Data
Each screen recording is followed by a section with the CouchDB data captured after the scenario was finished.
Create — Successful home capture
Geo.Create.Home.Capture.mov
Create — Successful home capture: CouchDB data
{ "_id": "019f05a0-319e-7ccf-a022-237c24b11fb6", "_rev": "2-ea6f9d0c8fab22b33c203f54fad07955", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } }, "type": "clinic", "is_name_generated": "false", "name": "Geo Create Home Capture", "external_id": "", "notes": "", "contact": { "_id": "019f05a0-319e-7ccf-a022-2a1713b0ed32", "parent": { "_id": "019f05a0-319e-7ccf-a022-237c24b11fb6", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } } } }, "geolocation": { "latitude": 43.06759581889168, "longitude": -89.45029228342403, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "meta": { "created_by": "noble.hirthe", "created_by_person_uuid": "c22ef015-4233-4af4-9636-ac2fbffb4a15", "created_by_place_uuid": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3" }, "reported_date": 1782505812382, "form_version": { "time": 1781646256080, "sha256": "b43227eac3f1b8ffda3145bd82c2510dc27f4c59c5f61ff71319e673204a3e01" }, "geolocation_log": [ { "timestamp": 1782505812418, "recording": { "latitude": 43.06759581889168, "longitude": -89.45029228342403, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "is_home": true } ], "place_id": "82223" }Create — Successful other capture
Geo.Create.Other.Capture.mov
Create — Successful other capture: CouchDB data
{ "_id": "019f059f-2ba4-7ccf-a022-1568e8170e67", "_rev": "2-65fdffca73c0da6e6e3f115816191f82", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } }, "type": "clinic", "is_name_generated": "false", "name": "Geo Create Other Capture", "external_id": "", "notes": "", "contact": { "_id": "019f059f-2ba4-7ccf-a022-1fe5cc968709", "parent": { "_id": "019f059f-2ba4-7ccf-a022-1568e8170e67", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } } } }, "geolocation": "", "meta": { "created_by": "noble.hirthe", "created_by_person_uuid": "c22ef015-4233-4af4-9636-ac2fbffb4a15", "created_by_place_uuid": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3" }, "reported_date": 1782505745316, "form_version": { "time": 1781646256080, "sha256": "b43227eac3f1b8ffda3145bd82c2510dc27f4c59c5f61ff71319e673204a3e01" }, "geolocation_log": [ { "timestamp": 1782505745374, "recording": { "latitude": 43.06759581889168, "longitude": -89.45029228342403, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "is_home": false } ], "place_id": "34721" }Create — Weak GPS signal (position unavailable)
Retry after failure then succeed
Geo.Create.Weak.Signal.Retry.mov
Create — Weak GPS signal (position unavailable): CouchDB data
Data after retry succeeds
{ "_id": "019f059d-2af1-7ccf-a022-04cea36918db", "_rev": "2-68c7d2d566a7be2424473ea05bdfbc9c", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } }, "type": "clinic", "is_name_generated": "false", "name": "Geo Create Weak Signal Retry", "external_id": "", "notes": "", "contact": { "_id": "019f059d-2af1-7ccf-a022-0a6e554c48eb", "parent": { "_id": "019f059d-2af1-7ccf-a022-04cea36918db", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } } } }, "geolocation": { "latitude": 43.06759581889168, "longitude": -89.45029228342403, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "meta": { "created_by": "noble.hirthe", "created_by_person_uuid": "c22ef015-4233-4af4-9636-ac2fbffb4a15", "created_by_place_uuid": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3" }, "reported_date": 1782505614065, "form_version": { "time": 1781646256080, "sha256": "b43227eac3f1b8ffda3145bd82c2510dc27f4c59c5f61ff71319e673204a3e01" }, "geolocation_log": [ { "timestamp": 1782505614101, "recording": { "latitude": 43.06759581889168, "longitude": -89.45029228342403, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "is_home": true } ], "place_id": "73623" }Create — Permission denied, continuing without location
Geo.Create.Permission.Denied.Skip.mov
Create — Permission denied, continuing without location: CouchDB data
{ "_id": "019f059c-0ec1-7ccf-a021-f6b05c62aead", "_rev": "2-52fe7525e27e5b38f0cc906d15bce4b5", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } }, "type": "clinic", "is_name_generated": "false", "name": "Geo Create Permission Denied Skip", "external_id": "", "notes": "", "contact": { "_id": "019f059c-0ec1-7ccf-a021-f99cfec3b4ef", "parent": { "_id": "019f059c-0ec1-7ccf-a021-f6b05c62aead", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } } } }, "geolocation": "", "meta": { "created_by": "noble.hirthe", "created_by_person_uuid": "c22ef015-4233-4af4-9636-ac2fbffb4a15", "created_by_place_uuid": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3" }, "reported_date": 1782505541313, "form_version": { "time": 1781646256080, "sha256": "b43227eac3f1b8ffda3145bd82c2510dc27f4c59c5f61ff71319e673204a3e01" }, "geolocation_log": [ { "timestamp": 1782505541363, "recording": { "code": 1, "message": "User denied Geolocation" }, "is_home": true } ], "place_id": "61074" }Response required
Geo.Create.Required.mov
Edit — Keep existing home location
Geo.Edit.Keep.Existing.Home.mov
Edit — Keep existing home location: CouchDB data
{ "_id": "019f057f-2002-7aa9-950b-20bd69fe157d", "_rev": "2-d795cc3f7aca4049fb8cec38e881dece", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } }, "type": "clinic", "is_name_generated": "false", "name": "Geo Edit Keep Existing Home", "external_id": "", "notes": "", "contact": { "_id": "019f057f-2002-7aa9-950b-2da3d6c0fe9c", "parent": { "_id": "019f057f-2002-7aa9-950b-20bd69fe157d", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } } } }, "geolocation": { "latitude": 43.06753313499881, "longitude": -89.450111215471, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "meta": { "created_by": "noble.hirthe", "created_by_person_uuid": "c22ef015-4233-4af4-9636-ac2fbffb4a15", "created_by_place_uuid": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3" }, "reported_date": 1782503645186, "form_version": { "time": 1781646256080, "sha256": "b43227eac3f1b8ffda3145bd82c2510dc27f4c59c5f61ff71319e673204a3e01" }, "geolocation_log": [ { "timestamp": 1782503645228, "recording": { "latitude": 43.06753313499881, "longitude": -89.450111215471, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "is_home": true } ], "place_id": "72114" }Edit — Keep existing other location
Geo.Edit.Keep.Existing.Other.mov
Edit — Keep existing other location: CouchDB data
{ "_id": "019f057e-1d80-7aa9-950b-14debaa5a7b3", "_rev": "2-ef66e3396d5ad7f3c9f9de25a29e6377", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } }, "type": "clinic", "is_name_generated": "false", "name": "Geo Edit Keep Existing Other", "external_id": "", "notes": "", "contact": { "_id": "019f057e-1d80-7aa9-950b-1890909d14d2", "parent": { "_id": "019f057e-1d80-7aa9-950b-14debaa5a7b3", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } } } }, "geolocation": "", "meta": { "created_by": "noble.hirthe", "created_by_person_uuid": "c22ef015-4233-4af4-9636-ac2fbffb4a15", "created_by_place_uuid": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3" }, "reported_date": 1782503579008, "form_version": { "time": 1781646256080, "sha256": "b43227eac3f1b8ffda3145bd82c2510dc27f4c59c5f61ff71319e673204a3e01" }, "geolocation_log": [ { "timestamp": 1782503579050, "recording": { "latitude": 43.06758516542101, "longitude": -89.45028588549593, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "is_home": false } ], "place_id": "03955" }Edit — Replace old home with new home
Geo.Edit.Replace.Existing.Home.mov
Edit — Replace old home with new home: CouchDB data
{ "_id": "019f057c-c629-7aa9-950b-056d0f7248a4", "_rev": "3-a9c3067c8b0ac99e02eb5fd113ffcca3", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } }, "type": "clinic", "contact": { "_id": "019f057c-c629-7aa9-950b-0c5a2f264c64", "parent": { "_id": "019f057c-c629-7aa9-950b-056d0f7248a4", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } } } }, "is_name_generated": "false", "generated_name": "Geo Edit Replace Existing Home's Household", "name": "Geo Edit Replace Existing Home", "external_id": "", "notes": "", "geolocation": { "latitude": 43.067541260535485, "longitude": -89.45011125614462, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "meta": { "created_by": "noble.hirthe", "created_by_person_uuid": "c22ef015-4233-4af4-9636-ac2fbffb4a15", "created_by_place_uuid": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "last_edited_by": "noble.hirthe", "last_edited_by_person_uuid": "c22ef015-4233-4af4-9636-ac2fbffb4a15", "last_edited_by_place_uuid": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3" }, "reported_date": 1782503491113, "form_version": { "time": 1781740967855, "sha256": "2a52627c6226e9a7f642754f29dba55ecbf203e9293e981244dde3bb485d1e82" }, "geolocation_log": [ { "timestamp": 1782503491156, "recording": { "latitude": 43.06758516542101, "longitude": -89.45028588549593, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "is_home": true }, { "timestamp": 1782504547961, "recording": { "latitude": 43.067541260535485, "longitude": -89.45011125614462, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "is_home": true } ], "place_id": "61906" }Edit — Replace old other with new home
Geo.Edit.Replace.Existing.Other.mov
Edit — Replace old other with new home: CouchDB data
{ "_id": "019f0579-fdab-7aa9-950a-f6cf84a4f7e8", "_rev": "3-72c88ff6339b92163b2a9f9b713b4489", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } }, "type": "clinic", "contact": { "_id": "019f0579-fdab-7aa9-950a-fb5c69062c44", "parent": { "_id": "019f0579-fdab-7aa9-950a-f6cf84a4f7e8", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } } } }, "is_name_generated": "false", "generated_name": "Geo Edit Replace Existing Other's Household", "name": "Geo Edit Replace Existing Other", "external_id": "", "notes": "", "geolocation": { "latitude": 43.067541260535485, "longitude": -89.45011125614462, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "meta": { "created_by": "noble.hirthe", "created_by_person_uuid": "c22ef015-4233-4af4-9636-ac2fbffb4a15", "created_by_place_uuid": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "last_edited_by": "noble.hirthe", "last_edited_by_person_uuid": "c22ef015-4233-4af4-9636-ac2fbffb4a15", "last_edited_by_place_uuid": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3" }, "reported_date": 1782503308715, "form_version": { "time": 1781740967855, "sha256": "2a52627c6226e9a7f642754f29dba55ecbf203e9293e981244dde3bb485d1e82" }, "geolocation_log": [ { "timestamp": 1782503308752, "recording": { "latitude": 43.06758516542101, "longitude": -89.45028588549593, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "is_home": false }, { "timestamp": 1782504650089, "recording": { "latitude": 43.067541260535485, "longitude": -89.45011125614462, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "is_home": true } ], "place_id": "66850" }Edit — GPS fails, revert to keep/capture choice
Geo.Edit.Home.Failed.Update.mov
Edit — GPS fails, revert to keep/capture choice: CouchDB data
{ "_id": "019f0589-af45-7774-a1d6-fd644cee1ac4", "_rev": "3-4f9873d80c3907a15c63da3e9611dee9", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } }, "type": "clinic", "contact": { "_id": "019f0589-af45-7774-a1d7-065fb159c6cf", "parent": { "_id": "019f0589-af45-7774-a1d6-fd644cee1ac4", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } } } }, "is_name_generated": "false", "generated_name": "Geo Edit Home Failed Update's Household", "name": "Geo Edit Home Failed Update", "external_id": "", "notes": "", "geolocation": { "latitude": 43.06756903546192, "longitude": -89.45029771390688, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "meta": { "created_by": "noble.hirthe", "created_by_person_uuid": "c22ef015-4233-4af4-9636-ac2fbffb4a15", "created_by_place_uuid": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "last_edited_by": "noble.hirthe", "last_edited_by_person_uuid": "c22ef015-4233-4af4-9636-ac2fbffb4a15", "last_edited_by_place_uuid": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3" }, "reported_date": 1782504337221, "form_version": { "time": 1781740967855, "sha256": "2a52627c6226e9a7f642754f29dba55ecbf203e9293e981244dde3bb485d1e82" }, "geolocation_log": [ { "timestamp": 1782504337260, "recording": { "latitude": 43.06756903546192, "longitude": -89.45029771390688, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "is_home": true } ], "place_id": "69814" }Edit — Contact with no prior location shows create mode
Geo.Edit.Add.Home.mov
Edit — Contact with no prior location shows create mode: CouchDB data
{ "_id": "019f0529-4b50-7ff9-81e3-0c0324428a93", "_rev": "3-8f36f8949dc397d4ac624a796735b163", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } }, "type": "clinic", "contact": { "_id": "019f0529-4b50-7ff9-81e3-12b07a080d0d", "parent": { "_id": "019f0529-4b50-7ff9-81e3-0c0324428a93", "parent": { "_id": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "parent": { "_id": "0490ad32-f955-4171-9d95-e951db7a90ec" } } } }, "is_name_generated": "false", "generated_name": "Geo Edit Add Home's Household", "name": "Geo Edit Add Home", "external_id": "", "notes": "", "geolocation": { "latitude": 43.067600480568494, "longitude": -89.45038494284957, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "meta": { "created_by": "noble.hirthe", "created_by_person_uuid": "c22ef015-4233-4af4-9636-ac2fbffb4a15", "created_by_place_uuid": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3", "last_edited_by": "noble.hirthe", "last_edited_by_person_uuid": "c22ef015-4233-4af4-9636-ac2fbffb4a15", "last_edited_by_place_uuid": "0fdfca7d-0446-4c0c-aedf-6f8b62a437f3" }, "reported_date": 1782498020176, "form_version": { "time": 1781740967855, "sha256": "2a52627c6226e9a7f642754f29dba55ecbf203e9293e981244dde3bb485d1e82" }, "geolocation_log": [ { "timestamp": 1782498020218, "recording": { "code": 2, "message": "" }, "is_home": true }, { "timestamp": 1782498081857, "recording": { "latitude": 43.067600480568494, "longitude": -89.45038494284957, "altitude": 0, "accuracy": 35, "altitudeAccuracy": null, "heading": null, "speed": null }, "is_home": true } ], "place_id": "49864" }Browser testing
The widget can be tested using the default household (clinic) creation and edit forms. The steps below assume a local dev server is running at
http://localhost:5988/with a CHW user and a health center in the contact hierarchy.Setup
1. Apply XForm changes to the clinic forms
Three edits are needed in
config/default/forms/contact/clinic-create.xml. Apply the same changes toclinic-edit.xmlto test the edit flow.In the model instance, add inside the
<clinic>node:<geo_capture/>In the binds, add:
In the body, add a new
field-listgroup between the primary contact page and the household naming page:2. Upload the config
From
config/default/:3. Prepare the browser
Cmd+Shift+R) and log in as a CHW user (not admin — geolocation is disabled for admin)Simulating weak GPS signal
To trigger the weak signal message (GPS unavailable, code 2), use Chrome DevTools Sensors: open DevTools, press Escape to open the drawer, click the three-dot menu in the drawer tab bar, select Sensors, then set Location to Location unavailable. Keep the Chrome location permission set to Allow (blocking permission triggers code 1 instead).
Create: successful home capture
Verify in CouchDB:
geolocation— coordinates objectgeolocation_log[0].is_home—truegeo_capture—"captured"Create: successful other-context capture
Same as above but select Other as context.
Verify in CouchDB:
geolocation— absent (only written for home captures)geolocation_log[0].is_home—falsegeo_capture—"captured"Create: weak GPS signal message (codes 2 and 3)
Create: permission denied (code 1)
Verify in CouchDB:
geolocation— absentgeolocation_log[0].recording—{ "code": 1, "message": "User denied Geolocation" }geo_capture—"skipped"Create: retry after failure then succeed
geolocation_log[0].is_homeis present in CouchDBEdit: keep existing location
Requires a household with a previously captured home location.
Verify in CouchDB:
geolocationandgeolocation_logare unchanged from before the edit.Edit: capture new location
Verify in CouchDB:
geolocation— new coordinatesgeolocation_log— now has 2 entries; new entry hasis_home: trueEdit: GPS fails, revert to keep/capture choice
Verify in CouchDB:
geolocationis unchanged from before the edit.Edit: contact with no prior location shows create mode
Requires a household where location was previously skipped.
Verify in CouchDB:
geolocationnow has coordinates;geolocation_loghas one entry.Open questions for reviewer
Widget is contact-specific: as currently designed, the widget is not appropriate
for report forms. The limitations are documented. Should the widget be renamed (e.g.
contact-geolocation-widget.js) to make this explicit in the code? Should the UIlanguage be made more generic (currently references "household")?
geo_capturepresent on failed-capture records: when GPS fails and the CHWcontinues without a location,
geo_captureis set to"skipped"and saved as partof the form data. Is this the intended behaviour, or should the field be cleared?
ne/sw translations: the Nepali and Swahili translation strings for new keys are
machine-generated. These should be reviewed by a native speaker before shipping.
Code review checklist
appearance="geolocation-capture". Existing forms without this appearance arecompletely unaffected.
small helper functions (one per UI section)
contacts-edit.component, geolocation-widget), cht-form integration tests, and e2e tests
messages-*.propertiesfor en, es, fr, ne,sw. ne and sw are machine-generated and flagged for native speaker review.
geolocation_logis a new field; existing documents without it are unaffected.License
The software is provided under AGPL-3.0. Contributions to this project are accepted under
the same license.