From 48d97ef70db4664807fb0b0d9b8d6a2352a7f01f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 11:05:44 +0200 Subject: [PATCH] feat(insights): render AI parts from the capability map, never as errors The daily briefing, the hero's briefing paragraph, the trend sentences and the status notes follow the briefing and statusText capabilities: hidden while unavailable, never an error card or a per-card "connect a provider" state. The advisor and status reads only fire while their capability is available; a 403 settles as an outcome instead of throwing. A failed comprehensive read no longer replaces the overview, and every overview section is fenced so a section that throws becomes one error row. The period review always renders its composed narrative. Cycle, breathing and lab-change sections go through the surface map. One calm setup hint appears on the overview while no provider is configured and one could be. Signals, rhythm events and the mood patterns card say a failed read with a retry, the score anatomy no longer calls a failed read insufficient data, the catalog marks only the rows whose read failed, and workouts are not probed while the module is off. --- src/app/insights/catalog/page.tsx | 158 ++++++++++++------ src/app/insights/medications/page.tsx | 6 +- src/app/insights/page-client.tsx | 131 +++++++-------- .../__tests__/daily-briefing.test.tsx | 43 ++--- .../healthkit-metric-page-status.test.tsx | 9 + ...sight-status-card-capability-gate.test.tsx | 86 ++++++++++ .../insight-status-card-flag-gate.test.tsx | 86 ---------- .../__tests__/insight-status-card.test.tsx | 30 ++-- src/components/insights/ai-setup-hint.tsx | 142 ++++++++++++++++ .../insights/coincident-deviation-card.tsx | 24 ++- src/components/insights/daily-briefing.tsx | 69 +------- .../derived/composite-score-anatomy.tsx | 19 ++- src/components/insights/hero-strip.tsx | 35 ---- .../insights/insight-status-card.tsx | 49 ++---- .../insights/insights-layout-shell.tsx | 30 ++-- .../insights/insights-tab-strip.tsx | 5 +- .../insights/mood/mood-what-stands-out.tsx | 20 +-- .../insights/overview-section-boundary.tsx | 87 ++++++++++ .../insights/rhythm-events-card.tsx | 25 ++- .../insights/use-insights-advisor.ts | 50 +++--- .../insights/workout-detail/day-context.tsx | 18 +- src/hooks/use-insight-status.ts | 17 +- src/hooks/use-workouts.ts | 8 +- 23 files changed, 687 insertions(+), 460 deletions(-) create mode 100644 src/components/insights/__tests__/insight-status-card-capability-gate.test.tsx delete mode 100644 src/components/insights/__tests__/insight-status-card-flag-gate.test.tsx create mode 100644 src/components/insights/ai-setup-hint.tsx create mode 100644 src/components/insights/overview-section-boundary.tsx diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx index 56ce8f971..5f050dee2 100644 --- a/src/app/insights/catalog/page.tsx +++ b/src/app/insights/catalog/page.tsx @@ -22,11 +22,12 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; -import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { TileHeader } from "@/components/insights/tile-header"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { useAuth } from "@/hooks/use-auth"; import { useWorkouts } from "@/hooks/use-workouts"; +import { isSurfaceVisible } from "@/lib/modules/surface"; import { useTranslations } from "@/lib/i18n/context"; import { apiGet } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; @@ -135,7 +136,8 @@ function CatalogRow({ present, }: { entry: CatalogEntry; - present: boolean | null; + /** `"unknown"` while the read that decides this row failed. */ + present: boolean | null | "unknown"; }) { const { t } = useTranslations(); @@ -154,6 +156,7 @@ function CatalogRow({ } const isPresent = present === true; + const unknown = present === "unknown"; return (
{t(entry.nameKey)} - + {unknown ? null : }

{t(entry.sourceKey)}

- {isPresent && entry.href ? ( + {unknown ? null : isPresent && entry.href ? ( + + + ); + } + + return ( + + + + + +

+ {t("insights.aiSetupHint.body")} +

+ +
+
+ ); +} diff --git a/src/components/insights/coincident-deviation-card.tsx b/src/components/insights/coincident-deviation-card.tsx index c909ca7fa..6d8926da7 100644 --- a/src/components/insights/coincident-deviation-card.tsx +++ b/src/components/insights/coincident-deviation-card.tsx @@ -5,6 +5,7 @@ import { AlertTriangle, Activity } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { SectionHeading } from "@/components/ui/section-heading"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta"; import { CoverageMeter } from "@/components/insights/derived/coverage-meter"; import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer"; @@ -172,11 +173,32 @@ export function CoincidentDeviationCard({ className, }: CoincidentDeviationCardProps) { const { t } = useTranslations(); - const { data } = useDerivedMetric( + const { data, isError, refetch } = useDerivedMetric( COINCIDENT_METRIC, { enabled }, ); + // A failed read says so with a retry. Without this branch the card sat on + // its skeleton forever, which read as "still loading" rather than failed. + if (isError && !data) { + return ( +
+ + void refetch()} + /> +
+ ); + } + // CLS-safe placeholder while the single read is in flight. if (!data) { return ; diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index fc2d19a0c..536102b71 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -107,21 +107,6 @@ interface DailyBriefingProps { onRegenerate?: () => void; /** Disables the regenerate CTA while a generation is in flight. */ regenerating?: boolean; - /** - * v1.15.20 — no AI provider is configured anywhere, so generating is - * futile. The empty state swaps the regenerate CTA for a quiet hint - * linking to Settings → AI instead of an eternal "preparing" loop. - */ - noProvider?: boolean; - /** - * v1.18.9 (#4) — no AI provider configured WHILE a (stale) cached - * briefing is still shown. The read path serves the last good briefing - * regardless of provider state, so a provider-less account keeps seeing - * a days-old briefing. When true, the footer pairs the honest relative - * age with a discreet "this can't refresh — connect a provider" hint so - * the staleness reads as intentional, not as a live report. - */ - noProviderStale?: boolean; /** * v1.25 — the most recent generation attempt failed (provider timeout / * error). The briefing keeps its last good text, so this never blanks a @@ -365,8 +350,6 @@ export function DailyBriefing({ loading = false, onRegenerate, regenerating = false, - noProvider = false, - noProviderStale = false, generationFailed = false, generationFailureClass = null, omittedReason = null, @@ -482,7 +465,7 @@ export function DailyBriefing({
)} - {(updatedAt || noProviderStale || generationFailed) && ( + {(updatedAt || generationFailed) && (
{updatedAt && (

)} - {/* v1.18.9 (#4) — a stale briefing that can never refresh - because no AI provider is connected. State that plainly - and point at Settings → AI, so the days-old read is - understood as held, not presented as current. */} - {noProviderStale && ( -

- - {t("insights.dailyBriefing.staleNoProviderHint")} - - - {t("insights.dailyBriefing.noProviderAction")} - -

- )} {/* v1.25 — the held briefing is shown, but the last refresh attempt failed (provider timeout / error). State it plainly and offer a retry so the staleness reads as held, not - current. Suppressed when no provider is configured (that - hint owns the footer) — a retry would be futile there. */} - {generationFailed && !noProviderStale && ( + current. The card only mounts while the briefing + capability is available, so a provider exists. */} + {generationFailed && (

)}

- ) : noProvider ? ( - // v1.15.20 — no provider configured anywhere: a regenerate CTA - // would 422 forever, so point at Settings → AI instead. - } - title={t("insights.dailyBriefing.noProviderTitle")} - description={t("insights.dailyBriefing.noProviderDescription")} - action={ - - } - /> ) : ( ); + if (query.isError) { + // A failed read is not "insufficient data": the score may well exist. + // Say it failed and offer a retry instead of an empty anatomy. + return ( +
+ void query.refetch()} + /> +
+ ); + } + if (!data) { - // Network/abort fallback — render the insufficient state honestly. + // Settled without a payload — render the insufficient state honestly. return ( } text={assessment.text} hasProvider + // Composed from the numbers; the server swaps in model text only + // while `statusText` is available, so the card shows either way. + aiAuthored={false} updatedAt={assessment.updatedAt} // The outbound edge. Same shared opener + auto-send hand-off the // metric pages use, so the answer lands directly instead of only diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index ef92e9184..eacaf9b5c 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -1,6 +1,5 @@ "use client"; -import Link from "next/link"; import { Sparkles } from "lucide-react"; import { useTranslations, @@ -65,15 +64,6 @@ interface HeroStripProps { * Defaults to `new Date()`. Production callers omit this. */ now?: Date; - /** - * v1.18.9 (#4) — true when the hero subtitle is drawn from a cached - * briefing that can never refresh because no AI provider is connected. - * The meta row then appends a discreet "connect a provider" link beside - * the "Generated " age, so the score-area context reads as - * honestly held rather than current. Consistent with the DailyBriefing - * footer hint below. - */ - noProviderStale?: boolean; /** * The Health Score report. Non-null paints the right column; null with * `scorePending` paints the reserve; null without it keeps the band @@ -117,7 +107,6 @@ export function HeroStrip({ updatedAt, userName, now, - noProviderStale = false, healthScore = null, scorePending = false, tension = null, @@ -222,30 +211,6 @@ export function HeroStrip({ )} - {/* v1.18.9 (#4) — the subtitle is a cached briefing that can - never refresh (no AI provider). Append a discreet hint + - Settings → AI link so the relative age above reads as - intentionally held, consistent with the briefing card. */} - {noProviderStale && ( - <> - - - {t("insights.dailyBriefing.staleNoProviderHint")} - - {t("insights.dailyBriefing.noProviderAction")} - - - - )} diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index df7e84c5b..a20e87f02 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -1,7 +1,5 @@ "use client"; -import Link from "next/link"; - import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { TileHeader } from "@/components/insights/tile-header"; @@ -14,7 +12,7 @@ import { formatUpdatedLabel } from "@/lib/i18n/relative-time"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { ProseBlocks } from "@/components/insights/prose-blocks"; import { cn } from "@/lib/utils"; -import { useFeatureFlags } from "@/hooks/use-feature-flags"; +import { useAiCapability } from "@/hooks/use-ai-capability"; import { AskCoachIconButton } from "@/components/insights/ask-coach-action"; import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; import type { AssessmentStatus } from "@/lib/insights/status-shared"; @@ -83,6 +81,14 @@ interface InsightStatusCardProps { * instead of only seeding the composer. Defaults to false. */ coachAutoSend?: boolean; + /** + * Whether the card's text is written by a model. True for every status + * note, which only shows while the `statusText` capability is available. + * The score anatomy passes false: its assessment is composed from the + * numbers and only upgraded to model text by the server, so it shows + * whatever the AI state. + */ + aiAuthored?: boolean; } // ─── Main Component ─────────────────────────────────────── @@ -99,13 +105,15 @@ export function InsightStatusCard({ coachQuestion, coachScope, coachAutoSend, + aiAuthored = true, }: InsightStatusCardProps) { const { t } = useTranslations(); - const flags = useFeatureFlags(); - // v1.4.31 — operator can hide every per-metric status card - // app-wide. The delta number stays; the LLM narration card is - // suppressed in full so the layout collapses naturally. - if (!flags.insightStatus) return null; + const statusText = useAiCapability("statusText"); + // A status note is model-written text, so the card is hidden, never an + // error or a "connect a provider" hint, while the `statusText` capability + // is unavailable for any reason. The numbers around it stay; the layout + // collapses naturally. + if (aiAuthored && !statusText.available) return null; const displayText = assessment?.kind === "generated" || @@ -213,31 +221,6 @@ export function InsightStatusCard({ ); } - const isNoProvider = - assessment?.kind === "no-provider" || - (!assessment && !hasProvider && !displayText); - if (isNoProvider) { - return ( - - - - - -

- {t("insights.noProviderConfigured")} -

- - {t("insights.noProviderAction")} - -
-
- ); - } - if (!displayText) { return ( diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index dbf4d0ea6..9baeb1c2e 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -5,7 +5,8 @@ import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; -import { useFeatureFlags } from "@/hooks/use-feature-flags"; +import { useAiCapability } from "@/hooks/use-ai-capability"; +import { useSurfaceVisible } from "@/hooks/use-surface-visible"; import { useWorkouts } from "@/hooks/use-workouts"; import { InsightsTabStrip } from "@/components/insights/insights-tab-strip"; import { @@ -52,26 +53,18 @@ interface ComprehensivePayload { export function InsightsLayoutShell({ children }: { children: ReactNode }) { const { isAuthenticated, user } = useAuth(); - // v1.4.33 F18 — gate the advisor POST on the operator's assistant - // feature flag. Pre-fix, every /insights mount fired POST - // /api/insights/generate even when the operator had disabled the - // briefing surface or the user had no AI provider configured. The - // server returned 422 in that case and the regenerate button on the - // tab strip rendered a non-functional spinner. Reading the flag - // matrix off `/api/feature-flags` keeps the hot path one fetch - // (shared `["feature-flags"]` cache, 60s staleTime) and skips the - // advisor request entirely when the operator has the briefing gate - // off. The fail-open default in `useFeatureFlags` means a network - // hiccup still renders the advisor — the gate only takes effect when - // the operator has explicitly turned the surface off. - const flags = useFeatureFlags(); + // The briefing read and the regenerate button follow the `briefing` + // capability on `/api/auth/me`: the server resolves the operator's switch, + // the person's AI analysis opt-out, provider presence and consent into one + // answer, and the strip offers regenerate only while that answer is yes. + // Unavailable while `/me` loads, so nothing fires before the answer lands. + const briefing = useAiCapability("briefing"); // v1.16.4 — `mounted` keeps the hydration render in lockstep with the // SSR HTML: a late-hydrating boundary can see resolved query state on // its first render (auth + flags settled), which used to flip the // regenerate affordance on at hydration time and trip React #418. const mounted = useMounted(); - const advisorEnabled = - mounted && isAuthenticated && flags.enabled && flags.briefing; + const advisorEnabled = mounted && isAuthenticated && briefing.available; const advisor = useInsightsAdvisorQuery(advisorEnabled); @@ -104,7 +97,10 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { // least one canonical row in the list-endpoint response. The // single-row probe shares its cache slot with the list page + // dashboard tile so navigation between surfaces is a free cache hit. - const workoutsProbe = useWorkouts({ limit: 1 }); + // Only while the Workouts module shows: a switched-off module is never + // probed (its pill is hidden by the surface map regardless). + const workoutsVisible = useSurfaceVisible("insights-page:workouts"); + const workoutsProbe = useWorkouts({ limit: 1, enabled: workoutsVisible }); // v1.29 — nutrients gate. `nutrients` is opt-in (default off), unlike // the always-on metric clusters, so the pill's floor is the module diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 0714a96ab..6017824e3 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -82,7 +82,8 @@ export interface InsightsTabStripProps { * v1.15.18 — the outcome of the last settled regenerate. The falling-edge * toast fires "refreshed" ONLY when this is `"fresh"`; a slow generation the * client gave up on (`"timeout"`) shows a "still working" hint instead of a - * misleading success, and a missing provider (`"no-provider"`) stays silent + * misleading success, and a missing provider (`"no-provider"`) or a refused + * briefing (`"unavailable"`) stays silent * (the surface already shows the connect-AI empty state). v1.15.20 — * `"rate-limited"` (quota exhausted) and `"empty"` (transient 503) show * their own honest hints instead of the success toast. Absent (legacy @@ -94,6 +95,7 @@ export interface InsightsTabStripProps { | "rate-limited" | "timeout" | "no-provider" + | "unavailable" | "settle-failed" | null; /** @@ -649,6 +651,7 @@ export function regenerateToastKind( case "settle-failed": return { kind: "error", messageKey: "insights.regenerateSettleFailed" }; case "no-provider": + case "unavailable": return null; default: return { kind: "success", messageKey: "insights.regenerateSuccess" }; diff --git a/src/components/insights/mood/mood-what-stands-out.tsx b/src/components/insights/mood/mood-what-stands-out.tsx index 53e319a5d..65b12eae4 100644 --- a/src/components/insights/mood/mood-what-stands-out.tsx +++ b/src/components/insights/mood/mood-what-stands-out.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { TrendingUp } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; @@ -43,16 +44,15 @@ export function MoodWhatStandsOut({ const { isAuthenticated } = useAuth(); const { t } = useTranslations(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.insightsCorrelations(), queryFn: async () => { - // 403 = operator disabled the surface; any rejection (ApiError) - // degrades to the card's isError → render-nothing path. + // Correlations are statistics, never gated on AI; a rejection is a + // real failure and renders the error row with a retry. return apiGet("/api/insights/correlations"); }, enabled: isAuthenticated, staleTime: 60_000, - // The surface is optional — don't retry a deliberate 403 into noise. retry: false, }); @@ -79,13 +79,11 @@ export function MoodWhatStandsOut({ /> ) : null} {isError ? ( -

- {t("insights.pattern.loadError")} -

+ 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, });