Skip to content

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

Open
cliftonmcintosh wants to merge 140 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 140 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 24 commits July 18, 2026 16:46
…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>
…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]);

@cliftonmcintosh cliftonmcintosh Jul 23, 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.

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

cliftonmcintosh and others added 4 commits July 23, 2026 12:18
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>
@cliftonmcintosh

Copy link
Copy Markdown
Contributor Author

@dianabarsan

I believe this is ready for re-review. A lot has changed since your earlier review. And it is SO LARGE!!!!

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