mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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).
207 lines
6.4 KiB
TypeScript
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,
|
|
});
|
|
}
|