Skip to content

feat(custody): temporary custody — hooks, date picker, request form and dashboard - #808

Open
Mikolo37 wants to merge 6 commits into
amina69:mainfrom
Mikolo37:feat/custody-dashboard
Open

Mikolo37 wants to merge 6 commits into
amina69:mainfrom
Mikolo37:feat/custody-dashboard

Conversation

@Mikolo37

Copy link
Copy Markdown

Epic D — Temporary Custody · Phase 3 backlog items #33, #34, #35 and #36.

Closes #628
Closes #629
Closes #630
Closes #631


Summary

This PR delivers the Temporary Custody flow end to end: the query and
mutation hooks, a keyboard-accessible date range picker, the two-step
custody request form and the dashboard.

The work is split over four focused commits, one per backlog item. The
commit order is hooks → picker → form → dashboard because the request
form builds on the picker; the items themselves are listed below in backlog
order.

Item Issue Commit Delivers
#33 #628 feat(custody) useCustodyList, useCustodyDetail, useRequestCustody, useExtendCustody, useEndCustody
#35 #630 feat(forms) src/components/forms/DateRangePicker.tsx + src/lib/dateOnly.ts
#34 #629 feat(custody) src/features/custody/components/CustodyRequestForm.tsx on /pets/:petId/custody/new
#36 #631 feat(custody) src/pages/CustodyDashboardPage.tsx + src/features/custody/components/CustodyCard.tsx

#628 — Custody query and mutation hooks

src/features/custody/hooks/

Hook Query key Notes
useCustodyList(params) ["custody", params] one cache entry per filter/page combination
useCustodyDetail(custodyId) ["custody-detail", custodyId] reads the escrow snapshot alongside the arrangement
useRequestCustody() — creates an arrangement and navigates to it
useExtendCustody(custodyId) — moves the end date
useEndCustody(custodyId) — ends the arrangement early

Invalidation scope (the acceptance criterion)

The list and the detail deliberately live under separate roots
(["custody"] vs ["custody-detail", id]), so a mutation can invalidate
exactly the keys it affects:

Mutation Invalidated Untouched
useRequestCustody every list variant, the created arrangement's detail other details, escrow caches, other domains
useExtendCustody every list variant, that arrangement's detail other details, escrow caches, other domains
useEndCustody every list variant, that arrangement's detail other details, escrow caches, other domains

This is asserted in
src/features/custody/hooks/__tests__/useCustodyMutations.test.tsx: the
cache is seeded with the affected and the unaffected keys (a second list
variant, another arrangement's detail, an escrow cache, an adoption cache
and a disputes cache) and the test checks which of them end up invalidated.

Escrow data

useCustodyDetail reads the escrow from the existing escrow endpoint
(GET /escrow/:id/status, now exposed as escrowService.getStatus) inside
the same cache entry as the arrangement, so invalidating the detail key
refreshes both. The escrow state is therefore always server-sourced — it is
never derived client-side from the arrangement's own status/escrowStatus.
A test proves it: the arrangement claims escrowStatus: "FUNDED" while the
escrow endpoint reports RELEASED, and the hook surfaces RELEASED.

CustodyArrangement gained an optional escrowId; the arrangement id is
used as a fallback when the API does not expose one (the same convention the
settlement summary page already uses for adoptions).


#630 — Date range picker with availability conflicts

src/components/forms/DateRangePicker.tsx + src/lib/dateOnly.ts

  • Keyboard accessible: arrow keys move the focus by a day/week, Home/End
    jump to the start/end of the week, PageUp/PageDown change the month and
    Enter/Space pick a day. The grid follows the grid → row →
    gridcell structure and announces the selected days with aria-pressed.
  • Disabled days: days before minDate (today by default) and the days
    the API reported as unavailable are rendered as disabled.
  • Blocked ranges: a range that would cross an unavailable day is rejected
    with an explanatory message ("The selected period includes 12 Jun 2026,
    which is not available. Pick another end date.").
  • Nights count: the length of the selected period is shown.

No off-by-one around DST and UTC-offset boundaries

A custody period is a range of calendar days, not a range of instants, so
every date is a date-only string ("YYYY-MM-DD"). src/lib/dateOnly.ts
never uses new Date("2026-06-20") — that is parsed as UTC midnight and is
the previous day west of Greenwich — and the day arithmetic runs on UTC
calendar-day ordinals, so a daylight-saving switch can neither shorten nor
lengthen a day.

src/lib/__tests__/dateOnly.test.ts re-runs the arithmetic in six time zones
(UTC, Europe/Berlin, America/New_York, Asia/Tokyo, UTC+14 and UTC-11) and
asserts the results never change — including across both the US and the EU
daylight-saving edges (8 March and 1 November 2026, 29 March and 25 October
2026).


#629 — Custody request form

src/features/custody/components/CustodyRequestForm.tsx on
/pets/:petId/custody/new

  • Pet summary header — the pet the request is for.
  • Custody period — the date range picker, with the days the API reported
    as unavailable disabled and a range crossing one of them blocked.
  • Caretaker — a select of the people who can look after the pet, plus a
    "somebody else" option that asks for a name.
  • Emergency contact and notes — both optional.
  • Confirmation step — summarises the period, the caretaker and the
    emergency contact, and explains what happens next (the caretaker is
    notified, an escrow is created, the period starts on the first day).

The payload is validated with custodyRequestSchema before the request
is sent, so an invalid form never reaches the network (asserted in
CustodyRequestForm.test.tsx — the POST /custody handler is never called).
Field-level errors are rendered inline next to the field they belong to, and
the submit button is disabled while the request is pending.

Supporting changes

  • GET /custody/availability — the days a pet is already booked for, plus
    the useCustodyAvailability hook that reads them. The query lives under
    the custody list root, so it is refreshed by the same invalidation.
  • petService.getPet() and GET /api/pets/:id — the pet summary header
    needs the pet.
  • emergencyContact is now part of the custody request payload.

#631 — Custody dashboard

src/pages/CustodyDashboardPage.tsx on /custody +
src/features/custody/components/CustodyCard.tsx

  • Tabs: Active / Upcoming / Past. Each tab maps to the statuses the API
    can be asked for (active/disputed, requested/approved,
    completed/cancelled); the list endpoint now accepts a comma-separated
    status filter, so a tab is always filtered by the server.
  • URL: the active tab and the page live in the URL
    (/custody?tab=upcoming&page=2), so a view can be reloaded or shared.
    Switching tab resets the page.
  • Cards: the pet, the counterpart, the dates, the status badge and the
    number of days left (or the days until the period starts, or how long ago
    it ended).
  • Perspective: an owner sees the card written from the owner's side
    ("Caretaker: Jane Doe", "You are the owner"), a caretaker sees it from the
    caretaker's side ("Owner: Sam Smith"). A viewer who is neither party gets
    a neutral card without a perspective label.

About useCurrentUser

The issue refers to useCurrentUser (backlog item #55, issue #650), which
is still open and owned by another contributor. Rather than duplicate that
work, this PR adds a small, custody-scoped stand-in —
useCustodyViewer (src/features/custody/hooks/useCustodyViewer.ts) — that
reads the signed-in user from the session the sign-in form mirrors into
localStorage (the same convention the app already uses for
petad_user_role). When #650 lands, useCustodyViewer should delegate to
it; nothing else has to change.


Also in this PR

  • fix(notifications) — mockNotificationStream.test.ts asserted that a
    freshly emitted mock notification is the first entry of
    GET /api/notifications. The seeded fixtures are stamped with fixed hours
    of the day (10:00 / 11:00 local), so the test failed whenever the suite ran
    before 11:00. It now looks the notification up by id. Unrelated to the
    custody work
    — included so the whole suite is green.
  • docs/custody.md — the routes, the query keys, the invalidation scope,
    the endpoints and the date-only conventions.

Testing

npm test        # 130 files, 1019 tests — all passing
npm run lint    # clean
npm run build   # clean (includes the MSW production check)

New tests:

  • src/features/custody/hooks/__tests__/useCustodyList.test.tsx
  • src/features/custody/hooks/__tests__/useCustodyDetail.test.tsx
  • src/features/custody/hooks/__tests__/useCustodyMutations.test.tsx
  • src/lib/__tests__/dateOnly.test.ts
  • src/components/forms/__tests__/DateRangePicker.test.tsx
  • src/features/custody/components/__tests__/CustodyRequestForm.test.tsx
  • src/features/custody/components/__tests__/CustodyCard.test.tsx
  • src/pages/__tests__/CustodyRequestPage.test.tsx
  • src/pages/__tests__/CustodyDashboardPage.test.tsx

How to review

The branch is a stack — each commit builds on the previous one:

  1. fix(notifications) — the unrelated test fix.
  2. feat(custody): add custody list/detail query hooks and scoped mutations ([033] Add custody query and mutation hooks #628)
  3. feat(forms): add a keyboard-accessible date range picker ([035] Date-range picker with availability conflicts #630)
  4. feat(custody): add the temporary custody request form ([034] Build CustodyRequestForm #629)
  5. feat(custody): add the temporary custody dashboard ([036] Build CustodyDashboardPage #631)
  6. docs(custody): document the temporary custody feature

The same four pieces of work are also pushed as standalone branches
(feat/custody-hooks, feat/date-range-picker, feat/custody-request-form
and feat/custody-dashboard) in case they are easier to review one by one.

… clock

`mockNotificationStream.test.ts` asserted that a freshly emitted mock
notification is the *first* entry of `GET /api/notifications`. The seeded
fixtures are stamped with fixed hours of the day (10:00 / 11:00 local), so
whenever the suite runs before 11:00 the fixture sorts ahead of the
emitted notification and the test fails.

Look the notification up by id instead of by position — the test's intent
("stores it for GET /api/notifications") is preserved and the failure goes
away.

This is unrelated to the custody work that follows; it is included so the
whole suite is green on this branch.
Epic D — Temporary Custody · Phase 3 backlog item amina69#33
Refs amina69#628

Adds the query and mutation hooks for the Temporary Custody domain.

### Hooks (`src/features/custody/hooks/`)

- `useCustodyList(params)` — the list, keyed by `["custody", params]`, one
  cache entry per filter/page combination.
- `useCustodyDetail(custodyId)` — a single arrangement, keyed by
  `["custody-detail", custodyId]`, with the escrow snapshot read **alongside**
  it from the existing escrow endpoint (`GET /escrow/:id/status`). The escrow
  state is always server-sourced; it is never derived client-side from the
  arrangement's own `status`/`escrowStatus`.
- `useRequestCustody()` — creates an arrangement and navigates to it.
- `useExtendCustody(custodyId)` / `useEndCustody(custodyId)` — extend or end
  an arrangement.

### Query keys

The list and the detail deliberately live under **separate roots**
(`["custody"]` vs `["custody-detail", id]`) so every mutation can invalidate
*exactly* the keys it affects:

| Mutation | Invalidated | Untouched |
| --- | --- | --- |
| `useRequestCustody` | every list variant, the created arrangement's detail | other details, escrow caches, other domains |
| `useExtendCustody` | every list variant, that arrangement's detail | other details, escrow caches, other domains |
| `useEndCustody` | every list variant, that arrangement's detail | other details, escrow caches, other domains |

The scope is asserted in `src/features/custody/hooks/__tests__/useCustodyMutations.test.tsx`
by seeding the cache with the affected *and* the unaffected keys and checking
which ones end up invalidated.

### Supporting changes

- `escrowService.getStatus()` — exposes the existing escrow status endpoint
  to the feature layer.
- `CustodyArrangement.escrowId` — the arrangement can now point at the
  escrow backing it (the arrangement id is used as a fallback).
- The MSW custody handlers now serve the whole Temporary Custody API
  (list / detail / request / extend / end) from a small in-memory store, and
  `CustodyStatusBadge` learned the lower-case Temporary Custody statuses.
Epic D — Temporary Custody · Phase 3 backlog item amina69#35
Refs amina69#630

Adds `src/components/forms/DateRangePicker.tsx` plus the calendar-date
helpers it is built on.

### `src/lib/dateOnly.ts`

Everything works on **date-only strings** (`"YYYY-MM-DD"`). A custody period
is a range of calendar days, not a range of instants, so the helpers avoid
`new Date(str)` for date-only input — that parses as *UTC* midnight and is
the previous day for anybody west of Greenwich. Day arithmetic runs on UTC
calendar-day ordinals, so a daylight-saving switch can never shorten or
lengthen a day.

### `DateRangePicker`

- Keyboard accessible: arrow keys move the focus by a day/week, `Home`/`End`
  jump to the start/end of the week, `PageUp`/`PageDown` change the month and
  `Enter`/`Space` pick a day. The grid follows the `grid` → `row` →
  `gridcell` structure and announces the selected days with `aria-pressed`.
- Days before `minDate` (today by default) and days the API reported as
  unavailable are rendered as disabled.
- A range that would cross an unavailable day is **blocked** and explained
  ("The selected period includes 12 Jun 2026, which is not available. Pick
  another end date.").
- The number of nights in the selected range is shown.

### Tests

`src/lib/__tests__/dateOnly.test.ts` re-runs the day arithmetic in six time
zones (UTC, Europe/Berlin, America/New_York, Asia/Tokyo, UTC+14 and UTC-11)
and asserts the results never change — including across both the US and the
EU daylight-saving edges. `src/components/forms/__tests__/DateRangePicker.test.tsx`
covers the disabled days, the blocked range, the nights count and the
keyboard interaction.
Epic D — Temporary Custody · Phase 3 backlog item amina69#34
Refs amina69#629

Adds the two-step custody request flow on `/pets/:petId/custody/new`.

### `CustodyRequestForm`

- **Pet summary header** — the pet the request is for.
- **Custody period** — the `DateRangePicker` from amina69#630, with the days the API
  reported as unavailable disabled and a range that crosses one of them
  blocked.
- **Caretaker** — a select of the people who can look after the pet, plus a
  "somebody else" option that asks for a name.
- **Emergency contact** and **notes** — both optional.
- **Confirmation step** — summarises the period, the caretaker and the
  emergency contact, and explains what happens next (the caretaker is
  notified, an escrow is created, the period starts on the first day).

The payload is validated with `custodyRequestSchema` **before** the request
is sent, so an invalid form never reaches the network. Field-level errors are
rendered inline next to the field they belong to, and the submit button is
disabled while the request is pending.

### Supporting changes

- `GET /custody/availability` — the days a pet is already booked for, plus
  the `useCustodyAvailability` hook that reads them. The query lives under
  the custody list root, so it is refreshed by the same invalidation.
- `petService.getPet()` and `GET /api/pets/:id` — the pet summary header
  needs the pet.
- `emergencyContact` is now part of the custody request payload.
- The sign-in form mirrors the signed-in user into `localStorage` so the app
  can tell which side of an arrangement the current user is on.
Epic D — Temporary Custody · Phase 3 backlog item amina69#36
Refs amina69#631

Adds the custody dashboard on `/custody`.

### Tabs and URL

Three tabs — **Active**, **Upcoming** and **Past** — each mapping to the
statuses the API can be asked for (`active`/`disputed`,
`requested`/`approved`, `completed`/`cancelled`). The list endpoint accepts a
comma-separated status filter, so a tab is always filtered by the server.

Both the active tab and the page live in the URL (`?tab=upcoming&page=2`),
so a view can be reloaded or shared. Switching tab resets the page.

### `CustodyCard`

Shows the pet, the counterpart, the dates, the status badge and the number
of days left (or days until the period starts, or how long ago it ended).

The **perspective** depends on the signed-in user: an owner sees the card
written from the owner's side ("Caretaker: Jane Doe", "You are the owner"),
a caretaker sees it from the caretaker's side ("Owner: Sam Smith"). A viewer
who is neither party gets a neutral card without a perspective label.

The signed-in user is read from the session the sign-in form mirrors into
`localStorage` (`useCustodyViewer`). That hook is a deliberately small,
custody-scoped stand-in: when the shared `useCurrentUser` hook (issue amina69#650)
lands it should delegate to it, and nothing else has to change.

### Supporting changes

- `CustodyArrangement` may now carry the display names (`petName`,
  `ownerName`, `caretakerName`); the card falls back to the ids when the API
  does not send them.
Describes the routes, the query keys (and the invalidation scope of every
mutation), the endpoints, the date-only conventions and how the signed-in
user is resolved.
@vercel

vercel Bot commented Oct 21, 2026

Copy link
Copy Markdown

@Mikolo37 is attempting to deploy a commit to the sheriffamina1-9497's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Oct 21, 2026

Copy link
Copy Markdown

@Mikolo37 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@amina69

amina69 commented Oct 22, 2026

Copy link
Copy Markdown
Owner

@Mikolo37 kindly fix the conflict

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants