diff --git a/messages/de.json b/messages/de.json index 510ee0524..b4a3ea36c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6176,10 +6176,6 @@ "correlations": { "title": "Korrelationen", "description": "Erzähl-Kachel zur Korrelation auf der Insights-Übersicht." - }, - "healthScoreExplainer": { - "title": "Health-Score-Erklärer", - "description": "Das ?-Symbol neben der Health-Score-Differenz. Die Zahl bleibt unabhängig davon sichtbar." } }, "modules": { diff --git a/messages/en.json b/messages/en.json index 8c12b07f3..e987f7186 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6176,10 +6176,6 @@ "correlations": { "title": "Correlations", "description": "Correlation tile narration on the Insights overview." - }, - "healthScoreExplainer": { - "title": "Health-Score explainer", - "description": "The ? glyph next to the Health-Score delta line. The delta digit stays visible regardless." } }, "modules": { diff --git a/messages/es.json b/messages/es.json index e4d6838cd..39e92050f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6176,10 +6176,6 @@ "correlations": { "title": "Correlaciones", "description": "Narración de la tarjeta de correlación en la vista general de Insights." - }, - "healthScoreExplainer": { - "title": "Explicador de Health-Score", - "description": "El icono ? junto a la línea de delta del Health-Score. El dígito del delta permanece visible siempre." } }, "modules": { diff --git a/messages/fr.json b/messages/fr.json index 3310a4e0d..574d5a491 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6176,10 +6176,6 @@ "correlations": { "title": "Corrélations", "description": "Narration de la tuile de corrélation sur la vue d'ensemble Insights." - }, - "healthScoreExplainer": { - "title": "Explicateur de Health-Score", - "description": "L'icône ? près de la ligne de delta du Health-Score. Le chiffre du delta reste visible quoi qu'il arrive." } }, "modules": { diff --git a/messages/it.json b/messages/it.json index 752f960dd..57dbf0109 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6176,10 +6176,6 @@ "correlations": { "title": "Correlazioni", "description": "Narrazione del tile di correlazione sulla panoramica Insights." - }, - "healthScoreExplainer": { - "title": "Spiegatore Health-Score", - "description": "L'icona ? accanto alla riga del delta dell'Health-Score. La cifra del delta resta visibile in ogni caso." } }, "modules": { diff --git a/messages/pl.json b/messages/pl.json index a997b9966..4049262b1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6176,10 +6176,6 @@ "correlations": { "title": "Korelacje", "description": "Narracja kafelka korelacji w przeglądzie Insights." - }, - "healthScoreExplainer": { - "title": "Objaśnienie Health-Score", - "description": "Ikona ? obok linii delty Health-Score. Cyfra delty pozostaje widoczna niezależnie." } }, "modules": { diff --git a/prisma/migrations/0288_drop_health_score_explainer_flag/migration.sql b/prisma/migrations/0288_drop_health_score_explainer_flag/migration.sql new file mode 100644 index 000000000..bb76ac3f1 --- /dev/null +++ b/prisma/migrations/0288_drop_health_score_explainer_flag/migration.sql @@ -0,0 +1,13 @@ +-- Remove the Health-Score explainer operator switch. +-- +-- The toggle gated a caption beside the Health-Score delta line. That +-- caption's component was deleted when the health score was replaced by +-- the reference composite, and the trailing "?" affordance the admin +-- description still named had already been retired a release earlier. +-- The switch has had nothing to switch since; an operator flipping it +-- changed nothing while being told they had. +-- +-- Destructive by design: the setting is gone, so its stored value goes +-- with it. +ALTER TABLE "app_settings" + DROP COLUMN IF EXISTS "assistant_health_score_explainer_enabled"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5d0562c6f..6cd6fa6a8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4364,10 +4364,10 @@ model AppSettings { defaultUserTimezone String? @map("default_user_timezone") @db.VarChar(64) // v1.4.31 — Assistant-surface operator feature flags. The master - // gates every LLM-driven surface app-wide; five sub-flags carve + // gates every LLM-driven surface app-wide; four sub-flags carve // the visibility cut per surface (Coach drawer + chat, Daily // Briefing + advisor, per-metric status cards, correlation - // narration, Health-Score delta explainer). Defaults preserve the + // narration). Defaults preserve the // v1.4.30 behaviour (every surface visible) so upgrades require // no admin action. The runtime resolver in // `src/lib/feature-flags/index.ts` forces every sub-flag false @@ -4376,12 +4376,11 @@ model AppSettings { // flag matrix gates server-routed AND iOS on-device surfaces, so // operators can opt out of every assistant surface regardless of // where the model runs. - assistantEnabled Boolean @default(true) @map("assistant_enabled") - assistantCoachEnabled Boolean @default(true) @map("assistant_coach_enabled") - assistantBriefingEnabled Boolean @default(true) @map("assistant_briefing_enabled") - assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled") - assistantCorrelationsEnabled Boolean @default(true) @map("assistant_correlations_enabled") - assistantHealthScoreExplainerEnabled Boolean @default(true) @map("assistant_health_score_explainer_enabled") + assistantEnabled Boolean @default(true) @map("assistant_enabled") + assistantCoachEnabled Boolean @default(true) @map("assistant_coach_enabled") + assistantBriefingEnabled Boolean @default(true) @map("assistant_briefing_enabled") + assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled") + assistantCorrelationsEnabled Boolean @default(true) @map("assistant_correlations_enabled") // v1.18.0 — operator-level server-wide module availability. A DISABLED // allowlist mirroring the per-user `User.modulePreferencesJson`: absent / diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index e6d28a7e7..03d749045 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -391,8 +391,6 @@ const handler = apiHandler( settings.assistantInsightStatusEnabled, assistantCorrelationsEnabled: settings.assistantCorrelationsEnabled, - assistantHealthScoreExplainerEnabled: - settings.assistantHealthScoreExplainerEnabled, moduleAvailabilityJson: settings.moduleAvailabilityJson as never, documentMaxFileBytes: settings.documentMaxFileBytes, documentQuotaBytes: BigInt(settings.documentQuotaBytes), diff --git a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts index 99fd498e0..60fef2dba 100644 --- a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts +++ b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts @@ -46,7 +46,6 @@ const ALL_ON = { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }; beforeEach(() => { @@ -140,7 +139,6 @@ describe("PUT /api/admin/settings/assistant-flags", () => { expect(body.data.resolved.briefing).toBe(false); expect(body.data.resolved.insightStatus).toBe(false); expect(body.data.resolved.correlations).toBe(false); - expect(body.data.resolved.healthScoreExplainer).toBe(false); }); it("rejects an empty body", async () => { diff --git a/src/app/api/admin/settings/assistant-flags/route.ts b/src/app/api/admin/settings/assistant-flags/route.ts index fd4fd451e..6445eca22 100644 --- a/src/app/api/admin/settings/assistant-flags/route.ts +++ b/src/app/api/admin/settings/assistant-flags/route.ts @@ -1,6 +1,6 @@ /** * `PUT /api/admin/settings/assistant-flags` — operator-side flip - * surface for the six assistant feature flags. + * surface for the five assistant feature flags. * * Dedicated endpoint (separate from the generic * `/api/admin/settings` PUT) so the admin panel can wire its @@ -47,7 +47,6 @@ const assistantFlagsSchema = z assistantBriefingEnabled: z.boolean().optional(), assistantInsightStatusEnabled: z.boolean().optional(), assistantCorrelationsEnabled: z.boolean().optional(), - assistantHealthScoreExplainerEnabled: z.boolean().optional(), }) .strict(); @@ -57,7 +56,6 @@ type AssistantFlagsRow = { assistantBriefingEnabled: boolean; assistantInsightStatusEnabled: boolean; assistantCorrelationsEnabled: boolean; - assistantHealthScoreExplainerEnabled: boolean; }; function buildResponseShape(row: AssistantFlagsRow) { @@ -67,7 +65,6 @@ function buildResponseShape(row: AssistantFlagsRow) { briefing: row.assistantBriefingEnabled, insightStatus: row.assistantInsightStatusEnabled, correlations: row.assistantCorrelationsEnabled, - healthScoreExplainer: row.assistantHealthScoreExplainerEnabled, }); return { raw: { @@ -76,8 +73,6 @@ function buildResponseShape(row: AssistantFlagsRow) { assistantBriefingEnabled: row.assistantBriefingEnabled, assistantInsightStatusEnabled: row.assistantInsightStatusEnabled, assistantCorrelationsEnabled: row.assistantCorrelationsEnabled, - assistantHealthScoreExplainerEnabled: - row.assistantHealthScoreExplainerEnabled, }, resolved, }; @@ -97,7 +92,6 @@ export const GET = apiHandler(async () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }, }); @@ -109,8 +103,6 @@ export const GET = apiHandler(async () => { settings?.assistantInsightStatusEnabled ?? true, assistantCorrelationsEnabled: settings?.assistantCorrelationsEnabled ?? true, - assistantHealthScoreExplainerEnabled: - settings?.assistantHealthScoreExplainerEnabled ?? true, }; return apiSuccess(buildResponseShape(row)); @@ -162,8 +154,6 @@ export const PUT = apiHandler(async (request: NextRequest) => { assistantBriefingEnabled: settings.assistantBriefingEnabled, assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled, assistantCorrelationsEnabled: settings.assistantCorrelationsEnabled, - assistantHealthScoreExplainerEnabled: - settings.assistantHealthScoreExplainerEnabled, }; return apiSuccess(buildResponseShape(row)); diff --git a/src/app/api/feature-flags/__tests__/route.test.ts b/src/app/api/feature-flags/__tests__/route.test.ts index 0bd8c9e44..71a4b06f2 100644 --- a/src/app/api/feature-flags/__tests__/route.test.ts +++ b/src/app/api/feature-flags/__tests__/route.test.ts @@ -72,7 +72,6 @@ describe("GET /api/feature-flags", () => { briefing: boolean; insightStatus: boolean; correlations: boolean; - healthScoreExplainer: boolean; }; }; error: null; @@ -85,7 +84,6 @@ describe("GET /api/feature-flags", () => { briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, }); }); @@ -97,7 +95,6 @@ describe("GET /api/feature-flags", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }); const res = await GET(req()); @@ -113,7 +110,6 @@ describe("GET /api/feature-flags", () => { briefing: false, insightStatus: false, correlations: false, - healthScoreExplainer: false, }); }); @@ -125,7 +121,6 @@ describe("GET /api/feature-flags", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: false, }); const res = await GET(req()); @@ -141,7 +136,6 @@ describe("GET /api/feature-flags", () => { briefing: true, insightStatus: false, correlations: true, - healthScoreExplainer: false, }); }); diff --git a/src/app/api/feature-flags/route.ts b/src/app/api/feature-flags/route.ts index 2e8d46d29..e92422307 100644 --- a/src/app/api/feature-flags/route.ts +++ b/src/app/api/feature-flags/route.ts @@ -13,8 +13,7 @@ * "coach": true, * "briefing": true, * "insightStatus": true, - * "correlations": true, - * "healthScoreExplainer": true + * "correlations": true * } * }, * "error": null diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index a21ef6e2a..6c0d67024 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -23,7 +23,7 @@ import { describe, expect, it } from "vitest"; * `requireAssistantSurface("coach")`. * 2. Other-gated — file invokes `requireAssistantSurface()` with a * different surface (`insightStatus`, - * `correlations`, `briefing`, `healthScoreExplainer`). + * `correlations`, `briefing`). * 3. Allowlisted — non-Coach Insights routes that don't gate on * the matrix at all (provider chain, settings, * targets, glp1-timeline, feedback). Allowlist diff --git a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts index 317554153..479be460f 100644 --- a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts +++ b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts @@ -129,7 +129,6 @@ describe("GET /api/insights/biomarker-assessment", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, } as never); const res = await callGet(makeReq("bm-1")); expect(res.status).toBe(403); diff --git a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts index 3b26c4286..d0bb9f16f 100644 --- a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts +++ b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts @@ -91,7 +91,6 @@ describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () = assistantBriefingEnabled: true, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, } as never); const res = await callGet(makeReq()); expect(res.status).toBe(403); diff --git a/src/app/api/insights/cards/__tests__/route.test.ts b/src/app/api/insights/cards/__tests__/route.test.ts index e4a162f53..fbd8d1a08 100644 --- a/src/app/api/insights/cards/__tests__/route.test.ts +++ b/src/app/api/insights/cards/__tests__/route.test.ts @@ -95,7 +95,6 @@ describe("GET /api/insights/cards — assistant-flag gate", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, } as never); const res = await callGet(makeReq()); expect(res.status).toBe(403); @@ -111,7 +110,6 @@ describe("GET /api/insights/cards — assistant-flag gate", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, } as never); const res = await callGet(makeReq()); expect(res.status).toBe(403); diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index b82360359..943931089 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -167,7 +167,6 @@ describe("GET /api/insights/correlations", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: false, - assistantHealthScoreExplainerEnabled: true, }); const res = await callGet(makeReq()); expect(res.status).toBe(403); @@ -185,7 +184,6 @@ describe("GET /api/insights/correlations", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }); const res = await callGet(makeReq()); expect(res.status).toBe(403); diff --git a/src/app/api/insights/metric-status/__tests__/route.test.ts b/src/app/api/insights/metric-status/__tests__/route.test.ts index f922dc5de..a98484540 100644 --- a/src/app/api/insights/metric-status/__tests__/route.test.ts +++ b/src/app/api/insights/metric-status/__tests__/route.test.ts @@ -134,7 +134,6 @@ describe("GET /api/insights/metric-status", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, } as never); const res = await callGet(makeReq("SLEEP_DURATION")); expect(res.status).toBe(403); diff --git a/src/app/api/insights/pregenerate/__tests__/route.test.ts b/src/app/api/insights/pregenerate/__tests__/route.test.ts index 68f17dac8..cec99b006 100644 --- a/src/app/api/insights/pregenerate/__tests__/route.test.ts +++ b/src/app/api/insights/pregenerate/__tests__/route.test.ts @@ -102,7 +102,6 @@ describe("POST /api/insights/pregenerate", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, } as never); const res = await callPost(makeReq()); expect(res.status).toBe(200); @@ -117,7 +116,6 @@ describe("POST /api/insights/pregenerate", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, } as never); const res = await callPost(makeReq()); expect(res.status).toBe(403); diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx index a54adbe21..556938bde 100644 --- a/src/components/admin/assistant-section.tsx +++ b/src/components/admin/assistant-section.tsx @@ -12,11 +12,10 @@ import { SettingsToggle } from "./_shared"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; /** - * v1.4.31 — operator-side panel for the six assistant feature - * flags. The master toggle gates the whole assistant; five + * v1.4.31 — operator-side panel for the five assistant feature + * flags. The master toggle gates the whole assistant; four * sub-toggles carve specific surfaces (Coach, Daily Briefing, - * per-metric status cards, correlation narration, Health-Score - * delta explainer). + * per-metric status cards, correlation narration). * * UX: * - Master toggle at the top. When off, the sub-toggles are @@ -35,7 +34,6 @@ interface AssistantFlagsResponse { assistantBriefingEnabled: boolean; assistantInsightStatusEnabled: boolean; assistantCorrelationsEnabled: boolean; - assistantHealthScoreExplainerEnabled: boolean; }; resolved: { enabled: boolean; @@ -43,7 +41,6 @@ interface AssistantFlagsResponse { briefing: boolean; insightStatus: boolean; correlations: boolean; - healthScoreExplainer: boolean; }; } @@ -154,17 +151,6 @@ export function AssistantSection() { } disabled={disabledSubs} /> - - mutation.mutate({ - assistantHealthScoreExplainerEnabled: checked, - }) - } - disabled={disabledSubs} - /> diff --git a/src/components/insights/__tests__/insight-status-card-flag-gate.test.tsx b/src/components/insights/__tests__/insight-status-card-flag-gate.test.tsx index 5f67f5f96..bc63bb811 100644 --- a/src/components/insights/__tests__/insight-status-card-flag-gate.test.tsx +++ b/src/components/insights/__tests__/insight-status-card-flag-gate.test.tsx @@ -33,7 +33,6 @@ function makeClient(flags: { briefing: boolean; insightStatus: boolean; correlations: boolean; - healthScoreExplainer: boolean; }): QueryClient { const client = new QueryClient({ defaultOptions: { queries: { retry: 0 } }, @@ -52,7 +51,6 @@ describe(" — assistant.insightStatus gate", () => { briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, }), ); expect(html).toContain("Your pulse is stable"); @@ -67,7 +65,6 @@ describe(" — assistant.insightStatus gate", () => { briefing: true, insightStatus: false, correlations: true, - healthScoreExplainer: true, }), ); expect(html).toBe(""); @@ -82,7 +79,6 @@ describe(" — assistant.insightStatus gate", () => { briefing: false, insightStatus: false, correlations: false, - healthScoreExplainer: false, }), ); expect(html).toBe(""); diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index c30bad4bd..4003760d1 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -74,7 +74,6 @@ const ALL_ON_FLAGS: AssistantFlagSet = { briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, }; const flagsSpy = vi.fn<() => AssistantFlagSet>(() => ALL_ON_FLAGS); vi.mock("@/hooks/use-feature-flags", () => ({ diff --git a/src/hooks/__tests__/use-feature-flags.test.tsx b/src/hooks/__tests__/use-feature-flags.test.tsx index 2c7cae7d3..329e23088 100644 --- a/src/hooks/__tests__/use-feature-flags.test.tsx +++ b/src/hooks/__tests__/use-feature-flags.test.tsx @@ -54,7 +54,6 @@ describe("useFeatureFlags", () => { briefing: true, insightStatus: false, correlations: true, - healthScoreExplainer: false, }, }, }), @@ -75,7 +74,6 @@ describe("useFeatureFlags", () => { briefing: true, insightStatus: false, correlations: true, - healthScoreExplainer: false, }, }); @@ -86,6 +84,5 @@ describe("useFeatureFlags", () => { ); 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 index 7606c79f0..68fe8451b 100644 --- a/src/hooks/use-feature-flags.ts +++ b/src/hooks/use-feature-flags.ts @@ -13,7 +13,7 @@ import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe"; * * 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 + * narration. 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. */ @@ -29,8 +29,6 @@ export interface AssistantFlagSet { insightStatus: boolean; /** Correlation narration tile on the mother page. */ correlations: boolean; - /** `?` glyph that opens the Health-Score delta explainer popover. */ - healthScoreExplainer: boolean; } interface FeatureFlagsPayload { @@ -44,7 +42,6 @@ export const DEFAULT_ASSISTANT_FLAGS: AssistantFlagSet = Object.freeze({ briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, }); async function fetchFeatureFlags(): Promise { diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 9aac226eb..644e4d108 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -215,7 +215,6 @@ beforeEach(() => { briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, }); fakePrisma.measurement.findMany.mockResolvedValue([]); fakePrisma.moodEntry.findMany.mockResolvedValue([]); @@ -652,7 +651,6 @@ describe("buildDashboardSnapshot — briefingState matrix", () => { briefing: false, insightStatus: false, correlations: false, - healthScoreExplainer: false, }); const snap = await buildDashboardSnapshot( fakePrisma, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index ca085debd..eb0e5794d 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -74,7 +74,6 @@ const appSettings = { assistantBriefingEnabled: false, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: false, - assistantHealthScoreExplainerEnabled: false, moduleAvailabilityJson: { nutrients: false, insights: true }, documentMaxFileBytes: 12_345_678, documentQuotaBytes: BigInt("9876543210"), diff --git a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx index 3bfa7f5bf..63bc1c149 100644 --- a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx @@ -82,7 +82,6 @@ function buildClient(flags: Partial): QueryClient { briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, ...flags, }, }); diff --git a/src/lib/feature-flags/__tests__/index.test.ts b/src/lib/feature-flags/__tests__/index.test.ts index a15c1c041..fa39c067b 100644 --- a/src/lib/feature-flags/__tests__/index.test.ts +++ b/src/lib/feature-flags/__tests__/index.test.ts @@ -43,7 +43,6 @@ describe("resolveAssistantFlags", () => { briefing: false, insightStatus: true, correlations: false, - healthScoreExplainer: true, }; expect(resolveAssistantFlags(input)).toEqual(input); }); @@ -55,7 +54,6 @@ describe("resolveAssistantFlags", () => { briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, }; expect(resolveAssistantFlags(input)).toEqual({ enabled: false, @@ -63,7 +61,6 @@ describe("resolveAssistantFlags", () => { briefing: false, insightStatus: false, correlations: false, - healthScoreExplainer: false, }); }); }); @@ -82,7 +79,6 @@ describe("getAssistantFlags", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: false, }); const flags = await getAssistantFlags(); expect(flags).toEqual({ @@ -91,7 +87,6 @@ describe("getAssistantFlags", () => { briefing: true, insightStatus: false, correlations: true, - healthScoreExplainer: false, }); }); @@ -102,7 +97,6 @@ describe("getAssistantFlags", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }); const flags = await getAssistantFlags(); expect(flags).toEqual({ @@ -111,7 +105,6 @@ describe("getAssistantFlags", () => { briefing: false, insightStatus: false, correlations: false, - healthScoreExplainer: false, }); }); @@ -130,7 +123,6 @@ describe("requireAssistantSurface", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }); const flags = await requireAssistantSurface("coach"); expect(flags.coach).toBe(true); @@ -143,7 +135,6 @@ describe("requireAssistantSurface", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }); await expect(requireAssistantSurface("coach")).rejects.toThrow( AssistantDisabledError, @@ -157,7 +148,6 @@ describe("requireAssistantSurface", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }); await expect(requireAssistantSurface("briefing")).rejects.toThrow( AssistantDisabledError, @@ -184,7 +174,6 @@ describe("per-request memoisation", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }); // Pin a stable stub object so every `getEvent()` inside this test // returns the same reference — the memo keys on identity, so a @@ -207,7 +196,6 @@ describe("per-request memoisation", () => { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }); mockEvent = { addWarning: vi.fn(), id: "req-1" }; diff --git a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts new file mode 100644 index 000000000..e60b0ab56 --- /dev/null +++ b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts @@ -0,0 +1,137 @@ +/** + * Every assistant switch an operator can flip has to move something. + * + * `healthScoreExplainer` did not. It gated a caption beside the + * Health-Score delta; the caption's component went when the health + * score was replaced by the reference composite, and the "?" control + * the admin description still named had been retired a release before + * that. The toggle stayed on the admin panel, in the flag matrix and in + * the API contract for two releases, so an operator could turn it off, + * be told it saved, and change nothing at all. A switch that lies is + * worse than no switch. + * + * Two guards, one point. The first pins the removal so the flag cannot + * drift back in pieces. The second is the general rule the removal came + * from: no sub-flag is allowed to exist without a surface that reads it. + * Plumbing does not count — the admin panel, the resolver, the hook and + * the two routes carry every flag by construction, so a dead one would + * look alive if they were allowed to vouch for it. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + ASSISTANT_FLAGS_DEFAULT, + resolveAssistantFlags, +} from "@/lib/feature-flags"; + +const ROOT = path.resolve(__dirname, "../../../.."); +const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const; + +/** Names the retired switch went by, on the wire and in the database. */ +const RETIRED_NAMES = [ + "healthScoreExplainer", + "assistantHealthScoreExplainerEnabled", + "assistant_health_score_explainer_enabled", +]; + +/** + * The files that carry every flag whatever it does: the operator panel + * that offers the switch, the resolver and hook that shape the matrix, + * and the two routes that read and write it. None of them is evidence + * that a flag gates anything. + */ +const PLUMBING = [ + "src/lib/feature-flags/index.ts", + "src/hooks/use-feature-flags.ts", + "src/components/admin/assistant-section.tsx", + "src/app/api/feature-flags/route.ts", + "src/app/api/admin/settings/assistant-flags/route.ts", +]; + +function sourceFiles(dir: string, acc: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + if (entry === "generated" || entry === "__tests__") continue; + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + sourceFiles(full, acc); + } else if (/\.tsx?$/.test(entry)) { + acc.push(full); + } + } + return acc; +} + +describe("the Health-Score explainer switch stays removed", () => { + it("is absent from the resolved flag matrix, master on and master off", () => { + const on = resolveAssistantFlags({ ...ASSISTANT_FLAGS_DEFAULT }); + const off = resolveAssistantFlags({ + ...ASSISTANT_FLAGS_DEFAULT, + enabled: false, + }); + for (const shape of [ASSISTANT_FLAGS_DEFAULT, on, off]) { + expect(Object.keys(shape)).not.toContain("healthScoreExplainer"); + } + }); + + it("declares no column on the Prisma schema", () => { + const schema = readFileSync( + path.join(ROOT, "prisma/schema.prisma"), + "utf8", + ); + for (const name of RETIRED_NAMES) { + expect(schema, `${name} is back on the schema`).not.toContain(name); + } + }); + + it("carries no operator-panel wording in any locale", () => { + for (const locale of LOCALES) { + const bundle = JSON.parse( + readFileSync(path.join(ROOT, `messages/${locale}.json`), "utf8"), + ) as { admin: { assistant: Record } }; + expect( + Object.keys(bundle.admin.assistant), + `${locale} still offers the switch`, + ).not.toContain("healthScoreExplainer"); + } + }); + + it("ships the migration that dropped the column", () => { + const sql = readFileSync( + path.join( + ROOT, + "prisma/migrations/0288_drop_health_score_explainer_flag/migration.sql", + ), + "utf8", + ); + expect(sql).toContain("assistant_health_score_explainer_enabled"); + expect(sql).toContain("DROP COLUMN"); + }); +}); + +describe("no assistant sub-flag is a dead switch", () => { + it("finds a surface that reads every flag the operator can turn off", () => { + const plumbing = new Set(PLUMBING.map((rel) => path.join(ROOT, rel))); + const sources = sourceFiles(path.join(ROOT, "src")).filter( + (file) => !plumbing.has(file), + ); + const corpus = sources.map((file) => readFileSync(file, "utf8")).join("\n"); + + const subFlags = Object.keys(ASSISTANT_FLAGS_DEFAULT).filter( + (flag) => flag !== "enabled", + ); + expect(subFlags.length).toBeGreaterThan(0); + + for (const flag of subFlags) { + const gatedOnTheServer = corpus.includes( + `requireAssistantSurface("${flag}")`, + ); + const readOnTheClient = new RegExp(`flags\\.${flag}\\b`).test(corpus); + expect( + gatedOnTheServer || readOnTheClient, + `the "${flag}" switch gates nothing — wire it to a surface or remove it`, + ).toBe(true); + } + }); +}); diff --git a/src/lib/feature-flags/index.ts b/src/lib/feature-flags/index.ts index c9126a803..35aa82242 100644 --- a/src/lib/feature-flags/index.ts +++ b/src/lib/feature-flags/index.ts @@ -5,9 +5,9 @@ import { memoizePerRequest } from "@/lib/request-cache"; /** * v1.4.31 — Assistant-surface operator feature flags. * - * Six boolean toggles on `AppSettings` carve the visibility cut for - * the seven LLM-driven surfaces. The master flag is a single kill- - * switch; the five sub-flags carve specific surfaces. + * Five boolean toggles on `AppSettings` carve the visibility cut for + * the LLM-driven surfaces. The master flag is a single kill-switch; + * the four sub-flags carve specific surfaces. * * `assistant.enabled = false` forces every sub-flag false in the * resolved shape — the master always wins. This means a server-side @@ -20,13 +20,9 @@ import { memoizePerRequest } from "@/lib/request-cache"; * shape so iOS reads the same authoritative set the web reads. */ -/** The five assistant sub-flags the operator can carve. */ +/** The four assistant sub-flags the operator can carve. */ export type AssistantSurface = - | "coach" - | "briefing" - | "insightStatus" - | "correlations" - | "healthScoreExplainer"; + "coach" | "briefing" | "insightStatus" | "correlations"; export interface AssistantFlagSet { /** Master kill-switch — when false, every sub-flag is forced false. */ @@ -39,8 +35,6 @@ export interface AssistantFlagSet { insightStatus: boolean; /** Correlation narration tile on the mother page. */ correlations: boolean; - /** `?` glyph that opens the Health-Score delta explainer popover. */ - healthScoreExplainer: boolean; } /** All-on default; mirrors v1.4.30 behaviour for fresh installs. */ @@ -50,7 +44,6 @@ export const ASSISTANT_FLAGS_DEFAULT: AssistantFlagSet = Object.freeze({ briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, }); /** @@ -80,7 +73,6 @@ export async function getAssistantFlags(): Promise { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, }, }); @@ -91,8 +83,6 @@ export async function getAssistantFlags(): Promise { briefing: settings?.assistantBriefingEnabled ?? true, insightStatus: settings?.assistantInsightStatusEnabled ?? true, correlations: settings?.assistantCorrelationsEnabled ?? true, - healthScoreExplainer: - settings?.assistantHealthScoreExplainerEnabled ?? true, }); } catch { getEvent()?.addWarning( @@ -116,7 +106,6 @@ export function resolveAssistantFlags(raw: AssistantFlagSet): AssistantFlagSet { briefing: false, insightStatus: false, correlations: false, - healthScoreExplainer: false, }; } return { ...raw }; diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index ddf9c85f8..48e013be8 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -25,7 +25,6 @@ vi.mock("@/lib/feature-flags", () => ({ briefing: true, insightStatus: true, correlations: true, - healthScoreExplainer: true, })), })); // v1.22.0 — `search` reads the record directly via Prisma; stub it so the @@ -1098,7 +1097,6 @@ describe("get_ecg_recordings — v1.30 coverage review (G3)", () => { briefing: true, insightStatus: false, correlations: true, - healthScoreExplainer: true, } as never); const result = (await tool("get_ecg_recordings").run(CTX, {})) as { present: boolean; diff --git a/src/lib/validations/admin.ts b/src/lib/validations/admin.ts index be2cce810..12725cc4a 100644 --- a/src/lib/validations/admin.ts +++ b/src/lib/validations/admin.ts @@ -101,6 +101,5 @@ export const adminSettingsSchema = z assistantBriefingEnabled: z.boolean().optional(), assistantInsightStatusEnabled: z.boolean().optional(), assistantCorrelationsEnabled: z.boolean().optional(), - assistantHealthScoreExplainerEnabled: z.boolean().optional(), }) .strict(); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 8af09e801..e103864d1 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -595,7 +595,6 @@ const appSettingsBackupSchema = z assistantBriefingEnabled: z.boolean(), assistantInsightStatusEnabled: z.boolean(), assistantCorrelationsEnabled: z.boolean(), - assistantHealthScoreExplainerEnabled: z.boolean(), moduleAvailabilityJson: z.unknown().nullable(), documentMaxFileBytes: z.number().int(), documentQuotaBytes: z.string().regex(/^\d+$/), diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index cfd86f30e..56d6f9687 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -155,7 +155,6 @@ describe("canonical disaster-recovery backup round-trip", () => { assistantBriefingEnabled: false, assistantInsightStatusEnabled: false, assistantCorrelationsEnabled: false, - assistantHealthScoreExplainerEnabled: false, moduleAvailabilityJson: { insights: true, nutrients: false }, documentMaxFileBytes: 12_345_678, documentQuotaBytes: BigInt("9876543210"),