Files
HealthLog/src/components/illness/use-illness.ts
T
Marc-André Bombeck f2f40ae8b2 Pair every daily-read invalidation with its inactive refetch
Ten client mutation sites forgot a consumer: a coach-accepted checkup
stayed invisible for up to five minutes, a threshold or target edit left
the dashboard bands stale, a score-config save marked the daily reads
stale but never refetched them, a booked visit missed the Today rail, a
source-priority reorder kept every chart on the old canonical picks, an
adopted self-context note never reached the Settings read. Each site now
invalidates what its write changed and forces the unmounted snapshot +
digest reads to refetch through refetchInactiveDailyReads.

New illnessDependentKeys bundle carries the daily reads (Rest Mode
frames both), dailyDigest joins encounterDependentKeys, and the
chart-data prefix gains a factory accessor for whole-cache flushes.

The daily-reads guard learns the cycle / encounter / vaccination /
illness bundle families and a second matcher for direct snapshot/digest
invalidateQueries without a forced refetch, each with a frozen reasoned
allowlist. Both matchers were proven live by reverting a fix and
watching the guard go red (see the test doc comment).
2026-08-13 18:48:10 +02:00

207 lines
6.4 KiB
TypeScript

"use client";
/**
* v1.18.1 — illness / condition-journal read + write hooks.
*
* Reads unwrap the envelope `data` (via `apiGet`/`apiPost`) per the project
* rule; every key is factory-routed through `queryKeys.illness*`. Writes
* invalidate the whole `["illness"]` prefix so the episode history list and
* an open day-log sheet repaint in lockstep after a log.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import {
illnessDependentKeys,
invalidateKeys,
queryKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import type {
IllnessCorrelationResponse,
IllnessDayLogDTO,
IllnessDayLogInput,
IllnessDayLogListResponse,
IllnessEpisodeCreateInput,
IllnessEpisodeDTO,
IllnessEpisodeUpdateInput,
IllnessInsightsResponse,
} from "./types";
/** Newest-first episode list. */
export function useIllnessEpisodes(includeResolved = true, enabled = true) {
return useQuery({
queryKey: queryKeys.illnessEpisodes(includeResolved),
enabled,
queryFn: () =>
apiGet<IllnessEpisodeDTO[]>(
`/api/illness/episodes?includeResolved=${includeResolved}`,
),
});
}
/** One episode by id. */
export function useIllnessEpisode(id: string | null) {
return useQuery({
queryKey: queryKeys.illnessEpisode(id ?? "none"),
enabled: id !== null,
queryFn: () => apiGet<IllnessEpisodeDTO>(`/api/illness/episodes/${id}`),
});
}
/**
* The per-episode retrospective correlation (recovery-gap / red-flag /
* pre-onset / nadir). Server-authoritative + coverage-gated — the surface
* pattern-matches `status` ("insufficient" → "still learning"), never
* recomputes.
*/
export function useIllnessCorrelation(episodeId: string | null) {
return useQuery({
queryKey: queryKeys.illnessCorrelation(episodeId ?? "none"),
enabled: episodeId !== null,
queryFn: () =>
apiGet<IllnessCorrelationResponse>(
`/api/illness/episodes/${episodeId}/correlation`,
),
});
}
/**
* The cross-episode retrospective summary over a trailing window in days.
*
* `includeRecoveryGap` is OFF by default: the count breakdown is served by a
* single fast query and the recovery-gap stays null. The illness list mounts
* this lightweight read; only the explicit "Analyse" expansion opts into the
* expensive per-episode correlation (`includeRecoveryGap: true`), which caches
* under its own key. `enabled` lets a caller defer the heavy read until opened.
*/
export function useIllnessInsights(
windowDays = 365,
options: { includeRecoveryGap?: boolean; enabled?: boolean } = {},
) {
const includeRecoveryGap = options.includeRecoveryGap ?? false;
return useQuery({
queryKey: queryKeys.illnessInsights(windowDays, includeRecoveryGap),
enabled: options.enabled ?? true,
queryFn: () =>
apiGet<IllnessInsightsResponse>(
`/api/illness/insights?windowDays=${windowDays}&includeRecoveryGap=${includeRecoveryGap}`,
),
});
}
/** One episode's day-log for a given date (null when nothing is logged). */
export function useIllnessDayLog(episodeId: string | null, date: string) {
return useQuery({
queryKey: queryKeys.illnessDayLog(episodeId ?? "none", date),
enabled: episodeId !== null,
queryFn: () =>
apiGet<IllnessDayLogDTO | null>(
`/api/illness/episodes/${episodeId}/day-logs?date=${date}`,
),
});
}
/**
* v1.18.3 — the episode's full day-log history, newest-first (date-less list).
* Powers the detail timeline's historical scroll instead of anchoring on
* today. Server-authoritative; the response carries `meta.total` for paging.
*/
export function useIllnessDayLogList(
episodeId: string | null,
sortDir: "asc" | "desc" = "desc",
) {
return useQuery({
queryKey: queryKeys.illnessDayLogList(episodeId ?? "none", sortDir),
enabled: episodeId !== null,
queryFn: () =>
apiGet<IllnessDayLogListResponse>(
`/api/illness/episodes/${episodeId}/day-logs?sortDir=${sortDir}`,
),
});
}
function useInvalidateIllness() {
const qc = useQueryClient();
// An episode write can flip Rest Mode, which frames the dashboard hero and
// the Today digest — both unmounted from here, so the bundle invalidation
// is paired with the forced inactive refetch. The write routes hard-evict
// the server analytics bucket, so the refetch returns post-write context.
return async () => {
await invalidateKeys(qc, illnessDependentKeys);
await refetchInactiveDailyReads(qc);
};
}
/** Create an episode. */
export function useCreateEpisode() {
const invalidate = useInvalidateIllness();
return useMutation({
mutationFn: (input: IllnessEpisodeCreateInput) =>
apiPost<IllnessEpisodeDTO>("/api/illness/episodes", input),
onSuccess: invalidate,
});
}
/** Edit an episode (partial). */
export function useUpdateEpisode() {
const invalidate = useInvalidateIllness();
return useMutation({
mutationFn: ({
id,
input,
}: {
id: string;
input: IllnessEpisodeUpdateInput;
}) => apiPatch<IllnessEpisodeDTO>(`/api/illness/episodes/${id}`, input),
onSuccess: invalidate,
});
}
/** Mark an episode recovered. */
export function useResolveEpisode() {
const invalidate = useInvalidateIllness();
return useMutation({
mutationFn: (id: string) =>
apiPatch<IllnessEpisodeDTO>(`/api/illness/episodes/${id}/resolve`, {}),
onSuccess: invalidate,
});
}
/** Soft-delete an episode. */
export function useDeleteEpisode() {
const invalidate = useInvalidateIllness();
return useMutation({
mutationFn: (id: string) => apiDelete<void>(`/api/illness/episodes/${id}`),
onSuccess: invalidate,
});
}
/**
* Restore a soft-deleted episode (the delete-Undo affordance). Clears
* `deletedAt` server-side, bringing the episode and its preserved day-logs
* back. Idempotent + owner-scoped.
*/
export function useRestoreEpisode() {
const invalidate = useInvalidateIllness();
return useMutation({
mutationFn: (id: string) =>
apiPost<IllnessEpisodeDTO>(`/api/illness/episodes/${id}/restore`, {}),
onSuccess: invalidate,
});
}
/** Upsert one day-log on an episode. */
export function useUpsertDayLog(episodeId: string) {
const invalidate = useInvalidateIllness();
return useMutation({
mutationFn: (input: IllnessDayLogInput) =>
apiPost<IllnessDayLogDTO>(
`/api/illness/episodes/${episodeId}/day-logs`,
input,
),
onSuccess: invalidate,
});
}