void refetch()}
+ />
) : null}
{!isLoading && !isError && data && !hasDiscovered ? (
{
+ state: FenceState = { failed: false };
+
+ static getDerivedStateFromError(): FenceState {
+ return { failed: true };
+ }
+
+ componentDidCatch(error: Error): void {
+ // Same hand-off the chart boundary uses: the section degrades quietly,
+ // the failure still reaches the client error tracker.
+ if (typeof window !== "undefined") {
+ const g = window as typeof window & {
+ __healthlog_onError?: (err: Error) => void;
+ };
+ g.__healthlog_onError?.(error);
+ }
+ }
+
+ render(): ReactNode {
+ if (!this.state.failed) return this.props.children;
+ return (
+
+ this.setState({ failed: false })}
+ />
+
+ );
+ }
+}
+
+export function OverviewSectionBoundary({
+ sectionId,
+ children,
+}: {
+ sectionId: string;
+ children: ReactNode;
+}) {
+ const { t } = useTranslations();
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/insights/rhythm-events-card.tsx b/src/components/insights/rhythm-events-card.tsx
index d9162d8c0..2367a516a 100644
--- a/src/components/insights/rhythm-events-card.tsx
+++ b/src/components/insights/rhythm-events-card.tsx
@@ -11,6 +11,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/ui/section-heading";
import { InsightSectionCard } from "./insight-section-card";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
/**
* v1.10.0 — device-flagged event awareness surface (categorical events,
@@ -89,7 +90,7 @@ export function RhythmEventsCard({
const { t } = useTranslations();
const fmt = useFormatters();
- const { data, isLoading } = useQuery({
+ const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.insightsRhythmEvents(),
queryFn: async () => {
try {
@@ -103,6 +104,28 @@ export function RhythmEventsCard({
enabled: enabled && isAuthenticated,
});
+ // A failed read is said, never swallowed: this card carries health alerts,
+ // and silently rendering nothing on an error would hide them.
+ if (isError && !data) {
+ return (
+
+
+ void refetch()}
+ />
+
+ );
+ }
+
// Data-availability gate — never paint an empty / alarming card.
if (isLoading || !data || !data.hasEvents) return null;
diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts
index 0823c247f..31eef1153 100644
--- a/src/components/insights/use-insights-advisor.ts
+++ b/src/components/insights/use-insights-advisor.ts
@@ -16,6 +16,7 @@ import type {
} from "@/lib/ai/schema";
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
import { apiFetchRaw } from "@/lib/api/api-fetch";
+import { useAiCapability } from "@/hooks/use-ai-capability";
import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker";
/**
@@ -245,9 +246,21 @@ export function nextAdvisorPollInterval(
* regenerate quota is exhausted, which deserves a "try again later"
* hint rather than the success toast the old lump produced. `empty` now
* means only the transient 503 surface (provider chain unavailable).
+ *
+ * `unavailable` (403) is the server refusing the briefing capability: the
+ * operator's switch, the person's opt-out, or a consent the chain needs. The
+ * web only asks while `/api/auth/me` says the capability is available, so this
+ * is the backstop for the moment between a switch flipping and `/me`
+ * refreshing. It settles like the other expected refusals instead of
+ * throwing, so the briefing card never turns into an error.
*/
export type AdvisorFetchOutcome =
- "fresh" | "empty" | "rate-limited" | "timeout" | "no-provider";
+ | "fresh"
+ | "empty"
+ | "rate-limited"
+ | "timeout"
+ | "no-provider"
+ | "unavailable";
interface AdvisorFetchResult {
payload: InsightAdvisorPayload | null;
@@ -316,6 +329,9 @@ export async function fetchAdvisor(
if (res.status === 503) {
return { payload: null, outcome: "empty" };
}
+ if (res.status === 403) {
+ return { payload: null, outcome: "unavailable" };
+ }
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
@@ -384,24 +400,6 @@ export interface UseInsightsAdvisorResult {
* assessment is still being prepared; NO toast fires until this falls.
*/
regenerateSettling: boolean;
- /**
- * v1.15.20 — the outcome of the last settled READ. Lets surfaces
- * distinguish "no briefing yet, a generate could help" (`empty` /
- * `timeout`) from "no provider configured, generating is futile"
- * (`no-provider`) and render a connect-AI hint instead of a dead
- * regenerate CTA.
- */
- readOutcome: AdvisorFetchOutcome | null;
- /**
- * v1.18.9 (#4) — false when the GET reported no usable AI provider. The
- * read path still serves the last cached briefing (no provider is
- * needed to read the cache), so this is the ONLY honest signal that a
- * shown-but-stale briefing can never refresh. Surfaces pair it with the
- * relative-age line to add a discreet connect-provider hint. Defaults
- * true (no hint) when the field is absent — a pre-field cached payload
- * or an unsettled query.
- */
- hasProvider: boolean;
/**
* v1.25 — true when the GET reported the last generation attempt failed.
* Pairs a shown-but-held briefing with a discreet "couldn't refresh" hint,
@@ -427,10 +425,17 @@ export interface UseInsightsAdvisorResult {
/**
* Read-only consumer for the advisor payload. Use this on surfaces that
* just want to render the cached insight (e.g. dashboard preview).
+ *
+ * The read only fires while the `briefing` capability on `/api/auth/me` is
+ * available, whatever the caller passes: every consumer (the overview, the
+ * tab strip) gets the same gate, and none can re-enable the read behind the
+ * other's back. While `/me` loads the capability reads unavailable, so the
+ * first paint never fires a request only to have it refused.
*/
export function useInsightsAdvisorQuery(
enabled: boolean,
): UseInsightsAdvisorResult {
+ const briefing = useAiCapability("briefing");
const queryClient = useQueryClient();
// Refs #786 — settling state for a timed-out force regenerate (see the
// contract comment above `AdvisorSettleState`). `settleOutcome` carries the
@@ -445,7 +450,7 @@ export function useInsightsAdvisorQuery(
// outcome) so the read path can surface WHY a payload is missing
// (`no-provider` → connect-AI hint instead of a dead regenerate CTA).
queryFn: () => fetchAdvisor(),
- enabled,
+ enabled: enabled && briefing.available,
// 24h cache window matches the server-side `insightsCachedAt` TTL.
staleTime: 60 * 60 * 1000,
retry: false,
@@ -577,11 +582,6 @@ export function useInsightsAdvisorQuery(
// outcome ("timeout" while settling is an interim state, not a result).
regenerateOutcome: settleOutcome ?? mutation.data?.outcome ?? null,
regenerateSettling: settling !== null,
- readOutcome: query.data?.outcome ?? null,
- // Absent field (pre-v1.18.9 cached payload, or query still settling) →
- // assume a provider is present so a transient unknown never flashes a
- // false "no provider" hint.
- hasProvider: query.data?.payload?.hasProvider ?? true,
// Absent (pre-field cached payload or unsettled query) → not failed, so a
// transient unknown never flashes a false "couldn't refresh" hint.
generationFailed: query.data?.payload?.generationFailed ?? false,
diff --git a/src/components/insights/workout-detail/day-context.tsx b/src/components/insights/workout-detail/day-context.tsx
index 413a9dfa7..5cc99ce15 100644
--- a/src/components/insights/workout-detail/day-context.tsx
+++ b/src/components/insights/workout-detail/day-context.tsx
@@ -60,9 +60,10 @@ export interface WorkoutDetailDayContextProps {
* undated and named for what their targets actually deliver — trends and
* history, not "that day".
*
- * Module gating: the intraday tile mounts only when `insights` is on and
- * the sleep row only when `sleep` is on, so a switched-off module never
- * paints as a failed request. Mood has no module gate, so the section
+ * Module gating: the sleep row mounts only when `sleep` is on, so a
+ * switched-off module never paints as a failed request. The intraday pulse
+ * tile is heart-rate data and follows no module (the "AI analysis" switch
+ * only governs model-written text). Mood has no module gate, so the section
* always carries at least one part and the heading is never an orphan.
*/
export function WorkoutDetailDayContext({
@@ -71,7 +72,6 @@ export function WorkoutDetailDayContext({
const { t } = useTranslations();
const fmt = useFormatters();
const { user, isAuthenticated } = useAuth();
- const insightsEnabled = useModuleEnabled("insights");
const sleepEnabled = useModuleEnabled("sleep");
const sleep = useSleepNight(workout.dayKey, isAuthenticated && sleepEnabled);
@@ -96,12 +96,10 @@ export function WorkoutDetailDayContext({
}
/>
- {insightsEnabled ? (
-
- ) : null}
+
diff --git a/src/hooks/use-insight-status.ts b/src/hooks/use-insight-status.ts
index b43141262..ebccbecc3 100644
--- a/src/hooks/use-insight-status.ts
+++ b/src/hooks/use-insight-status.ts
@@ -2,6 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useAiCapability } from "@/hooks/use-ai-capability";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
@@ -295,6 +296,10 @@ const QUERY_KEY_FACTORY: Record<
*/
export function useInsightStatus(metric: InsightStatusMetric) {
const { isAuthenticated } = useAuth();
+ // Status notes are model-written: no read while the `statusText`
+ // capability is unavailable (the card is hidden then anyway). Unavailable
+ // while `/me` loads, so the first paint fires nothing.
+ const statusText = useAiCapability("statusText");
const { locale } = useTranslations();
const queryKey = QUERY_KEY_FACTORY[metric](locale);
@@ -305,7 +310,7 @@ export function useInsightStatus(metric: InsightStatusMetric) {
`/api/insights/${metric}-status?locale=${locale}`,
signal,
),
- enabled: isAuthenticated,
+ enabled: isAuthenticated && statusText.available,
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
@@ -359,6 +364,7 @@ export function useInsightMetricStatus(
enabled = true,
) {
const { isAuthenticated } = useAuth();
+ const statusText = useAiCapability("statusText");
const { locale } = useTranslations();
const queryKey = queryKeys.insightsMetricStatus(metric, locale);
@@ -371,7 +377,7 @@ export function useInsightMetricStatus(
)}&locale=${locale}`,
signal,
),
- enabled: isAuthenticated && enabled,
+ enabled: isAuthenticated && enabled && statusText.available,
staleTime: 60 * 1000,
retry: 0,
refetchInterval: (query) =>
@@ -409,6 +415,7 @@ export function useInsightBiomarkerAssessment(
enabled = true,
) {
const { isAuthenticated } = useAuth();
+ const statusText = useAiCapability("statusText");
const { locale } = useTranslations();
const queryKey = queryKeys.insightsBiomarkerAssessment(biomarkerId, locale);
@@ -421,7 +428,11 @@ export function useInsightBiomarkerAssessment(
)}&locale=${locale}`,
signal,
),
- enabled: isAuthenticated && enabled && biomarkerId.length > 0,
+ enabled:
+ isAuthenticated &&
+ enabled &&
+ statusText.available &&
+ biomarkerId.length > 0,
staleTime: 60 * 1000,
retry: 0,
refetchInterval: (query) =>
diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts
index 1a8401ea1..f37d0ec33 100644
--- a/src/hooks/use-workouts.ts
+++ b/src/hooks/use-workouts.ts
@@ -70,6 +70,12 @@ export interface UseWorkoutsOptions {
since?: string;
/** Optional sport-type filter. Pass through the canonical string. */
sportType?: string;
+ /**
+ * Whether to read at all. A probe on a surface that shows workouts only
+ * while the Workouts module is on passes the module answer here, so a
+ * switched-off module is never asked for rows. Defaults to true.
+ */
+ enabled?: boolean;
}
async function fetchWorkouts(
@@ -108,7 +114,7 @@ export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult {
sportType: opts.sportType,
}),
queryFn: () => fetchWorkouts(opts),
- enabled: isAuthenticated,
+ enabled: isAuthenticated && opts.enabled !== false,
staleTime: 60 * 1000,
});