diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index dfb164401..c05923f44 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { Loader2, TrendingUp } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useTranslations } from "@/lib/i18n/context"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; @@ -115,6 +116,7 @@ export default function InsightsPage() { // on the same context, and the drawer itself is mounted next to // the provider in `src/app/insights/layout.tsx`. const coachLaunch = useCoachLaunch(); + const flags = useFeatureFlags(); const { data, isLoading } = useQuery({ queryKey: ["insights", "comprehensive"], @@ -194,15 +196,17 @@ export default function InsightsPage() { healthScore={analytics?.healthScore ?? undefined} /> - + {flags.briefing && ( + + )} - {analytics?.correlations && ( + {flags.correlations && analytics?.correlations && ( )} diff --git a/src/components/insights/__tests__/assistant-disabled-notice.test.tsx b/src/components/insights/__tests__/assistant-disabled-notice.test.tsx new file mode 100644 index 000000000..c236f7c7c --- /dev/null +++ b/src/components/insights/__tests__/assistant-disabled-notice.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { AssistantDisabledNotice } from "../assistant-disabled-notice"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("renders the coach copy in the card variant", () => { + const html = render(); + expect(html).toContain("data-slot=\"assistant-disabled-notice\""); + expect(html.toLowerCase()).toContain("coach"); + }); + + it("renders the briefing copy", () => { + const html = render(); + expect(html).toContain("data-slot=\"assistant-disabled-notice\""); + }); + + it("renders the status copy", () => { + const html = render(); + expect(html).toContain("data-slot=\"assistant-disabled-notice\""); + }); + + it("renders the inline variant without the Card wrapper", () => { + const html = render( + , + ); + expect(html).toContain("` returns `null` when + * the operator has disabled the `insightStatus` surface. Renders + * normally otherwise. + */ +function wrap(node: React.ReactNode, client: QueryClient) { + return renderToStaticMarkup( + + {node} + , + ); +} + +const baseProps = { + title: "Pulse", + icon: null, + text: "Your pulse is stable.", + hasProvider: true, + cached: false, + updatedAt: null, +}; + +function makeClient(flags: { + enabled: boolean; + coach: boolean; + briefing: boolean; + insightStatus: boolean; + correlations: boolean; + healthScoreExplainer: boolean; +}): QueryClient { + const client = new QueryClient({ + defaultOptions: { queries: { retry: 0 } }, + }); + client.setQueryData(["feature-flags"], { assistant: flags }); + return client; +} + +describe(" — assistant.insightStatus gate", () => { + it("renders the card when the flag is on", () => { + const html = wrap( + , + makeClient({ + enabled: true, + coach: true, + briefing: true, + insightStatus: true, + correlations: true, + healthScoreExplainer: true, + }), + ); + expect(html).toContain("Your pulse is stable"); + }); + + it("returns null when the operator disables the sub-flag", () => { + const html = wrap( + , + makeClient({ + enabled: true, + coach: true, + briefing: true, + insightStatus: false, + correlations: true, + healthScoreExplainer: true, + }), + ); + expect(html).toBe(""); + }); + + it("returns null when the master flag is off", () => { + const html = wrap( + , + makeClient({ + enabled: false, + coach: false, + briefing: false, + insightStatus: false, + correlations: false, + healthScoreExplainer: false, + }), + ); + expect(html).toBe(""); + }); +}); diff --git a/src/components/insights/assistant-disabled-notice.tsx b/src/components/insights/assistant-disabled-notice.tsx new file mode 100644 index 000000000..7d2bece99 --- /dev/null +++ b/src/components/insights/assistant-disabled-notice.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Sparkles } from "lucide-react"; + +import { Card, CardContent } from "@/components/ui/card"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** + * v1.4.31 — Small inline notice surfaced when an assistant surface + * is gated off by the operator. Used by callers that already + * resolved a `errorCode: "assistant.disabled."` response + * from the server or that detected the matching flag locally via + * `useFeatureFlags()`. + * + * The copy intentionally stays generic — "currently unavailable on + * this server" — so the surface doesn't leak operator-identity + * info into a user-facing string. Marc-voice English; the locale + * key carries the surface-specific phrasing. + */ +export type AssistantDisabledSurface = + | "coach" + | "briefing" + | "insightStatus" + | "correlations"; + +interface AssistantDisabledNoticeProps { + surface: AssistantDisabledSurface; + className?: string; + variant?: "card" | "inline"; +} + +const SURFACE_LABEL_KEY: Record = { + coach: "insights.coach.disabledByOperator", + briefing: "insights.briefing.disabledByOperator", + insightStatus: "insights.status.disabledByOperator", + correlations: "insights.correlations.disabledByOperator", +}; + +export function AssistantDisabledNotice({ + surface, + className, + variant = "card", +}: AssistantDisabledNoticeProps) { + const { t } = useTranslations(); + const message = t(SURFACE_LABEL_KEY[surface]); + + if (variant === "inline") { + return ( +

+ {message} +

+ ); + } + + return ( + + + + + ); +} diff --git a/src/components/insights/coach-launch-button.tsx b/src/components/insights/coach-launch-button.tsx index f559d097e..84c7dd6bc 100644 --- a/src/components/insights/coach-launch-button.tsx +++ b/src/components/insights/coach-launch-button.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; +import { useFeatureFlags } from "@/hooks/use-feature-flags"; /** * Inline desktop Coach launch button. @@ -40,12 +41,15 @@ export function CoachLaunchButton({ }: CoachLaunchButtonProps) { const { t } = useTranslations(); const launch = useCoachLaunch(); + const flags = useFeatureFlags(); if (!launch) { // The button only makes sense beneath the provider. Render nothing // so the sub-page doesn't paint a dead control. return null; } + // v1.4.31 — operator can hide the Coach surface app-wide. + if (!flags.coach) return null; const accessibleLabel = label ?? t("insights.heroActionAskCoach"); diff --git a/src/components/insights/health-score-delta-explainer.tsx b/src/components/insights/health-score-delta-explainer.tsx index 388747662..008e7c52e 100644 --- a/src/components/insights/health-score-delta-explainer.tsx +++ b/src/components/insights/health-score-delta-explainer.tsx @@ -9,6 +9,7 @@ import { } from "@/components/ui/popover"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { useIsMobile } from "@/hooks/use-is-mobile"; +import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; @@ -61,12 +62,19 @@ export function HealthScoreDeltaExplainer({ }: HealthScoreDeltaExplainerProps) { const { t } = useTranslations(); const isMobile = useIsMobile(); + const flags = useFeatureFlags(); const [open, setOpen] = useState(false); // Stable fallback id when the parent doesn't supply one. The body // still paints the id so future consumers can thread the same // describedby pattern without modifying the explainer. const generatedId = useId(); const resolvedBodyId = bodyId ?? generatedId; + // v1.4.31 — operator can hide the `?` trigger; the delta digit on + // the parent line stays visible because the parent reads it + // directly. Silent suppression per the architecture brief. The + // early return sits below every hook call so the hook order stays + // stable across renders. + if (!flags.healthScoreExplainer) return null; const triggerLabel = t("insights.healthScore.deltaExplainer.trigger"); const title = t("insights.healthScore.deltaExplainer.title"); diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index 7eeb943d2..993632730 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -7,6 +7,7 @@ import { Loader2 } from "lucide-react"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { cn } from "@/lib/utils"; +import { useFeatureFlags } from "@/hooks/use-feature-flags"; // ─── Types ──────────────────────────────────────────────── @@ -32,6 +33,11 @@ export function InsightStatusCard({ loading = false, }: 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; if (loading) { return ( diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index 0f8412b96..ba1fd7214 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; +import { useFeatureFlags } from "@/hooks/use-feature-flags"; /** * Layout-level mobile Coach launch FAB. @@ -25,7 +26,11 @@ import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; export function LayoutCoachFab() { const { t } = useTranslations(); const launch = useCoachLaunch(); + const flags = useFeatureFlags(); if (!launch) return null; + // v1.4.31 — operator can hide the Coach surface app-wide. The + // FAB returns null when the master OR the coach sub-flag is off. + if (!flags.coach) return null; const accessibleLabel = t("insights.heroActionAskCoach"); diff --git a/src/components/insights/layout-coach-mount.tsx b/src/components/insights/layout-coach-mount.tsx index b93f953b5..442a27b7d 100644 --- a/src/components/insights/layout-coach-mount.tsx +++ b/src/components/insights/layout-coach-mount.tsx @@ -2,6 +2,7 @@ import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; +import { useFeatureFlags } from "@/hooks/use-feature-flags"; /** * v1.4.27 R3d MB4 — bridge between the layout-level @@ -15,7 +16,11 @@ import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; */ export function LayoutCoachMount() { const launch = useCoachLaunch(); + const flags = useFeatureFlags(); if (!launch) return null; + // v1.4.31 — operator can hide the Coach drawer app-wide; suppress + // the entire SSE mount when the flag is off. + if (!flags.coach) return null; return ( {JSON.stringify(flags)}; +} + +function htmlEscape(s: string): string { + return s.replace(/"/g, """); +} + +describe("useFeatureFlags", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns the all-on default when no QueryClient is mounted", () => { + const html = renderToStaticMarkup(); + expect(html).toContain(htmlEscape(JSON.stringify(DEFAULT_ASSISTANT_FLAGS))); + }); + + it("returns the all-on default while the query is still loading", () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: 0 } }, + }); + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain(htmlEscape(JSON.stringify(DEFAULT_ASSISTANT_FLAGS))); + }); + + it("returns the resolved matrix when the endpoint responds", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + data: { + assistant: { + enabled: true, + coach: false, + briefing: true, + insightStatus: false, + correlations: true, + healthScoreExplainer: false, + }, + }, + }), + { status: 200 }, + ), + ), + ); + + const client = new QueryClient({ + defaultOptions: { queries: { retry: 0 } }, + }); + // Pre-populate so the SSR pass surfaces the cached payload + // synchronously without a fetch round-trip. + client.setQueryData(["feature-flags"], { + assistant: { + enabled: true, + coach: false, + briefing: true, + insightStatus: false, + correlations: true, + healthScoreExplainer: false, + }, + }); + + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain(""coach":false"); + expect(html).toContain(""insightStatus":false"); + expect(html).toContain(""healthScoreExplainer":false"); + }); +}); diff --git a/src/hooks/use-feature-flags.ts b/src/hooks/use-feature-flags.ts new file mode 100644 index 000000000..2ac5d05ab --- /dev/null +++ b/src/hooks/use-feature-flags.ts @@ -0,0 +1,99 @@ +"use client"; + +import { useContext } from "react"; +import { QueryClientContext, useQuery } from "@tanstack/react-query"; + +/** + * 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/` 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 { + const res = await fetch("/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 (``, ``) + * whose unit tests render the component without a + * ``. 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"). + */ +export function useFeatureFlags(): AssistantFlagSet { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hasClient = useContext(QueryClientContext as unknown as React.Context); + if (!hasClient) return DEFAULT_ASSISTANT_FLAGS; + // eslint-disable-next-line react-hooks/rules-of-hooks + return useFeatureFlagsQuery(); +} + +function useFeatureFlagsQuery(): AssistantFlagSet { + const query = useQuery({ + queryKey: ["feature-flags"], + 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; +}