Skip to content

feat(#10676): store geolocation when submitting contact forms#11136

Open
cliftonmcintosh wants to merge 92 commits into
medic:masterfrom
cliftonmcintosh:feature/10676-contact-form-geolocation-stored
Open

feat(#10676): store geolocation when submitting contact forms#11136
cliftonmcintosh wants to merge 92 commits into
medic:masterfrom
cliftonmcintosh:feature/10676-contact-form-geolocation-stored

Conversation

@cliftonmcintosh

@cliftonmcintosh cliftonmcintosh commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

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 text
field with an interactive GPS capture experience:

  • Detects GPS availability and Android permission state at render time
  • Home/other context radios embedded in the widget; capture button is disabled until a
    context option is selected ("prepare then capture" flow)
  • "Press here to capture GPS location" button initiates acquisition with a 30-second
    animated progress bar
  • On success: green bar + confirmation message; sets field value to "captured"
  • On failure: red bar + "Retry" and "Continue without location" buttons; sets field
    value to "skipped". When signal is weak (GPS unavailable or timed out), a message
    appears above the Retry button: "GPS signal is weak. Please move to an open area and
    try again."
  • Retry resets the progress bar and re-acquires from GeolocationService
  • The "Continue without location" action requires the CHW to check an acknowledgement
    checkbox before the button activates

Edit mode: when a contact already has a location captured, the widget renders
differently on the edit form:

  • A green badge shows "Location already saved" with the context (Home or Other) and
    the date of the last capture
  • Two radio options: "Keep existing location" (pre-selected) and "Capture new location"
  • Selecting "Capture new location" reveals a warning ("Are you at this household?") and
    an acknowledgement checkbox; checking the checkbox begins capture automatically
  • If capture fails in edit mode, "Continue without location" reverts to the keep/capture
    choice instead of proceeding. The existing location is never discarded on a failed attempt.
  • A contact whose only prior captures are failures (e.g. permission denied) is treated the
    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
    }
  ]
}
  • geolocation is only written when GPS capture succeeds and the context is home.
    Other-context captures are recorded in the log only.
  • geolocation_log is append-only. Subsequent edits push new entries without removing
    previous ones. Each entry carries an is_home boolean derived from the context selected.
  • When editing with "Keep existing location", geolocation and geolocation_log are
    left unchanged.

Form author contract

A form author adds one field per field-list group:

<group appearance="field-list" ref="/data/contact">
  <input ref="/data/contact/geo_capture" appearance="geolocation-capture">
    <label>Capture GPS location</label>
  </input>
</group>

The appearance="geolocation-capture" value is the only contract between the form and
CHT. 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 + edit
    mode; refactored into helper functions per UI section)
  • webapp/src/js/enketo/widgets.js: widget registered
  • webapp/src/ts/services/geolocation.service.ts: init() / retry() / currentHandle / currentPromise
  • webapp/src/ts/services/integration-api.service.ts: exposes Geolocation on window.CHTCore
  • webapp/src/ts/services/form.service.ts: injectGeoEditContext() reads existing
    location from contact and sets dataset attributes on the widget input before the form
    renders; getGeoContext() reads context from DOM at save time; saveGeo() writes
    geolocation and geolocation_log; restoreGeoFieldsIfKept() fetches the original
    doc and restores geolocation fields when the CHW keeps the existing location
  • webapp/src/ts/modules/contacts/contacts-edit.component.ts: detects widget, calls
    GeolocationService.init(), passes geo handle through save pipeline
  • webapp/src/css/enketo/medic.less: widget styles (progress bar animation, button
    layout, badge, warning box, success/failure colours)
  • Translation keys added to messages-en.properties; translations for es, fr, ne, sw
    (ne and sw translations are machine-generated and flagged for native speaker review)
  • Unit tests for all new service and widget logic
  • tests/integration/cht-form/: integration tests for the geolocation widget save pipeline
  • tests/e2e/default/contacts/: e2e tests for create and edit flows

A 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:

Category New lines Share
Tests and test fixtures 1,966 69%
Production code 765 26%
Translations 131 5%
Total 2,862

The production code breaks down as:

Type Lines
JavaScript (widget) 384
CSS / LESS 254
TypeScript (services, components) 127
Total 765

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 currentPromise is always available and GPS capture works
as expected. I added a null guard in _startCapture() as a defensive safety net for
embeddings outside standard CHT infrastructure, and this is covered by an integration test
that explicitly clears currentPromise to 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:

  • Implement production code across all phases: the Enketo widget, GeolocationService,
    form.service.ts changes, contacts-edit.component.ts changes, CSS, and translation
    strings
  • Write tests following a TDD approach: failing tests were written first, then production
    code was written to make them pass
  • Debug browser issues, including diagnosing that Enketo renders field-list groups as
    section.or-group-data (not .or-group) by inspecting the live DOM
  • Perform a preliminary code review via Claude's /code-review ultra command
  • Draft this PR description and the cht-docs documentation draft

All 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 to clinic-edit.xml to test the edit flow.

In the model instance, add inside the <clinic> node:

<geo_capture/>

In the binds, add:

<bind nodeset="/data/clinic/geo_capture" type="string" required="true()"/>

In the body, add a new field-list group between the primary contact page and the household naming page:

<group appearance="field-list" ref="/data/clinic">
  <input ref="/data/clinic/geo_capture" appearance="geolocation-capture">
    <label>Capture GPS location</label>
  </input>
</group>

2. Upload the config

From config/default/:

node /path/to/cht-conf/src/bin/index.js \
  --url=http://medic:password@localhost:5988 \
  upload-contact-forms

3. Prepare the browser

  • In Chrome, click the lock icon → Location → Allow for the happy path
  • Hard-reload (Cmd+Shift+R) and log in as a CHW user (not admin — geolocation is disabled for admin)
  • Navigate to People → health center → New Household for create paths
  • Navigate to People → health center → [existing household] → Edit for edit paths

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
  1. Open New Household, advance to the GPS page
  2. Verify the capture button is disabled
  3. Select Home — the button enables
  4. Click the capture button; progress bar fills and turns green
  5. Submit

Verify in CouchDB:

  • geolocation — coordinates object
  • geolocation_log[0].is_hometrue
  • geo_capture"captured"
Create: successful other-context capture

Same as above but select Other as context.

Verify in CouchDB:

  • geolocationabsent (only written for home captures)
  • geolocation_log[0].is_homefalse
  • geo_capture"captured"
Create: weak GPS signal message (codes 2 and 3)
  1. Set DevTools Sensors to Location unavailable (keep Chrome permission as Allow)
  2. Open New Household, select a context, click capture
  3. Progress bar turns red
  4. Verify the message "GPS signal is weak. Please move to an open area and try again." appears above the Retry button
Create: permission denied (code 1)
  1. Set Chrome location permission to Block
  2. Open New Household, select a context, click capture
  3. Progress bar turns red
  4. Verify no weak signal message appears; only Retry and Continue without location are shown
  5. Check the acknowledgement checkbox, then click Continue without location

Verify in CouchDB:

  • geolocation — absent
  • geolocation_log[0].recording{ "code": 1, "message": "User denied Geolocation" }
  • geo_capture"skipped"
Create: retry after failure then succeed
  1. Block location in Chrome and trigger a failure
  2. Re-allow location in Chrome settings
  3. Click Retry — progress bar resets and fills again, turns green on success
  4. Submit and verify geolocation_log[0].is_home is present in CouchDB
Edit: keep existing location

Requires a household with a previously captured home location.

  1. Open the household → Edit
  2. Verify the green "Location already saved" badge shows "Captured at: Home" and a last-updated date
  3. Verify "Keep existing location" is pre-selected
  4. Submit without changing the selection

Verify in CouchDB: geolocation and geolocation_log are unchanged from before the edit.

Edit: capture new location
  1. Open a household with an existing home location → Edit
  2. Select "Capture new location"
  3. Verify the warning box ("Are you at this household?") appears
  4. Check the acknowledgement checkbox — capture begins automatically
  5. GPS resolves → submit

Verify in CouchDB:

  • geolocation — new coordinates
  • geolocation_log — now has 2 entries; new entry has is_home: true
Edit: GPS fails, revert to keep/capture choice
  1. Block Chrome location permission
  2. Open a household with existing location → Edit
  3. Select "Capture new location", check the acknowledgement checkbox
  4. GPS fails — progress bar turns red
  5. Click Continue without location and verify it returns to the keep/capture radio choice rather than proceeding
  6. Submit with "Keep existing location"

Verify in CouchDB: geolocation is unchanged from before the edit.

Edit: contact with no prior location shows create mode

Requires a household where location was previously skipped.

  1. Open the household → Edit
  2. Verify the widget shows the standard capture UI (no badge, no keep/capture choice)
  3. Capture a location normally

Verify in CouchDB: geolocation now has coordinates; geolocation_log has one entry.

Open questions for reviewer

  1. 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 UI
    language be made more generic (currently references "household")?

  2. geo_capture present on failed-capture records: when GPS fails and the CHW
    continues without a location, geo_capture is set to "skipped" and saved as part
    of the form data. Is this the intended behaviour, or should the field be cleared?

  3. 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

  • UI/UX backwards compatible: The widget only activates on fields with
    appearance="geolocation-capture". Existing forms without this appearance are
    completely unaffected.
  • Readable: Concise, well named, follows the style guide; widget refactored into
    small helper functions (one per UI section)
  • Documented: Draft documentation written and available at feat(medic/cht-core#10676): store geolocation when submitting contact forms cht-docs#2221
  • Tested: Unit tests (form.service, geolocation.service, integration-api.service,
    contacts-edit.component, geolocation-widget), cht-form integration tests, and e2e tests
  • Internationalised: Widget UI strings in messages-*.properties for en, es, fr, ne,
    sw. ne and sw are machine-generated and flagged for native speaker review.
  • Backwards compatible: No changes to existing document structure or form behaviour.
    geolocation_log is a new field; existing documents without it are unaffected.
  • AI disclosure: Developed with Claude Code (Anthropic)

License

The software is provided under AGPL-3.0. Contributions to this project are accepted under
the same license.

@cliftonmcintosh cliftonmcintosh force-pushed the feature/10676-contact-form-geolocation-stored branch from cc8b8f0 to 69f2ec3 Compare June 4, 2026 16:10
@cliftonmcintosh cliftonmcintosh changed the title feat(#10676): Store geolocation when submitting contact forms feat(#10676): store geolocation when submitting contact forms Jun 4, 2026
@cliftonmcintosh cliftonmcintosh force-pushed the feature/10676-contact-form-geolocation-stored branch 5 times, most recently from 4a93b1a to 20b0fbc Compare June 9, 2026 22:09
Comment on lines +723 to +731
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added Spanish, French, Nepali and Swahili translations to pass the linter. I did this with the help of Claude. These should be verified.

@cliftonmcintosh cliftonmcintosh marked this pull request as ready for review June 9, 2026 22:39
@cliftonmcintosh

Copy link
Copy Markdown
Contributor Author

@dianabarsan Would you be available to review this PR? If not, would you be able to suggest a reviewer?

@dianabarsan dianabarsan self-requested a review June 10, 2026 04:24
@cliftonmcintosh

Copy link
Copy Markdown
Contributor Author

I'll move this back into draft state so that we can make updates based on the conversation from today.

@cliftonmcintosh cliftonmcintosh marked this pull request as draft June 10, 2026 13:59
@cliftonmcintosh cliftonmcintosh force-pushed the feature/10676-contact-form-geolocation-stored branch from 122b3d0 to 0209a61 Compare June 18, 2026 13:21
cliftonmcintosh and others added 16 commits June 20, 2026 08:08
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>
cliftonmcintosh and others added 18 commits June 24, 2026 09:44
…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>
@cliftonmcintosh

cliftonmcintosh commented Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

@dianabarsan

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:

  • Renamed the widget from GeolocationWidget to HouseholdGeolocationWidget to better reflect its purpose (your comment on the name).

  • Restored backwards-compatible geolocation write for report forms. Reports now unconditionally write doc.geolocation as before; the success-only guard applies only to contacts.

  • Set sentinel values ('denied', 'unavailable') on the hidden capture input when location is blocked by permissions or device capability. This allows forms with required="true()" on the field to still submit in those cases.

  • Treated service timeout (code -2) as a weak signal, alongside browser timeout (code 3). Users now see the "move to an open area" guidance instead of a bare failure message.

  • Fixed the orphaned currentPromise when form save fires before GPS acquires a position. The promise now resolves with a synthetic result so the widget's .then() handler fires correctly instead of hanging.

  • Switched all translation calls from Translate.get (Promise-based) to Translate.instant (synchronous), removing all Promise scaffolding from the widget.

  • Extracted all translation key strings as named constants in a TRANSLATION_KEYS object at the top of the widget file.

  • Replaced manual midnight arithmetic with moment for the badge date calculation. This was later removed when @kunwarLB advised leaving out the "days since" phrase entirely.

  • Collapsed setLastCaptureFromHomeLog and setLastCaptureFromOtherLog into a single parameterized helper.

  • Delegated isAvailable() and isPermissionDenied() checks to the geolocation service rather than reading from browser APIs directly in the widget.

  • Added an e2e test for the "Other" context capture path, asserting the log entry is written and doc.geolocation is absent.

  • Made the edit mode e2e test deterministic by testing only the GPS failure path, which is the predictable outcome in CI.

  • The geo_capture field (the widget's internal Enketo state holder) is now stripped from contact docs before saving to CouchDB so it does not persist on the contact record.

  • Removed the "Press here to capture" button. Selecting Home or Other now immediately starts GPS capture, simplifying the flow to a single user action.

  • After selecting a context, confirmation text ("GPS Location at: Home" or "GPS Location at: Other") appears above the progress bar so the user can see their choice throughout capture.

  • When editing a contact that has no prior location recorded, an informational message is now shown above the context options explaining that no location exists yet.

@dianabarsan dianabarsan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what would happen if the user wants to change their mind about the context? if they missclicked?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

@cliftonmcintosh cliftonmcintosh Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
    }
  ]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@cliftonmcintosh

Copy link
Copy Markdown
Contributor Author

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.

cliftonmcintosh and others added 5 commits July 1, 2026 07:14
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Store geolocation when submitting contact forms

2 participants