frontend phase 3: geography — addresses, map-pin picker & nurse coverage areas
Three domain services (mirroring the patients/nurse template): services/geography (cached province→city→district lookups; Infinity staleTime + shared geographyKeys), services/addresses (address book CRUD + set-primary; single-primary invariant), and services/serviceAreas (coverage add/remove; areaExists dup-guard, districtId=null = whole city). Four tested composites in src/components/geography: CascadingRegionSelect (drives the cascade queries), AddressMapPicker (map-pin stand-in emitting real lat/lng), AddressForm, AddressCard. Screens: customer address book (/addresses, reached from the profile hub) and nurse coverage editor (/nurse/coverage, new sidebar tab, inline duplicate block + 409). Adds geo/address/coverage i18n namespaces (both locales), location/delete/coverage icons, ADDRESSES/NURSE_COVERAGE routes. Consumes the b4 geography-addresses contract; filed REQ-008 (accept the map pin on create/update) and REQ-009 (provinceId on CustomerAddressDto) for gaps. Gate: npm run check + npm run test:ci (129, +17) + npm run build all green. A 5-dimension adversarial review fixed 3 findings (map-marker RTL transform, page_size→pageSize pagination casing, coverage districts-scope dead-end on district-less cities). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,32 @@ for awareness.
|
||||
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
|
||||
-->
|
||||
|
||||
## frontend-phase-3-b4 — Addresses, map picker & nurse coverage areas — 2026-07-03
|
||||
- **Shipped:** three domain services — `services/geography` (cached province→city→district reference lookups;
|
||||
**Infinity `staleTime`** + shared `geographyKeys`; `useProvinces`/`useCities`/`useDistricts`; seam+mock+client),
|
||||
`services/addresses` (address book CRUD + set-primary; single-primary invariant; every mutation invalidates
|
||||
the list; `useAddresses`/`useCreateAddress`/`useUpdateAddress`/`useDeleteAddress`/`useSetPrimaryAddress`),
|
||||
`services/serviceAreas` (coverage add/remove; `areaExists` dup-guard; `useServiceAreas`/`useAddServiceArea`/
|
||||
`useRemoveServiceArea`). Shared composites (`src/components/geography/`, each tested): `CascadingRegionSelect`
|
||||
(drives the cached cascade), `AddressMapPicker` (map-pin **stand-in** emitting real lat/lng), `AddressForm`,
|
||||
`AddressCard`. Screens: **customer address book** (`/addresses`, reached from a profile-hub link — cascade +
|
||||
map pin dialog, set-primary, delete, empty/skeleton), **nurse coverage editor** (`/nurse/coverage`, new
|
||||
sidebar tab — chips, whole-city/specific-district scope toggle, inline duplicate block + 409, "won't appear
|
||||
in search" empty warning). Added `geo`/`address`/`coverage` i18n namespaces + `nav.coverage` (both locales);
|
||||
`location`/`delete`/`coverage` icons; routes `ADDRESSES`/`NURSE_COVERAGE`.
|
||||
- **Consumes:** dev/contracts/domains/geography-addresses.md (backend-phase-4). Routes `api/v1/geo/{provinces,
|
||||
cities,districts}`, `api/v1/customer_addresses/{list,create,update,set_primary,delete}`, `api/v1/
|
||||
nurse_service_areas/{list,add,remove}`. Wire camelCase; geo query params snake_case; 409 on duplicate coverage.
|
||||
- **Mocked client-side:** `services/geography` (`USE_GEOGRAPHY_MOCK`), `services/addresses` (`USE_ADDRESSES_MOCK`),
|
||||
`services/serviceAreas` (`USE_SERVICE_AREAS_MOCK`) — all default `true`; real clients wired for a one-line flip.
|
||||
The `AddressMapPicker` is a stand-in (no real map tiles). See mocks-registry + the report.
|
||||
- **Reviewed:** 5-dimension adversarial review → 3 findings fixed (map marker RTL transform; `page_size`→`pageSize`
|
||||
pagination casing on the real list calls; coverage "districts" dead-end on a district-less city).
|
||||
- **Gate:** npm run check green · npm run test:ci green (129 tests, +17 across 5 suites) · npm run build green
|
||||
with NEXT_PUBLIC_API_URL set (routes /addresses, /nurse/coverage generated).
|
||||
- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-008 accept the map pin on address create/update,
|
||||
REQ-009 `provinceId` on `CustomerAddressDto` for edit prefill).
|
||||
|
||||
## frontend-phase-2-b3 — Onboarding & profiles (customer, patient, nurse, bank) — 2026-07-02
|
||||
- **Shipped:** three domain services — `services/patients` (rewritten to the b3 `PatientDto` + client-augmented
|
||||
`relation`/`conditions`; full CRUD seam + mock + real client; `usePatients`/`useCreatePatient`/
|
||||
|
||||
@@ -94,3 +94,29 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
||||
returns `roles` but no user `id` (only `/me` has it) — fine for now (context id is hydrated from `/me`),
|
||||
flagging in case that changes.
|
||||
- **Status:** open
|
||||
|
||||
## REQ-008 — Accept the client-picked map pin on address create/update — filed by frontend-phase-3-b4 — 2026-07-02
|
||||
- **Need:** Let `customer_addresses/create` and `customer_addresses/update/{id}` accept optional
|
||||
`latitude`/`longitude` (decimals) from the request body — the coordinates the user dropped with the map-pin
|
||||
picker — and persist those when provided, only falling back to the `IGeocoder` when the client sends none.
|
||||
- **Why:** The b4 contract's create body geocodes server-side from `addressLine`+city and does **not** accept
|
||||
client coordinates, but f3 requires the user to **drop a pin** on the map (a hard client-side validation) so
|
||||
the stored coordinate is the user's exact door location for the later EVV distance check (b9) — a geocoded
|
||||
street centroid is coarser. The client already sends `latitude`/`longitude` in the create/update body and
|
||||
echoes them locally; until the server accepts them, the real path silently ignores them and geocodes instead.
|
||||
- **Proposed shape:** create/update body gains `latitude?: number, longitude?: number`; when both present, store
|
||||
them (and mark the geocode source as "user-pin"); when absent, geocode as today. `CustomerAddressDto` already
|
||||
returns `latitude`/`longitude`.
|
||||
- **Status:** open
|
||||
|
||||
## REQ-009 — Add `provinceId` to `CustomerAddressDto` — filed by frontend-phase-3-b4 — 2026-07-02
|
||||
- **Need:** Add `provinceId` (long) to `CustomerAddressDto` (the province that owns the address's `cityId`).
|
||||
- **Why:** The address book's **edit** form prefills the cascading province → city → district dropdowns from a
|
||||
saved address, and the city list is fetched **per province** (`geo/cities?province_id=`). The DTO carries
|
||||
`cityId` but not its province, so the client can't drive the city query to preselect the city without the
|
||||
province id. The client currently augments `provinceId` behind the `services/addresses` seam (the mock
|
||||
persists it; the real client echoes the just-saved choice), so editing an address that was **loaded fresh from
|
||||
the server** can't prefill the province until this lands. `cityId` still implies the province server-side —
|
||||
this is purely to prefill the client cascade.
|
||||
- **Proposed shape:** `CustomerAddressDto { …, provinceId: long }` (join from `cities.province_id`).
|
||||
- **Status:** open
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Frontend Phase 3 — Addresses, map picker & nurse coverage areas (b4)
|
||||
|
||||
**Track:** frontend · **Consumes:** [`dev/contracts/domains/geography-addresses.md`](../../contracts/domains/geography-addresses.md) (backend-phase-4)
|
||||
· **Unlocks:** f7 booking request (needs a chosen address) + f6 search (needs nurse coverage areas)
|
||||
· **Date:** 2026-07-03 · **Gate:** `npm run check` green · `npm run test:ci` green (129 tests, +17 across 5 new suites) · `npm run build` green (routes `/[locale]/addresses`, `/[locale]/nurse/coverage` generated)
|
||||
|
||||
## What shipped
|
||||
|
||||
Both actors get their *place* on the map — pure geography, no money, no clinical data.
|
||||
|
||||
### Three domain services (mirroring the `patients`/`nurse` template)
|
||||
- **`services/geography`** — the cached province → city → district reference lookups. `types.ts`
|
||||
(`Province`/`City`/`District` + the `GeographyApi` seam), `keys.ts` (`geographyKeys.{provinces,cities,districts}`),
|
||||
`constants.ts` (`USE_GEOGRAPHY_MOCK`, **`GEO_STALE_TIME = Infinity`** + `GEO_GC_TIME`, `CITY_CENTROIDS` +
|
||||
`cityCentroid()`), `names.ts` (`pickRegionName`), `apis/{clientApi,mockApi,seed,index}.ts`, hooks
|
||||
`useProvinces`/`useCities`(enabled on province)/`useDistricts`(enabled on city). **Aggressively cached**: each
|
||||
level is fetched once per session and shared across both editors (and later f6 search).
|
||||
- **`services/addresses`** — the customer address book. `types.ts` (`CustomerAddress` = wire `CustomerAddressDto`
|
||||
+ client-augmented `provinceId`; `CreateAddressInput`; `AddressesApi`), `keys.ts`, `constants.ts`, full
|
||||
seam+mock+client, hooks `useAddresses`/`useCreateAddress`/`useUpdateAddress`/`useDeleteAddress`/
|
||||
`useSetPrimaryAddress` — **every mutation invalidates `addressKeys.lists()`** (set-primary flips two rows, so
|
||||
invalidate, don't hand-patch).
|
||||
- **`services/serviceAreas`** — the nurse coverage areas. `types.ts` (`NurseServiceArea`; `AddServiceAreaInput`;
|
||||
`ServiceAreasApi`; the pure **`areaExists`** dup-guard), `keys.ts`, `constants.ts`, seam+mock+client, hooks
|
||||
`useServiceAreas`/`useAddServiceArea`/`useRemoveServiceArea` (add/remove invalidate the list).
|
||||
|
||||
### Two shared composites + two form/card composites (`src/components/geography/`, each tested)
|
||||
- **`CascadingRegionSelect`** — province → city → district dependent MUI selects, **driving the cached geography
|
||||
queries itself** so both editors drop it in with only `value`/`onChange`. Each level enables on its parent,
|
||||
resets its children on change; a whole-city-only city surfaces the **whole-city** affordance; district is
|
||||
optional. Guards edit-prefill against out-of-range values before options load.
|
||||
- **`AddressMapPicker`** — the **map-pin stand-in** (see Mocks). A tappable/draggable marker canvas that emits
|
||||
real `{ latitude, longitude }`; marker positioned with inline `style` (physical left/top) + `dir="ltr"` so the
|
||||
RTL stylis plugin can't mirror the pin off its click point. Centres on the chosen city's centroid.
|
||||
- **`AddressForm`** — the add/edit body: cascade + map pin + title + street + set-primary toggle; validates
|
||||
**city required, pin required**, title + street required, district optional. Emits `CreateAddressInput`.
|
||||
- **`AddressCard`** — presentational list card: title + primary badge (reuses the f0 `StatusChip`), region label,
|
||||
street line, edit/delete/set-primary; set-primary shows only on non-primary cards (never two primaries).
|
||||
|
||||
### Screens
|
||||
- **Customer address book** (`/addresses`, reached from a profile-hub link) — cards with primary badge, add/edit
|
||||
dialog (cascade + map pin), set-primary, soft-delete confirm, empty + skeleton states.
|
||||
- **Nurse coverage editor** (`/nurse/coverage`, new sidebar tab) — area chips (whole-city shown explicitly), an
|
||||
add control (cascade + whole-city/specific-districts scope toggle), **inline duplicate block** (client
|
||||
`areaExists` fast path + the server 409 mapped to the same message), remove confirm, and the empty-state
|
||||
**"won't appear in search"** warning.
|
||||
|
||||
### Wiring
|
||||
- `constants/routes.ts`: `ADDRESSES`, `NURSE_COVERAGE`. `AppIcon` registry: `location`, `delete`, `coverage`.
|
||||
- i18n: new `geo`/`address`/`coverage` namespaces + `nav.coverage` in **both** `en.json` and `fa.json` (identical
|
||||
key sets, RTL-first). Colours from `tokens.css` only.
|
||||
- `client/CLAUDE.md` Project Structure + i18n namespaces + the reference-data caching convention updated.
|
||||
|
||||
## What is now testable, and exactly how
|
||||
|
||||
Run `cd client && npm run dev`, sign in (f1-b2 OTP). All three services default to their client mock, so the
|
||||
flows work **without the backend running**.
|
||||
1. **Cascading dropdowns + caching.** Customer → Profile → *Manage addresses* → *Add address*. Province → city →
|
||||
district cascade; a whole-city-only city (Mashhad/Isfahan/…) shows the whole-city affordance. Re-open *Add
|
||||
address*: the lists come **from cache** (React Query Devtools shows no refetch).
|
||||
2. **Add with a map pin + set primary.** Pick city (+ optional district), **drop a pin**, enter title + street,
|
||||
toggle primary, save → the card shows a primary badge. Add a second, set *it* primary → exactly one badge
|
||||
moves. Save without a city or pin → inline errors.
|
||||
3. **Nurse coverage + duplicate block.** Nurse → *Coverage*. Empty → the "won't appear in search" warning. Add a
|
||||
**whole-city** area (a chip); add a **city + district** area; add the **same** pair again → inline "already
|
||||
covered", no request fired; remove → the chip disappears.
|
||||
4. **i18n / RTL.** Flip `fa`↔`en`: every label/empty/error/duplicate string translates; the cascade, chips, and
|
||||
map controls mirror correctly; colours match the brand tokens.
|
||||
5. `npm run check`, `npm run test:ci`, `npm run build` all pass.
|
||||
|
||||
## What is mocked client-side (and how f-next swaps it)
|
||||
|
||||
All three services are behind a `services/{domain}` seam with a `USE_*_MOCK` flag (**default `true`**) and a
|
||||
real `clientApi` already wired to the contract routes — the swap is a one-line flag flip per service. The
|
||||
**`IGeocoder`** seam is backend-owned (backend-phase-4); the client only sends the picked coordinates. The
|
||||
`AddressMapPicker` is a **stand-in** (no Neshan/Google tiles) behind a component boundary — a real map drops in
|
||||
without touching `AddressForm`. See the **mock registry** for the exact rows + make-it-real steps.
|
||||
|
||||
## Contract consumed + gaps filed
|
||||
|
||||
Consumed `dev/contracts/domains/geography-addresses.md` (camelCase wire, snake_case query params, action-style
|
||||
routes, 409 on duplicate coverage, single-primary address). Two gaps filed in
|
||||
[`for-backend.md`](../frontend/requests/for-backend.md):
|
||||
- **REQ-008** — accept the client-picked `latitude`/`longitude` on address create/update (the contract geocodes
|
||||
server-side today; f3 requires the user's dropped pin for EVV precision). The client sends them + echoes locally.
|
||||
- **REQ-009** — add `provinceId` to `CustomerAddressDto` so the edit form can prefill the province→city cascade
|
||||
(the city list is fetched per-province). Client-augmented behind the seam meanwhile.
|
||||
|
||||
## Adversarial review (pre-merge)
|
||||
|
||||
Ran a 5-dimension multi-agent review (contract fidelity · conventions/i18n · single-primary · coverage
|
||||
dedup/cascade · map/RTL/caching) with an adversarial verify pass. **3 findings confirmed and fixed:**
|
||||
1. **(high, RTL)** `AddressMapPicker` marker's centering `transform` was in `sx`, so stylis-plugin-rtl
|
||||
flipped its X on the default `fa` locale — the pin rendered ~one icon-width off the tap point. Moved the
|
||||
transform into the inline `style` alongside the left/top offsets.
|
||||
2. **(med, contract)** `addresses`/`serviceAreas` `list` sent `page_size` (snake_case), which the server's
|
||||
`PageSize` binder ignores (only the geo lookups are explicitly snake_cased) → truncated lists once the real
|
||||
endpoints are used. Changed both to `pageSize`, matching the patients template.
|
||||
3. **(med, UX)** The coverage "specific districts" scope dead-ended on a whole-city-only city (district-less)
|
||||
with contradictory `no_districts`↔`district_required` messages. The page now reads the cached districts to
|
||||
force whole-city for such cities (disables the "districts" toggle), so the add never dead-ends.
|
||||
|
||||
## Follow-ups for later phases
|
||||
- **f7 booking request** consumes the chosen customer address (id + coordinates).
|
||||
- **f6 search** consumes nurse coverage areas (whole-city rows match every district; reuse the `geography` cache
|
||||
+ `geographyKeys`, don't reinvent). The same-gender filter is f6, not here.
|
||||
- When REQ-008/REQ-009 land, flip `USE_ADDRESSES_MOCK` to `false`.
|
||||
- Swap the `AddressMapPicker` stand-in for a real map (mock registry row).
|
||||
Reference in New Issue
Block a user