feat(#10676): store geolocation when submitting contact forms#11136
feat(#10676): store geolocation when submitting contact forms#11136cliftonmcintosh wants to merge 140 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>
…location" across all GPS failure states Add explicit acknowledgment button to permission-denied and GPS-unavailable states, which previously auto-set a value and left the user stuck on a required-field error. Rename geolocation.cant.record translation key to geolocation.continue.without.location and update the CSS class and method names throughout. All three failure states (permission denied, unavailable, GPS failure) now use a consistent "Continue without location" button that sets the value to skipped. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ilure states
- GeolocationService.permissionRequestResolved() now dispatches a
geolocationPermissionGranted DOM event after starting the GPS watcher,
enabling the widget to react without polling
- Widget's permission-denied UI now registers a { once: true } listener
on geolocationPermissionGranted that removes the permission-denied
message and transitions to the correct mode: edit-with-location,
edit-without-location, or create flow
- _onCaptureFailure() now re-checks isPermissionDenied() when GPS fails
and redirects to the permission-denied message + continue-without
button instead of the generic GPS failure UI; the progress bar is
also immediately marked as failed in this case
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…repeated dialogs getLocationPermissions() in the Android bridge both checks the permission state and launches the RequestLocationPermissionActivity as a side effect whenever permissions are not yet granted. This caused the "Location access" dialog to appear 3 times on a single form open: once from geolocationService.init(), once from the widget's _init() call to isPermissionDenied(), and once from _onCaptureFailure() after the first dialog was dismissed. GeolocationService.init() now caches the result of the Android permission check. isPermissionDenied() returns the cached value on subsequent calls without hitting the Android API again. The cache is reset at the start of each init() call so the check is always fresh per form session. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces permissionResponsePending to prevent isPermissionDenied() from calling getAndroidPermission() while an Android permission dialog result is in flight (which would trigger a second dialog). Adds isCodePermissionRelated() to catch GPS code 2 failures that are permission-caused even when the pending flag suppresses a direct check. The widget now shows a retry button on the permission-denied failure path in addition to the existing "Continue without location" button. Note: widget spec test 'should not show retry button' in the code-2-permission-related describe block needs updating — it was written before the retry button was added to this path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This reverts commit 64680c9.
…prevent repeated dialogs" This reverts commit 5aefcb9.
…emove unused Markdown field All public service references are assigned once in the constructor and never mutated. Marking them readonly enforces this at compile time and resolves the outstanding Sonar warning. The Markdown field was declared but never assigned or referenced anywhere in the codebase. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…een radio groups Moves the GPS progress section above the "Household location already saved" badge in the edit flow, so the capture feedback is visible before the edit choices rather than after. Adds a "Location details" label between the Keep/Record new/Remove radios and the conditional Household/Somewhere else radios when Record new is selected and GPS succeeds, providing visual separation consistent with other Enketo question displays. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eckbox Replace the button with a labeled checkbox. Checking it sets the field value to "skipped"; unchecking clears it, making the field invalid again and blocking submit. This is more consistent with how Enketo handles explicit acknowledgement and prevents accidental opt-out. Remove the now-dead geolocation.continue.without.location translation key. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…checkbox event propagation Reorder _initCreateFlow before the no-location message so the progress bar renders above "No GPS location has been recorded" in both the main init path and the geolocationPermissionGranted handler. Also add event.stopPropagation() to the save-without checkbox change handler to prevent Enketo's form-level listener from treating it as an XForm input, which was causing validation failures on Next/Submit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…PS succeeds In the edit-with-location flow, the "Record new location" radio is now disabled at init and only enabled once GPS captures a fix. On any failure (weak signal, permission denied, timeout) it is re-disabled, and if it was somehow already selected, the widget reverts to "Keep saved location". The "Save without location" checkbox is never shown in the edit-with-location flow because Keep / Record New / Remove is already a complete option set. This removes confusing UI state where a user with a saved location saw a "Save without location" checkbox alongside the edit-mode radio choices. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three tests in household-geolocation-widget.wdio-spec.js were failing: Two referenced `.geolocation-continue-without-btn`, a selector that no longer exists after bba8189 replaced that button with a `.geolocation-save-without-checkbox` checkbox. Updated the selectors and interaction (click-to-toggle instead of click-to-activate) to match. The third asserted that selecting "Record new location" after a GPS failure would reveal a continue-without option. 4ba3133 intentionally disabled that radio until GPS succeeds and removed the save-without checkbox from the edit-with-location flow entirely (Keep / Record New / Remove is already a complete option set). Replaced the test with coverage of the actual current behavior: the radio stays disabled and no checkbox appears after a failure in edit mode, matching the equivalent karma spec coverage already added in that commit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
FormService.renderForm() unconditionally called geolocationService.init() for any form containing the geolocation-capture widget, colliding with the reports/ tasks/training-cards components that already call init() themselves for their own pre-existing GPS-tagging feature. It also ran injectGeoEditContext() for every form type, not just contacts. ContactsEditComponent read the shared GeolocationService singleton's currentHandle directly instead of owning a handle, so editing one contact then navigating to edit another (without the component being destroyed) could leak the first contact's stale GPS handle into the second contact's save. ContactsEditComponent now calls geolocationService.init() itself, mirroring the other form components, cancelling any previous handle first; renderForm() now only touches geolocation state for contact-type forms. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cancel() only stopped the native GPS watcher, never settled the deferred
backing currentPromise. If cancel() was called (component destroy, or
navigating to a different contact) before GPS resolved, the widget's
pending .then() on currentPromise never fired, leaving its closure over
the widget/DOM retained until the next unrelated init() call replaced
currentPromise.
cancel() now resolves the pending promise with a distinct {code: -4,
message: 'Geolocation cancelled'} result when nothing has resolved yet,
via the same finalise() path success()/failure() already use. When a
real result already arrived, it only stops the watcher, avoiding a
duplicate telemetry event and leaving the already-settled promise
untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Checking "save without location" only set the field's value to 'skipped' without stopping the underlying GPS watch. If a position arrived in the background after the explicit opt-out, it was still written into geolocation_log on save, silently overriding the user's choice. The checkbox handler now calls currentHandle.cancel() when checked, which (as of the prior cancel() fix) both stops the watcher and settles the pending promise. Unchecking does not cancel anything. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
saveContact() passed the entire prepared-docs array (primary contact + repeated children + any new sibling contact) to saveGeo(), which stamps whatever geolocation it captures onto every doc it's given. Creating a household together with its new primary person in one submission caused the household's GPS location to also land on the new person doc. saveGeo() itself is unchanged, since the generic report-save path relies on it applying the same geolocation to multiple linked docs from a single report submission (the "extra docs" feature) - that behavior is intentional and stays as-is. saveContact() now passes only the primary contact doc to saveGeo(), leaving siblings and repeated children alone. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
saveContact() stripped the raw capture sentinel via a hardcoded `delete doc.geo_capture`, while every other lookup in this file finds the field generically via the .or-appearance-geolocation-capture selector. Nothing enforces the field being named geo_capture, so a form binding that appearance to a differently-named field would leak the raw 'captured'/'kept'/'skipped' value onto the saved doc. The generic _save() path (reports/tasks) never cleaned this field up at all. Since report docs nest form fields under .fields while contact docs flatten them onto the doc directly, the shared stripGeoCaptureField() helper checks both locations rather than assuming one doc shape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
injectGeoEditContext()'s `!contact.geolocation?.latitude` check treated a latitude of exactly 0 (equator) as falsy, causing an early return that skipped the edit-mode UI setup entirely - a contact geo-tagged on the equator would render as if never located, hiding the badge and keep/record-new/remove choices for a location that genuinely exists. Changed to typeof contact.geolocation?.latitude !== 'number', an explicit presence check that doesn't misfire on legitimate zero values. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
setLastCapture() computed and stored a data-geo-last-capture attribute (isHome + timestamp of the most recent successful capture) on the edit badge, but nothing in the widget ever read it - the "last captured on DATE" display described in the PR was never actually wired up. Rather than half-ship it, removing the dead computation, its now-orphaned test coverage, and the corresponding (also unused) test-harness support in mock-config.js's loadFormWithEditContext(). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
isPermissionDenied() only checked the Android bridge's permission flag, which always reports "not denied" when there's no Android bridge present. On a plain browser or PWA session, a genuine permission denial (the standard GeolocationPositionError.PERMISSION_DENIED, code 1) was never detected, so the widget always fell through to the generic failure message instead of showing permission-specific guidance. isPermissionDenied() now also checks the last GPS error's code against a new GEOLOCATION_PERMISSION_DENIED constant. Android is unaffected - its permission failures surface as code 2 through the bridge mechanism, not code 1, so this only changes behavior on the browser/PWA path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_onCaptureFailure ignored the errorCode parameter describing why this specific capture attempt failed, and instead re-queried live permission state via isPermissionDenied() to decide which message to show. This PR's own test proved the mismatch: a timeout failure rendered as "permission denied" whenever isPermissionDenied() happened to return true at render time, regardless of what actually went wrong. The permission-denied branch now checks errorCode === 1 directly (the standard GeolocationPositionError.PERMISSION_DENIED code), removing the render-time race entirely. isPermissionDenied() is unchanged and still used at widget init, where no errorCode exists yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The retry button's intended styling (flex layout, width cap, icon color) was nested under `.geolocation-status .geolocation-retry-btn`, but the widget inserted the button as a sibling of `.geolocation-status`, not a descendant - so that rule never matched, and only a plain duplicate rule (margin-bottom: 8px) actually applied. Appending the button to $status directly fixes the CSS targeting and lets the insertBefore/editChoices branching be removed entirely, since $status is already positioned before .geolocation-edit-choices in both create and edit-mode DOM structures. Removed the now-dead duplicate CSS rule. Also fixed a spec test that checked container.children directly - it would have silently passed for the wrong reason after this change, since indexOf() on a removed direct child returns -1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…roup Replace the two-step Keep/Record New/Remove -> Home/Elsewhere choice with one flat radio group (Keep, Change household location, I am not at the household, Remove) per team feedback that the nested choice was confusing. Keep remains the default; the two capture options stay disabled until GPS resolves, then enable together. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-resolve progress row) Cherry-pick ab3320c from Diana, adapted onto our merged 4-option edit radios (her change predates that work, so the two now-dead helper methods it touched were dropped instead of reapplied). Also fixes unsafe .html() string interpolation, drops a leftover debug console.warn, and removes CSS/translations left dead by the change. Co-Authored-By: Diana Barsan <barsan@medic.org> Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| EDIT_REMOVE: 'geolocation.edit.remove', | ||
| }; | ||
|
|
||
| const WEAK_SIGNAL_CODES = new Set([2, 3, -2]); |
There was a problem hiding this comment.
Should 2 be in this set?
From what I can tell, that code is overloaded: it fires for OS Location Services being off, no network route to the location provider (desktop), and Android's WebView reporting a missing OS-level permission through this same code rather than PERMISSION_DENIED. If we include 2 in the list of weak signal codes, "GPS signal is weak, move to an open area" can show for problems that have nothing to do with signal strength, and moving won't fix them.
I don't think we have a reliable way to distinguish these cases from the code alone. Options:
(a) remove 2 from this set,
(b) leave it as one generic "GPS signal is weak..." message and accept the imprecision,
(c) word the message more neutrally ("Couldn't get a location. Check your GPS/location settings") instead of implying a signal problem specifically, or
(d) something smarter using the Android permission bridge as a signal. I tried this but ended up in a loop with the Location denied screen from cht-android appearing again and again and again and again....
CSS class names, radio/field values, and geo-context strings were repeated verbatim across separate methods (e.g. the capture-home/capture-other radio selector duplicated in both _onCaptureSuccess and _onCaptureFailure). Pull them into module-level constants (CLASS_NAMES, RADIO_VALUES, FIELD_VALUES, GEO_CONTEXT) so renaming one no longer risks silently breaking a matching string elsewhere in the file. No behavior change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…esigns The edit-flow's UI has been redesigned several times over the course of this work (acknowledge checkboxes, warning messaging, a capture/skip button pair, etc. were all replaced along the way). Cross-referenced every selector under .or-appearance-geolocation-capture against what household-geolocation-widget.js actually renders, and against the rest of the repo, to remove the ones from those earlier iterations that nothing renders or tests reference anymore. No behavior change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
I believe this is ready for re-review. A lot has changed since your earlier review. And it is SO LARGE!!!! |
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.