mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Give each biomarker a detail page that matches the metric sub-pages: a numbers-first Min/Max/Median/Mean strip over the numeric readings and a data-grounded AI assessment card. The assessment is served by a new read-only endpoint GET /api/insights/biomarker-assessment?biomarkerId=&locale=, returning the same envelope as /api/insights/metric-status so InsightStatusCard consumes it unchanged. A new generator reads the marker's LabResult history and reuses the shared provider runner, the stale-while-revalidate read-only path, and the persist helper. Regeneration is gated on a fingerprint of the latest reading, so the assessment is rebuilt only when a new reading lands; an idle marker re-stamps the cached text without an LLM call. The biomarker: scope rides the existing on-demand status queue. The stat strip and the assessment card both self-gate on numeric data, so a qualitative-only or brand-new marker paints neither.
298 lines
12 KiB
TypeScript
298 lines
12 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,
|
|
),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Per-biomarker assessment loader for the lab-marker detail page. Mirrors
|
|
* `useInsightMetricStatus` exactly — the same `InsightStatusData` envelope,
|
|
* the 8 s client ceiling, the no-retry policy, and the bounded `preparing`
|
|
* poll — but keyed by the marker id against
|
|
* `GET /api/insights/biomarker-assessment?biomarkerId=…`.
|
|
*
|
|
* Read-only + stale-while-revalidate: the route serves cached text and warms
|
|
* a regeneration out of band on a cache miss, exactly like every metric
|
|
* page. Generation never warms on mount beyond that shared seam; the worker
|
|
* cron + the read-only enqueue are the only producers.
|
|
*
|
|
* `enabled` lets the page suppress the fetch when the marker has no readings
|
|
* (the page renders its empty state instead of the card), so a brand-new
|
|
* marker never fires an assessment round-trip.
|
|
*/
|
|
export function useInsightBiomarkerAssessment(
|
|
biomarkerId: string,
|
|
enabled = true,
|
|
) {
|
|
const { isAuthenticated } = useAuth();
|
|
const { locale } = useTranslations();
|
|
|
|
return useQuery({
|
|
queryKey: queryKeys.insightsBiomarkerAssessment(biomarkerId, locale),
|
|
queryFn: async (): Promise<InsightStatusData> => {
|
|
const controller = new AbortController();
|
|
const timeoutHandle = setTimeout(
|
|
() => controller.abort(),
|
|
STATUS_TIMEOUT_MS,
|
|
);
|
|
try {
|
|
return await apiGet<InsightStatusData>(
|
|
`/api/insights/biomarker-assessment?biomarkerId=${encodeURIComponent(
|
|
biomarkerId,
|
|
)}&locale=${locale}`,
|
|
{ signal: controller.signal },
|
|
);
|
|
} finally {
|
|
clearTimeout(timeoutHandle);
|
|
}
|
|
},
|
|
enabled: isAuthenticated && enabled && biomarkerId.length > 0,
|
|
staleTime: 60 * 1000,
|
|
retry: 0,
|
|
refetchInterval: (query) =>
|
|
nextStatusPollInterval(
|
|
query.state.data?.preparing,
|
|
query.state.dataUpdateCount,
|
|
query.state.data?.revalidating,
|
|
),
|
|
});
|
|
}
|