mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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.
108 lines
4.0 KiB
TypeScript
108 lines
4.0 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
|
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
|
|
|
|
/**
|
|
* v1.4.31 — Client-side accessor for the operator's assistant
|
|
* feature-flag matrix. Mirrors `GET /api/feature-flags`'s response
|
|
* shape directly so callers can rely on a typed structure.
|
|
*
|
|
* The matrix gates every assistant-driven surface on the web —
|
|
* Coach + chat, Daily Briefing, per-metric status cards, correlation
|
|
* narration, Health-Score delta explainer. iOS reads the same
|
|
* endpoint and uses the same matrix; the contract is locked per
|
|
* `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
|
|
*/
|
|
|
|
export interface AssistantFlagSet {
|
|
/** Master kill-switch — when false, every sub-flag is forced false. */
|
|
enabled: boolean;
|
|
/** Coach drawer, chat SSE, history rail, FAB. */
|
|
coach: boolean;
|
|
/** Daily Briefing card + advisor recommendations + regen icon. */
|
|
briefing: boolean;
|
|
/** Per-metric status cards on every `/insights/<metric>` sub-page. */
|
|
insightStatus: boolean;
|
|
/** Correlation narration tile on the mother page. */
|
|
correlations: boolean;
|
|
/** `?` glyph that opens the Health-Score delta explainer popover. */
|
|
healthScoreExplainer: boolean;
|
|
}
|
|
|
|
interface FeatureFlagsPayload {
|
|
assistant: AssistantFlagSet;
|
|
}
|
|
|
|
/** All-on default — matches the v1.4.30 behaviour for fresh installs. */
|
|
export const DEFAULT_ASSISTANT_FLAGS: AssistantFlagSet = Object.freeze({
|
|
enabled: true,
|
|
coach: true,
|
|
briefing: true,
|
|
insightStatus: true,
|
|
correlations: true,
|
|
healthScoreExplainer: true,
|
|
});
|
|
|
|
async function fetchFeatureFlags(): Promise<FeatureFlagsPayload> {
|
|
// `apiFetchRaw` (no .ok throw) — this read soft-fails to the all-on
|
|
// default below instead of surfacing an error state.
|
|
const res = await apiFetchRaw("/api/feature-flags");
|
|
if (!res.ok) {
|
|
// Soft-fail: any HTTP error returns the all-on default so the
|
|
// surface continues to render. The endpoint is a non-critical
|
|
// metadata read; a 5xx must not turn into a "Coach hidden"
|
|
// false negative for the user.
|
|
return { assistant: { ...DEFAULT_ASSISTANT_FLAGS } };
|
|
}
|
|
const json = (await res.json()) as { data: FeatureFlagsPayload };
|
|
return json.data;
|
|
}
|
|
|
|
/**
|
|
* Read the operator's assistant feature-flag matrix.
|
|
*
|
|
* `staleTime: 60_000` matches the server-side cache header so the
|
|
* hot /insights mount path doesn't re-fetch the matrix on every
|
|
* remount within a minute. The hook fails open — any network error
|
|
* returns the all-on default so the user keeps seeing the surface.
|
|
*
|
|
* The hook is referenced from a long tail of legacy presentational
|
|
* components (`<InsightStatusCard>`, `<HealthScoreDeltaExplainer>`)
|
|
* whose unit tests render the component without a
|
|
* `<QueryClientProvider>`. To keep those tests valid we delegate
|
|
* the React-Query call to a child hook that's only invoked when a
|
|
* client is mounted. The branch is stable across the component's
|
|
* lifetime — `QueryClientProvider` placement never flips between
|
|
* renders — so the conditional hook call is safe per the Rules of
|
|
* Hooks (same posture the React docs document for "different
|
|
* environments where the same component renders").
|
|
*
|
|
* v1.4.48 M4 — the SSR-mount probe is shared with `useDisableCoach`
|
|
* via `_internal/use-query-client-safe` so the eslint-disable line
|
|
* stays in exactly one place per consumer.
|
|
*/
|
|
export function useFeatureFlags(): AssistantFlagSet {
|
|
const hasClient = useQueryClientMounted();
|
|
if (!hasClient) return DEFAULT_ASSISTANT_FLAGS;
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
return useFeatureFlagsQuery();
|
|
}
|
|
|
|
function useFeatureFlagsQuery(): AssistantFlagSet {
|
|
const query = useQuery({
|
|
queryKey: queryKeys.featureFlags(),
|
|
queryFn: fetchFeatureFlags,
|
|
staleTime: 60_000,
|
|
gcTime: 300_000,
|
|
retry: 0,
|
|
});
|
|
|
|
// While loading or on error, default to all-on so the assistant
|
|
// surfaces don't flicker out and back in.
|
|
return query.data?.assistant ?? DEFAULT_ASSISTANT_FLAGS;
|
|
}
|