Files
HealthLog/src/hooks/use-insight-status.ts
T
Marc-André Bombeck a8149e0bdf fix(ux): honest deletes, stable hydration and tidy numbers after release review
Server and first client render now agree everywhere query state used to
leak into the chrome, checkboxes no longer nest inside buttons, delete
confirmations admit the undo they offer, raw step counts drop their
decimals, the truncated unit label wraps instead, streaks know their
singular, the medication header speaks the user's date format, a failed
coach turn keeps the user's message and says what went wrong, settings
status notes follow a language switch, the locale only flips once its
bundle actually loaded, and the last raw api fetches in hooks move onto
the shared client.
2026-06-11 03:30:18 +02:00

244 lines
9.6 KiB
TypeScript

"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
/**
* Shape returned by every `/api/insights/<metric>-status` endpoint.
*
* - `hasProvider` — whether the user has configured an AI provider; the
* sub-page hides the status card entirely when false.
* - `text` — the rendered status string; null when the provider has
* not yet produced one.
* - `cached` — whether the payload came from the per-locale cache or a
* fresh provider round-trip.
* - `updatedAt` — ISO timestamp of the cached payload's creation, or
* null when the route has never produced a value.
*/
export interface InsightStatusData {
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
/**
* v1.8.3 — the route is read-only: a cache miss enqueues an out-of-band
* generation and returns `preparing: true` with `text: null`. The card
* shows a preparing state and the hook polls (bounded) until the worker
* warms the cache. Absent / false means the payload is terminal.
*/
preparing?: boolean;
/**
* v1.9.0 — true when the route serves last-good (stale) text AND a fresh
* generation is in flight. The served payload would otherwise be terminal
* (`preparing` false), so the open card would stop polling and never pick
* up the warmed assessment until a remount. The hook keeps polling on
* `preparing || revalidating` (bounded by the same attempt ceiling) so the
* card upgrades to the fresh text in the same session.
*/
revalidating?: boolean;
}
/**
* v1.8.3 — client-side ceiling on the status GET. The route is now
* read-only (sub-second cache read), so a slow response means the network
* itself is degraded, not the LLM. Mirror the advisor hook's 8 s
* `ADVISOR_TIMEOUT_MS`: abort and surface the empty / preparing state
* rather than letting a hung request pin the navigation. No user
* navigation may ever await an uncapped round-trip.
*/
const STATUS_TIMEOUT_MS = 8_000;
/**
* Poll interval while a card is `preparing`. Bounded by `staleTime` so a
* settled (terminal) payload never re-fetches on a timer; only the
* preparing state keeps polling, and it stops as soon as text lands.
*/
const STATUS_POLL_MS = 4_000;
/**
* v1.8.4 — hard ceiling on the preparing poll. The route is read-only and
* a worker warms the cache out of band; a healthy generation lands within
* a couple of intervals. If a provider is configured but generation
* persistently fails, `preparing` stays true forever and the open page
* would otherwise poll indefinitely (battery / network waste). Cap the
* poll at ~12 attempts (≈ 48 s at the 4 s cadence) and then fall back to
* the static empty / "no analysis yet" state. `dataUpdateCount` counts
* every settled fetch — initial load plus each poll round — so once it
* crosses the cap we stop scheduling the next interval.
*/
export const STATUS_POLL_MAX_ATTEMPTS = 12;
/**
* Decide whether a card should schedule its next poll. Pure and shared
* between the `useInsightStatus` hook and the inline medication-compliance
* query so both sites enforce the identical ceiling. Returns the interval
* in ms while polling is warranted, or `false` once the payload is terminal
* OR the attempt cap is reached.
*
* v1.9.0 — `revalidating` joins `preparing` as a "keep polling" signal: when
* the route serves last-good (stale) text it would otherwise be terminal and
* stop the poll, so the freshly-warmed assessment never reaches the open card
* until a remount. The attempt ceiling bounds both signals identically, so a
* persistently failing generation still cannot poll an open page forever.
*/
export function nextStatusPollInterval(
preparing: boolean | undefined,
dataUpdateCount: number,
revalidating?: boolean | undefined,
): number | false {
if (!preparing && !revalidating) return false;
if (dataUpdateCount >= STATUS_POLL_MAX_ATTEMPTS) return false;
return STATUS_POLL_MS;
}
/**
* Metric slugs the sub-pages render. Each slug is paired with the
* Insights-status endpoint and the matching `queryKeys.*` factory so
* the cache keys stay aligned across the whole app — a hard-coded
* `["insights", "weight-status", locale]` array typoed once is exactly
* the class of bug `queryKeys` was introduced to defend against.
*/
export type InsightStatusMetric =
| "blood-pressure"
| "weight"
| "pulse"
| "bmi"
| "mood"
| "medication-compliance";
const QUERY_KEY_FACTORY: Record<
InsightStatusMetric,
(locale: string) => readonly unknown[]
> = {
"blood-pressure": queryKeys.insightsBpStatus,
weight: queryKeys.insightsWeightStatus,
pulse: queryKeys.insightsPulseStatus,
bmi: queryKeys.insightsBmiStatus,
mood: queryKeys.insightsMoodStatus,
"medication-compliance": queryKeys.insightsMedicationComplianceStatus,
};
/**
* Shared loader for the insight-status payload backing
* `/insights/<metric>` sub-pages. Five sub-pages previously copy-pasted
* the same 13-line useQuery block plus an identical `XxxStatusData`
* interface; the hook collapses them onto one helper plus the existing
* `queryKeys` factory so the next "add a metric" PR touches one map
* entry instead of an entire page module.
*
* Returns the `useQuery` result narrowed to the `InsightStatusData`
* payload — callers stay free to use `data` / `isLoading` / refetch
* helpers without an extra unwrap.
*/
export function useInsightStatus(metric: InsightStatusMetric) {
const { isAuthenticated } = useAuth();
const { locale } = useTranslations();
return useQuery({
queryKey: QUERY_KEY_FACTORY[metric](locale),
queryFn: async (): Promise<InsightStatusData> => {
// v1.8.3 — bound the fetch on the client. AbortController + 8 s
// timeout so a degraded network can't pin the navigation thread.
// The card surfaces its preparing / empty state on abort exactly as
// it does for a `preparing` payload, and the bounded poll retries.
const controller = new AbortController();
const timeoutHandle = setTimeout(
() => controller.abort(),
STATUS_TIMEOUT_MS,
);
try {
return await apiGet<InsightStatusData>(
`/api/insights/${metric}-status?locale=${locale}`,
{ signal: controller.signal },
);
} finally {
clearTimeout(timeoutHandle);
}
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
// v1.4.28 FB-D2 — the status route returns a deterministic envelope on
// a miss/timeout. Retrying inline re-fires work and lengthens the
// perceived hang; the bounded `preparing` poll below covers the warm-up.
retry: 0,
// v1.8.3 — poll only while the worker is preparing the assessment.
// Returns false (no timer) for any terminal payload so a settled card
// never re-fetches on an interval. v1.8.4 — also stop after the
// attempt ceiling so a persistently failing generation can't poll an
// open page forever.
refetchInterval: (query) =>
nextStatusPollInterval(
query.state.data?.preparing,
query.state.dataUpdateCount,
query.state.data?.revalidating,
),
});
}
/**
* v1.8.7.1 — generic per-metric assessment loader for the HealthKit
* metric sub-pages. The seven bespoke metrics above (`weight`, `pulse`,
* …) each own a hand-written `/api/insights/<metric>-status` route; the
* ~29 HealthKit pages instead share one generic route keyed by the
* metric id:
*
* GET /api/insights/metric-status?metric=<METRIC_ID>&locale=<locale>
*
* where METRIC_ID is the existing HealthKit measurement identifier
* (`HEART_RATE_VARIABILITY`, `RESTING_HEART_RATE`, `SLEEP_DURATION`, …).
* The response envelope, the 8 s client ceiling, the no-retry policy and
* the bounded `preparing` poll are identical to `useInsightStatus` — the
* card consumes the same `InsightStatusData` shape for every scope.
*
* `enabled` lets the page suppress the fetch entirely when the metric has
* no data (the page renders the insufficient-data empty state instead of
* the card), so a brand-new account never fires an assessment round-trip.
*/
export function useInsightMetricStatus(
// The closed registry-id union (the route's Zod enum vocabulary). `""`
// is the disabled-fetch placeholder the `HealthKitMetricPage` passes when
// no `statusMetric` is wired (the hook must still run per the rules of
// hooks); `enabled` is false on that branch so the empty id never reaches
// a round-trip. A typo'd metric id is therefore a compile error.
metric: MetricStatusMetricId | "",
enabled = true,
) {
const { isAuthenticated } = useAuth();
const { locale } = useTranslations();
return useQuery({
queryKey: queryKeys.insightsMetricStatus(metric, locale),
queryFn: async (): Promise<InsightStatusData> => {
const controller = new AbortController();
const timeoutHandle = setTimeout(
() => controller.abort(),
STATUS_TIMEOUT_MS,
);
try {
return await apiGet<InsightStatusData>(
`/api/insights/metric-status?metric=${encodeURIComponent(
metric,
)}&locale=${locale}`,
{ signal: controller.signal },
);
} finally {
clearTimeout(timeoutHandle);
}
},
enabled: isAuthenticated && enabled,
staleTime: 60 * 1000,
retry: 0,
refetchInterval: (query) =>
nextStatusPollInterval(
query.state.data?.preparing,
query.state.dataUpdateCount,
query.state.data?.revalidating,
),
});
}