Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,7 @@ care-connect/
For production, point a wildcard subdomain to the server

- `*.example.com` → Care Connect app

## Further reading

- [`docs/holds-state-machine.md`](docs/holds-state-machine.md) — deflection lifecycle, `BedType` counter invariants, and how the chair-state UI maps onto subject statuses. Read before changing a lifecycle transition or any view that summarizes capacity.
4 changes: 2 additions & 2 deletions client/src/lesc/components/ChairAvailabilityCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { inflect } from 'inflection';

function ChairAvailabilityCard ({
availableChairs,
inTransitCount,
heldCount,
occupiedCount,
actionLabel,
onActionClick,
Expand All @@ -19,7 +19,7 @@ function ChairAvailabilityCard ({
</Title>
<Group gap={4} justify='center' wrap='wrap'>
<Text size='md' c='gray.6' ta='center'>
{inTransitCount} in transit
{heldCount} held
</Text>
<IconTallymark1 color='var(--mantine-color-gray-3)' size={20} />
<Text size='md' c='gray.6' ta='center'>
Expand Down
4 changes: 2 additions & 2 deletions client/src/lesc/components/care/Care.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ function Care () {
[notInCustodyDeflections]
);
const availableChairs = (bedTypes ?? facility.bedTypes ?? []).reduce((sum, bedType) => sum + (bedType.available ?? 0), 0);
const inTransitCount = (bedTypes ?? facility.bedTypes ?? []).reduce((sum, bedType) => sum + (bedType.inTransit ?? 0), 0);
const heldCount = (bedTypes ?? facility.bedTypes ?? []).reduce((sum, bedType) => sum + (bedType.holds ?? 0), 0);
const occupiedCount = (bedTypes ?? facility.bedTypes ?? []).reduce((sum, bedType) => sum + (bedType.occupied ?? 0), 0);

function handleScanSuccess () {
Expand Down Expand Up @@ -159,7 +159,7 @@ function Care () {
<Stack gap='lg'>
<ChairAvailabilityCard
availableChairs={availableChairs}
inTransitCount={inTransitCount}
heldCount={heldCount}
occupiedCount={occupiedCount}
actionLabel={user.roles?.includes('FACILITY_ADMIN') ? 'Manage capacity' : undefined}
onActionClick={() => navigate('/manage-capacity')}
Expand Down
6 changes: 3 additions & 3 deletions client/src/lesc/components/care/Care.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ vi.mock('@/FacilityContext', () => ({
facility: {
id: 7,
status: 'OPEN',
bedTypes: [{ id: 1, available: 2, inTransit: 2, occupied: 2, type: 'CHAIR' }],
bedTypes: [{ id: 1, available: 2, holds: 2, occupied: 2, type: 'CHAIR' }],
},
}),
}));
Expand Down Expand Up @@ -95,7 +95,7 @@ beforeEach(() => {
vi.clearAllMocks();

mockBedTypesIndex.mockResolvedValue({
data: [{ id: 1, available: 17, inTransit: 2, occupied: 2, type: 'CHAIR' }],
data: [{ id: 1, available: 17, holds: 2, occupied: 2, type: 'CHAIR' }],
});

mockDeflectionsList.mockImplementation(({ subjectStatus }) => {
Expand Down Expand Up @@ -139,7 +139,7 @@ describe('Care', () => {
renderCare();

expect(await screen.findByText('17 chairs available')).toBeInTheDocument();
expect(screen.getByText('2 in transit')).toBeInTheDocument();
expect(screen.getByText('2 held')).toBeInTheDocument();
expect(screen.getByText('2 occupied')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Manage capacity' })).toBeInTheDocument();
});
Expand Down
21 changes: 17 additions & 4 deletions client/src/lesc/components/custody/Custody.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Button, Card, Container, Group, SegmentedControl, Stack, Text, Title } from '@mantine/core';
import { Badge, Button, Card, Container, Group, SegmentedControl, Stack, Text, Title } from '@mantine/core';
import { DateTime } from 'luxon';
import { Head } from '@unhead/react';
import { useNavigate } from 'react-router';
Expand Down Expand Up @@ -361,7 +361,7 @@ function Custody () {
const hasTransit = (transitDeflections?.length ?? 0) > 0;
const hasInCustody = (inCustodyDeflections?.length ?? 0) > 0;
const availableChairs = (bedTypes ?? facility.bedTypes ?? []).reduce((sum, bedType) => sum + (bedType.available ?? 0), 0);
const inTransitCount = (bedTypes ?? facility.bedTypes ?? []).reduce((sum, bedType) => sum + (bedType.inTransit ?? 0), 0);
const heldCount = (bedTypes ?? facility.bedTypes ?? []).reduce((sum, bedType) => sum + (bedType.holds ?? 0), 0);
const occupiedCount = (bedTypes ?? facility.bedTypes ?? []).reduce((sum, bedType) => sum + (bedType.occupied ?? 0), 0);

useEffect(() => {
Expand Down Expand Up @@ -389,16 +389,29 @@ function Custody () {
<Stack gap='xl'>
<ChairAvailabilityCard
availableChairs={availableChairs}
inTransitCount={inTransitCount}
heldCount={heldCount}
occupiedCount={occupiedCount}
/>
<SegmentedControl
fullWidth
value={activeTab}
onChange={setTab}
withItemsBorders={false}
styles={{ label: { paddingInline: 4 } }}
data={[
{ label: 'Transit', value: 'transit' },
{
label: (
<Group gap={6} justify='center' wrap='nowrap'>
<span>Transit</span>
{transitDeflections && transitDeflections.length > 0 && (
<Badge size='sm' variant='filled' color='gray.6'>
{transitDeflections.length}
</Badge>
)}
</Group>
),
value: 'transit',
},
{ label: 'Custody', value: 'custody' },
{ label: 'Released', value: 'released' },
]}
Expand Down
25 changes: 22 additions & 3 deletions client/src/lesc/components/custody/Custody.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ vi.mock('@/FacilityContext', () => ({
facility: {
id: 6,
status: 'OPEN',
bedTypes: [{ id: 1, available: 3, inTransit: 2, occupied: 2, type: 'CHAIR' }],
bedTypes: [{ id: 1, available: 3, holds: 2, occupied: 2, type: 'CHAIR' }],
},
}),
}));
Expand Down Expand Up @@ -94,7 +94,7 @@ beforeEach(() => {
mockSessionStateValue.current = 'in-custody';

mockBedTypesIndex.mockResolvedValue({
data: [{ id: 1, available: 17, inTransit: 2, occupied: 2, type: 'CHAIR' }],
data: [{ id: 1, available: 17, holds: 2, occupied: 2, type: 'CHAIR' }],
});

mockDeflectionsList.mockImplementation(({ subjectStatus, status }) => {
Expand Down Expand Up @@ -210,10 +210,29 @@ describe('Custody', () => {
renderCustody();

expect(await screen.findByText('17 chairs available')).toBeInTheDocument();
expect(screen.getByText('2 in transit')).toBeInTheDocument();
expect(screen.getByText('2 held')).toBeInTheDocument();
expect(screen.getByText('2 occupied')).toBeInTheDocument();
});

it('shows a count badge on the Transit tab matching the number of in-transit deflections', async () => {
renderCustody();
await screen.findByText('17 chairs available');

const transitLabel = screen.getByText('Transit').closest('label');
// Fixture returns 3 deflections under DETAINED,ONSITE_AWAITING_TRANSFER.
expect(transitLabel).toHaveTextContent('3');
});

it('hides the Transit tab badge when there are no in-transit deflections', async () => {
mockDeflectionsList.mockImplementation(() => Promise.resolve({ data: [] }));

renderCustody();
await screen.findByText('17 chairs available');

const transitLabel = screen.getByText('Transit').closest('label');
expect(transitLabel?.textContent?.trim()).toBe('Transit');
});

it('shows the Take custody button on the Released tab', async () => {
mockSessionStateValue.current = 'released';

Expand Down
116 changes: 116 additions & 0 deletions docs/holds-state-machine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Holds state machine

Reference for the deflection lifecycle, the `BedType` counters, and the chair-state UI. Read this before adding a new lifecycle transition, a new counter, or a new view that summarizes capacity.

## Conservation invariant

For every `BedType`, this must hold at all times:

```
capacity = unavailableUnoccupied + unavailableOccupied + occupied + holds + available
```

Every chair sits in exactly one of those five buckets. If you're adding a transition or a counter, your job is to keep this true under every code path.

The chair-availability card displays *online capacity*:

```
online = capacity - unavailableUnoccupied - unavailableOccupied
= occupied + holds + available
```

So the card's three numbers (`available`, `held`, `occupied`) sum to online capacity.

`inTransit` is **not** part of this partition — it's a tracking counter that overlaps `holds` (specifically, the DETAINED-status subset). See "Surprises" below.

## State → bucket mapping

| `subjectStatus` | hold status | Bucket | Counter(s) |
| --------------------------------------- | ------------------- | --------- | ------------------------ |
| `DETAINED` | `ACTIVE` | Held | `holds`, `inTransit` |
| `ONSITE_AWAITING_TRANSFER` | `ACTIVE` | Held | `holds` |
| `AWAITING_INTAKE` | `ACTIVE` | Held | `holds` |
| `READY_FOR_INTAKE` | `ACTIVE` | Held | `holds` |
| `FAILED_INTAKE` | `ACTIVE` | Held | `holds` |
| `IN_MEDICAL_INTAKE` | `ACTIVE` | Held | `holds` |
| `IN_CHAIR` | `ACTIVE` | Occupied | `occupied` |
| `RELEASED` | `ACTIVE` | Occupied | `occupied` (path-dependent — see Surprises) |
| `EXITED` | `COMPLETED` | — | freed → `available` |
| `DEATH_IN_FACILITY` / `DEATH_IN_CUSTODY`| `COMPLETED` | — | freed → `available` |
| (any) | `CANCELLED` / `EXPIRED` | — | freed → `available` |

Plus the offline buckets (admin-controlled, see "Counter mutations" below):

| Chair condition | Bucket | Counter |
| ---------------------------- | -------- | ---------------------- |
| Chair offline, empty | Offline | `unavailableUnoccupied`|
| Chair offline, person inside | Offline | `unavailableOccupied` |

## Surprises

Two facts that are easy to miss and have caused bugs.

**1. `inTransit` ⊂ `holds`, not a peer counter.**
A new `DETAINED` hold increments **both** `holds` and `inTransit`. The arrival transition (`DETAINED → ONSITE_AWAITING_TRANSFER`) decrements only `inTransit`; the chair stays in `holds` until intake completes. So:

- To answer "how many chairs are reserved (any pre-chair state)?", read `holds`.
- To answer "how many people are physically traveling?", read `inTransit`.

If you display both, label them carefully — they overlap.

**2. `RELEASED` is path-dependent.**
The same `subjectStatus` can mean different things depending on how it was reached:

- *Sobered from a pre-chair hold* (`DETAINED`, `AWAITING_INTAKE`, etc.) → finalizes immediately as `EXITED`. Chair is freed. No lingering `RELEASED` row exists.
- *Sobered from `IN_CHAIR`* → lingers as `ACTIVE` / `RELEASED`. Chair is still `occupied` until a separate exit transition.
- *Medical / behavioral-health / "other" release* → finalizes immediately as `EXITED` regardless of starting state.

So a row with `subjectStatus = RELEASED` is always still in a chair (counted as `occupied`); a row with `subjectStatus = EXITED` is always freed. Don't infer chair state from `subjectStatus` alone — read the counters.

## Tabs vs. chair-state — orthogonal axes

The Custody page surfaces two partitions of the same data, on different axes:

- **Chair-state** (in `ChairAvailabilityCard`): partitions chairs into Available / Held / Occupied. Conserves to online capacity. Answers *can I accept another?*
- **People-state** (in the Custody page tabs): partitions active deflections into Transit / Custody / Released. Answers *who needs my attention?*

The tab partition does **not** align with the chair partition. The tabs are defined as:

```
Transit → subjectStatus ∈ { DETAINED, ONSITE_AWAITING_TRANSFER }
Custody → subjectStatus ∈ { AWAITING_INTAKE, FAILED_INTAKE, READY_FOR_INTAKE, IN_MEDICAL_INTAKE, IN_CHAIR }
Released → subjectStatus ∈ { RELEASED, EXITED }
```

So:

- *Transit tab* is a strict subset of *Held*.
- *Custody tab* spans *Held* (pre-chair statuses) and *Occupied* (`IN_CHAIR`).
- *Released tab* spans *Occupied* (`RELEASED`-still-in-chair) and freed-from-counters (`EXITED`).

This is intentional. If you find yourself trying to make one display answer both questions, separate them.

## Where the counter mutations live

Counters are stored on `BedType` (see `server/prisma/schema.prisma`) and mutated transactionally by the lifecycle routes in `server/routes/api/deflections/`. To find the counter change for any transition, read the route that performs it.

Quick index:

| File | Transition |
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `deflections/create.js`, `incidents/create.js` | New hold: `holds++`, `inTransit++`, `available--` |
| `deflections/cancel.js` | Cancel: `holds--` (and `inTransit--` if was `DETAINED`), `available++`|
| `deflections/reopen.js` | Reopen cancelled/expired: inverse of cancel |
| `facilities/_facilityId/arrived.js` | `DETAINED → ONSITE_AWAITING_TRANSFER`: `inTransit--` only |
| `deflections/transfer.js`, `admit.js`, `safety-check.js` | Within-`holds` transitions: no counter change |
| `deflections/intake-complete.js` (success) | `IN_MEDICAL_INTAKE → IN_CHAIR`: `holds--`, `occupied++` |
| `deflections/release.js` | Release: depends on previous state (see Surprises) |
| `deflections/exit.js`, `exit-to-jail.js` | Exit: `occupied--`, `available++` |
| `deflections/record-death.js` | Death: frees the chair, decrements whichever counter held it |
| `facilities/_facilityId/bed-types/update.js` | Admin capacity change. May auto-cancel in-transit holds to balance. |

**Admin override.** `server/routes/api/facilities/_facilityId/bed-types/{create,update}.js` accept the counters directly via `BedType.UpdateSchema`, gated by `requireFacilityAdmin`. This is the only path that can move chairs into `unavailableOccupied`; no normal lifecycle route writes to it.

## Maintenance

Treat this doc as part of the contract. If you add or change a state, transition, or counter, update the relevant section. The conservation invariant is the load-bearing claim — if a future change breaks it, fix the change, not the doc.
4 changes: 4 additions & 0 deletions server/models/bedType.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// BedType counters partition every chair into available / held / occupied / two
// offline buckets. The invariant and per-transition mutations live in
// docs/holds-state-machine.md — read it before changing the schema or any
// counter-touching route.
import prismaPkg from '@prisma/client';
import { z } from 'zod';

Expand Down
3 changes: 3 additions & 0 deletions server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,9 @@ model AdminSecurityEvent {
@@index([targetUserId, createdAt])
}

// Counters here partition every chair into available / held / occupied / two
// offline buckets. Conservation invariant and per-transition mutations live in
// docs/holds-state-machine.md — read before adding fields or wiring new routes.
model BedType {
id String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
Expand Down
Loading