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