Files
HealthLog/src/hooks/use-encounters.ts
T
Marc-André Bombeck 2d69842994 fix(encounters): the visit list carries its links
Two defects behind one omission. The card counts what a visit produced, so a
list that dropped the links made every card read zero for a visit with three
documents. And the edit sheet seeds its pickers from the row it was handed: an
edit opened from a linkless list row would save a replace-set of three empty
arrays and silently unfile everything the visit had collected.

The links now ride the list, resolved in three grouped queries for the whole
page through the batched arm of the same service the detail uses, with the same
per-family redaction from the same predicate. The single-visit read hook had no
caller and is gone with it — the list is where the surfaces read from.

Both defects get a test that would have caught them: the card counts assert
against a populated links object, the draft asserts the arrays survive a round
trip through draftToBody, and the integration file asserts the list response
itself carries every family.
2026-08-08 15:06:10 +02:00

139 lines
4.4 KiB
TypeScript

"use client";
/**
* Data hooks for visits and the address book behind them.
*
* Every value the surfaces render arrives resolved from the server — the
* practitioner as an object, the links with a label and a date, the
* appointment's next-due instant computed. Nothing here re-derives one. A
* client that recomputed a server-side derivation drifts from it the first
* time the derivation changes on one side only.
*
* Writes reach two roots (`encounterDependentKeys`), because renaming a
* practice changes the label every visit that names it renders.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
import {
encounterDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto";
import type { EncounterSuggestionResult } from "@/lib/encounters/suggest-window";
export type Encounter = EncounterDTO;
export type EncounterList = EncounterListDTO;
export interface EncounterWriteBody {
occurredAt?: string;
status?: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW";
kind?: string;
practitionerId?: string | null;
reason?: string | null;
outcome?: string | null;
/** The checkup this visit closes, when it was filed from a due one. */
reminderId?: string | null;
documentIds?: string[];
labResultIds?: string[];
episodeIds?: string[];
}
const BASE = "/api/encounters";
export function useEncounters(
enabled = true,
window?: {
from?: string;
to?: string;
status?: string;
/** Only visits filed against this condition episode. */
episodeId?: string;
},
) {
const from = window?.from ?? null;
const to = window?.to ?? null;
return useQuery({
queryKey: queryKeys.encounterList(
from,
to,
window?.status,
window?.episodeId,
),
queryFn: () => {
const sp = new URLSearchParams();
if (from) sp.set("from", from);
if (to) sp.set("to", to);
if (window?.status) sp.set("status", window.status);
if (window?.episodeId) sp.set("episodeId", window.episodeId);
const qs = sp.toString();
return apiGet<EncounterList>(qs ? `${BASE}?${qs}` : BASE);
},
enabled,
});
}
/**
* "Does this belong to a visit?" for one anchor date.
*
* The verdict is resolved server-side so the document moment and the two lab
* moments cannot answer the question differently. `anchor` being null means
* the surface has no date yet and there is nothing to ask about.
*/
export function useEncounterSuggestion(anchor: string | null, enabled = true) {
return useQuery({
queryKey: queryKeys.encounterSuggestion(anchor ?? ""),
queryFn: () =>
apiGet<EncounterSuggestionResult>(
`${BASE}/suggest?anchor=${encodeURIComponent(anchor ?? "")}`,
),
enabled: enabled && Boolean(anchor),
});
}
export function useEncounterMutations() {
const qc = useQueryClient();
/**
* A visit write reaches further than the visit list.
*
* Filing one against a due checkup satisfies that reminder, which moves its
* next-due date on the preventive-care list, on the dashboard card and on the
* Today rail — three surfaces that read the reminder table and would
* otherwise keep showing it due until their own poll. Booking one moves the
* same reads the other way: a new appointment is a row the digest surfaces.
*
* So the fan-out is the visit roots PLUS the reminder reads, through the same
* helper the Vorsorge surfaces use, rather than a second sweep that could
* drift from it.
*/
const invalidate = () =>
Promise.all([
invalidateKeys(qc, encounterDependentKeys),
invalidateReminderReads(qc),
]);
const create = useMutation({
mutationKey: queryKeys.encounterCreate(),
mutationFn: (body: EncounterWriteBody) => apiPost<Encounter>(BASE, body),
onSuccess: invalidate,
});
const update = useMutation({
mutationKey: queryKeys.encounterUpdate(),
mutationFn: ({ id, body }: { id: string; body: EncounterWriteBody }) =>
apiPatch<Encounter>(`${BASE}/${id}`, body),
onSuccess: invalidate,
});
const remove = useMutation({
mutationKey: queryKeys.encounterDelete(),
mutationFn: (id: string) =>
apiDelete<{ deleted: boolean }>(`${BASE}/${id}`),
onSuccess: invalidate,
});
return { create, update, remove };
}