From 8e6880b04cf3c1da7fce4ea2095936ca4bf5e931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 07:58:45 +0200 Subject: [PATCH 1/2] Say when the onboarding profile step could not save a field The baseline step submitted what you filled in and ignored the answer. The profile write is field-independent: it stores everything that validated and reports back what it declined. Nobody read that report here, so a value the server refused vanished between the input and the next screen and you moved on believing it was saved. It now says which field did not make it, using the name that sits next to the input you typed into, and keeps going with the rest that did save. When nothing could be saved at all, the step stays put and names the field that blocked it instead of a shrug. The wording and the field-naming come from the same place the account form in Settings uses, so the same refusal reads the same wherever you hit it. --- .../baseline-form-rejected-fields.test.ts | 186 ++++++++++++++++++ .../onboarding/baseline-form-utils.ts | 173 ++++++++++++++++ src/components/onboarding/baseline-form.tsx | 47 +++-- .../__tests__/account-section-utils.test.ts | 6 +- .../account-section/account-section-utils.ts | 43 +--- .../settings/account-section/index.tsx | 4 +- src/lib/profile/rejected-fields.ts | 62 ++++++ 7 files changed, 463 insertions(+), 58 deletions(-) create mode 100644 src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts create mode 100644 src/components/onboarding/baseline-form-utils.ts create mode 100644 src/lib/profile/rejected-fields.ts diff --git a/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts b/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts new file mode 100644 index 000000000..198ed664f --- /dev/null +++ b/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts @@ -0,0 +1,186 @@ +/** + * The onboarding baseline step used to submit the profile and throw the + * server's answer away. A height the server declined vanished between + * the input and the next screen, and the person walked off believing it + * was stored. + * + * These tests run the real assembly end to end — build the body, PUT it + * over a stubbed `fetch`, read the reply — because the gap was never in + * either end on its own. They use the real English and German bundles, + * so a sentence that reads as a key or leaks validator prose fails here. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getCachedMessages } from "@/lib/i18n/load-locale"; +import { resolveKey } from "@/lib/i18n/resolve-key"; +import { + baselineFieldLabelKeys, + buildBaselineProfileBody, + describeBaselineSaveOutcome, + putBaselineProfile, +} from "../baseline-form-utils"; + +/** A `t` backed by the shipped bundle — no stand-in strings. */ +function translator(locale: "en" | "de") { + const bundle = getCachedMessages(locale); + if (!bundle) throw new Error(`No cached bundle for ${locale}`); + return (key: string, params?: Record) => { + const raw = resolveKey(bundle as Record, key); + if (raw === undefined) return key; + return Object.entries(params ?? {}).reduce( + (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), + raw, + ); + }; +} + +const FILLED_FORM = { + displayName: "Robin", + dateOfBirth: "1988-04-02", + gender: "OTHER", +}; + +/** Stub `fetch` with one canned response and capture what was sent. */ +function stubProfilePut(status: number, body: unknown) { + const calls: Array<{ url: string; init: RequestInit }> = []; + vi.stubGlobal("fetch", (url: string, init: RequestInit) => { + calls.push({ url, init }); + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ); + }); + return calls; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("onboarding baseline profile save", () => { + it("names the rejected field and still lets the accepted ones stand", async () => { + const calls = stubProfilePut(200, { + data: { + id: "u1", + displayName: "Robin", + rejectedFields: [{ path: "heightCm", code: "too_big" }], + }, + error: null, + }); + + const body = buildBaselineProfileBody(FILLED_FORM, 940); + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(body), + translator("en"), + baselineFieldLabelKeys(false), + ); + + // Everything the person filled in travelled to the server; nothing + // was withheld because one sibling was going to be refused. + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe("/api/auth/profile"); + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + displayName: "Robin", + heightCm: 940, + dateOfBirth: "1988-04-02", + gender: "OTHER", + }); + + // The accepted fields are saved and the wizard moves on... + expect(outcome.advance).toBe(true); + // ...but the declined one is said out loud, by the name this screen + // put next to the input. + expect(outcome.notice).toEqual({ + tone: "warning", + message: "Profile saved, but Height (cm) could not be updated.", + }); + }); + + it("says nothing when every field was accepted", async () => { + stubProfilePut(200, { data: { id: "u1" }, error: null }); + + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 178)), + translator("en"), + baselineFieldLabelKeys(false), + ); + + expect(outcome).toEqual({ advance: true, notice: null }); + }); + + it("names the blocking field and holds the step when nothing was saved", async () => { + stubProfilePut(422, { + data: null, + error: 'Nothing was saved. Fix the "dateOfBirth" field and try again.', + meta: { errorCode: "profile.update.nothingSaved" }, + details: { issues: [{ path: "dateOfBirth", code: "invalid_value" }] }, + }); + + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, null)), + translator("en"), + baselineFieldLabelKeys(false), + ); + + expect(outcome.advance).toBe(false); + expect(outcome.notice?.tone).toBe("error"); + expect(outcome.notice?.message).toBe( + "Nothing was saved. Fix Date of birth and try again.", + ); + // The server's own sentence carries a schema key in quotes; it must + // not be what the person reads. + expect(outcome.notice?.message).not.toContain("dateOfBirth"); + }); + + it("speaks the person's language, not the validator's", async () => { + stubProfilePut(200, { + data: { rejectedFields: [{ path: "gender", code: "invalid_value" }] }, + error: null, + }); + + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 178)), + translator("de"), + baselineFieldLabelKeys(false), + ); + + const de = translator("de"); + expect(outcome.notice?.message).toBe( + de("settings.profilePartiallySaved", { + field: de("onboarding.baseline.genderLabel"), + }), + ); + expect(outcome.notice?.message).not.toContain("settings."); + expect(outcome.notice?.message).not.toContain("invalid_value"); + }); + + it("uses the feet-and-inches label when that is what the person read", () => { + const outcome = describeBaselineSaveOutcome( + { + ok: true, + body: { data: { rejectedFields: [{ path: "heightCm", code: "x" }] } }, + }, + translator("en"), + baselineFieldLabelKeys(true), + ); + expect(outcome.notice?.message).toContain("Height (ft, in)"); + }); + + it("falls back to the step's own sentence for a failure with no field detail", async () => { + stubProfilePut(500, { data: null, error: "boom" }); + + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 178)), + translator("en"), + baselineFieldLabelKeys(false), + ); + + expect(outcome.advance).toBe(false); + expect(outcome.notice?.message).toBe( + "Something went wrong while finishing setup. Please try again.", + ); + expect(outcome.notice?.message).not.toContain("boom"); + }); +}); diff --git a/src/components/onboarding/baseline-form-utils.ts b/src/components/onboarding/baseline-form-utils.ts new file mode 100644 index 000000000..634d5ffc9 --- /dev/null +++ b/src/components/onboarding/baseline-form-utils.ts @@ -0,0 +1,173 @@ +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { + describeRejectedProfileField, + type RejectedProfileField, +} from "@/lib/profile/rejected-fields"; +import { toProfileSex } from "@/lib/profile/sex"; + +/** + * Reading the answer to the onboarding baseline step's profile write. + * + * `PUT /api/auth/profile` is field-independent: it writes everything + * that validated and reports the rest back rather than throwing the + * whole submission away. Until now this step ignored that report, so a + * height the server declined disappeared between the input and the next + * screen with nothing said, and the person walked away believing it was + * stored. + * + * The settings account form already answers this question (see + * `handleSaveProfile` in `src/components/settings/account-section`): + * name the field in the person's own words, keep validator prose off + * the screen, and only claim a clean save when there is one. The two + * screens share the field-naming helper and the two sentences, so the + * same rejection reads the same wherever it happens; only the labels + * differ, because each screen calls the fields what its own inputs + * call them. + */ + +/** The parts of the profile response this step reads. */ +export interface BaselineProfileResponse { + ok: boolean; + /** Parsed envelope, or `null` when the body was absent or unreadable. */ + body: { + data?: { rejectedFields?: RejectedProfileField[] } | null; + meta?: { errorCode?: string } | null; + details?: { issues?: RejectedProfileField[] } | null; + } | null; +} + +export interface BaselineSaveOutcome { + /** Whether the wizard may move on to the next step. */ + advance: boolean; + /** A ready-to-show sentence, already localized. Null on a clean save. */ + notice: { tone: "warning" | "error"; message: string } | null; +} + +/** + * Collect the fields the person actually filled in. An untouched field + * is left out entirely rather than sent as an empty value, so skipping + * the step never clears something already stored. Height arrives + * already converted to canonical centimetres — the wire stays cm + * whatever unit the control was showing. + */ +export function buildBaselineProfileBody( + form: { displayName: string; dateOfBirth: string; gender: string }, + heightCm: number | null, +): Record { + const body: Record = {}; + if (form.displayName.trim()) body.displayName = form.displayName.trim(); + if (heightCm !== null) body.heightCm = heightCm; + if (form.dateOfBirth) body.dateOfBirth = form.dateOfBirth; + // Every value the profile stores travels; the guard drops only the + // "prefer not to say" placeholder. It used to list two of the three + // by hand, so a third-value answer vanished on submit without a word. + const gender = toProfileSex(form.gender); + if (gender) body.gender = gender; + return body; +} + +/** + * Write the collected fields and hand back what the server said. + * + * A raw fetch rather than `apiPut`, because the answer this step needs + * lives in the body on both sides of the `ok` line: `rejectedFields` on + * a partial success, `details.issues` when nothing landed. The throwing + * wrapper would collapse both into one opaque failure. + */ +export async function putBaselineProfile( + body: Record, +): Promise { + const res = await apiFetchRaw("/api/auth/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const parsed = (await res.json().catch(() => null)) as + BaselineProfileResponse["body"] | null; + return { ok: res.ok, body: parsed }; +} + +/** + * The labels the baseline step puts next to its own inputs, keyed by the + * schema field the server rejects. Height carries two labels because the + * control follows the unit preference, so the caller passes the one the + * person actually read. + */ +export function baselineFieldLabelKeys(usesFeetInches: boolean) { + return { + displayName: "onboarding.baseline.displayNameLabel", + heightCm: usesFeetInches + ? "onboarding.baseline.heightLabelFtIn" + : "onboarding.baseline.heightLabel", + dateOfBirth: "onboarding.baseline.dateOfBirthLabel", + gender: "onboarding.baseline.genderLabel", + }; +} + +/** + * Decide what the person is told and whether the wizard continues. + * + * - Some fields declined, the rest written: say which one did not make + * it and carry on. The accepted values are already stored; stopping + * here would only make the person re-enter what already saved. + * - Nothing written: name the field that blocked it and stay put, so + * there is something specific to fix instead of a shrug. + * - Any other failure: fall back to the step's own generic sentence. + * The server's `error` string is never shown — the stable + * `meta.errorCode` is resolved through the locale catalog instead, so + * no validator wording can reach the screen. + */ +export function describeBaselineSaveOutcome( + res: BaselineProfileResponse, + t: (key: string, params?: Record) => string, + labelKeys: Record, +): BaselineSaveOutcome { + if (res.ok) { + const rejected = res.body?.data?.rejectedFields; + const field = describeRejectedProfileField(rejected, t, labelKeys); + if (!field) return { advance: true, notice: null }; + return { + advance: true, + notice: { + tone: "warning", + message: t("settings.profilePartiallySaved", { field }), + }, + }; + } + + const errorCode = res.body?.meta?.errorCode; + const blockingField = describeRejectedProfileField( + res.body?.details?.issues, + t, + labelKeys, + ); + + if (errorCode === "profile.update.nothingSaved" && blockingField) { + return { + advance: false, + notice: { + tone: "error", + message: t("settings.profileNothingSaved", { field: blockingField }), + }, + }; + } + + return { + advance: false, + notice: { tone: "error", message: localizedCode(errorCode, t) }, + }; +} + +/** Resolve a stable error code through the catalog, or fall back to the + * step's own sentence when the code is absent or has no entry. */ +function localizedCode( + errorCode: string | undefined, + t: (key: string, params?: Record) => string, +): string { + if (errorCode) { + const key = `apiErrors.${errorCode}`; + const translated = t(key); + if (translated !== key) return translated; + } + return t("onboarding.errorGeneric"); +} diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index f905c1ca1..6ab2f7a17 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -29,12 +29,17 @@ import { useTranslations } from "@/lib/i18n/context"; import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; import { queryKeys } from "@/lib/query-keys"; -import { toProfileSex } from "@/lib/profile/sex"; import { AnamnesisCard, buildAnamnesisAboutMeBody, type AnamnesisValue, } from "@/components/onboarding/anamnesis-card"; +import { + baselineFieldLabelKeys, + buildBaselineProfileBody, + describeBaselineSaveOutcome, + putBaselineProfile, +} from "@/components/onboarding/baseline-form-utils"; /** * v1.4.25 W14b-Content — onboarding step 3 (baseline). @@ -49,7 +54,12 @@ import { * path (see `applyProfileUpdate` in `src/lib/auth/profile-update.ts`). * * Submit flow on "Save and continue": - * 1. PUT profile (best-effort — empty fields are skipped). + * 1. PUT profile (empty fields are skipped). The write is + * field-independent, so the answer is read rather than assumed: + * a field the server declined is named on screen and the rest + * still counts as saved; a submission where nothing landed keeps + * the person on this step with the blocking field named. See + * `baseline-form-utils.ts`. * 2. PUT /api/coach/about-me — only when the optional anamnesis card * (v1.17.1) was filled; preserves any existing `aboutMe` and * writes conditions / allergies encrypted at rest. @@ -134,19 +144,28 @@ export function BaselineForm() { setSaving(true); try { if (opts.saveProfile) { - const profileBody: Record = {}; - if (form.displayName.trim()) - profileBody.displayName = form.displayName.trim(); - const heightCm = heightAdapter.toCanonicalCm(form.height); - if (heightCm !== null) profileBody.heightCm = heightCm; - if (form.dateOfBirth) profileBody.dateOfBirth = form.dateOfBirth; - // Every value the profile stores travels; the guard drops only the - // "prefer not to say" placeholder. It used to list two of the three - // by hand, so a third-value answer vanished on submit without a word. - const gender = toProfileSex(form.gender); - if (gender) profileBody.gender = gender; + const profileBody = buildBaselineProfileBody( + form, + heightAdapter.toCanonicalCm(form.height), + ); if (Object.keys(profileBody).length > 0) { - await apiPut("/api/auth/profile", profileBody); + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(profileBody), + t, + baselineFieldLabelKeys(heightAdapter.usesFeetInches), + ); + if (outcome.notice) { + const show = + outcome.notice.tone === "warning" ? toast.warning : toast.error; + show(outcome.notice.message); + } + if (!outcome.advance) { + // Nothing was written. Staying on the step is the point — + // the person has a named field to fix and the values they + // typed are still in front of them. + setSaving(false); + return; + } } // Anamnesis — only write when the user actually filled a field diff --git a/src/components/settings/account-section/__tests__/account-section-utils.test.ts b/src/components/settings/account-section/__tests__/account-section-utils.test.ts index d1ef5b814..304d46848 100644 --- a/src/components/settings/account-section/__tests__/account-section-utils.test.ts +++ b/src/components/settings/account-section/__tests__/account-section-utils.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect } from "vitest"; -import { - describeRejectedProfileField, - resolveInitialTimezone, -} from "../account-section-utils"; +import { describeRejectedProfileField } from "@/lib/profile/rejected-fields"; +import { resolveInitialTimezone } from "../account-section-utils"; describe("describeRejectedProfileField", () => { const t = (key: string) => `translated:${key}`; diff --git a/src/components/settings/account-section/account-section-utils.ts b/src/components/settings/account-section/account-section-utils.ts index a2a455267..d37ab9683 100644 --- a/src/components/settings/account-section/account-section-utils.ts +++ b/src/components/settings/account-section/account-section-utils.ts @@ -61,44 +61,9 @@ export function statusText( return "key" in msg ? t(msg.key, msg.params) : msg.text; } -/** A single rejected-field entry from `applyProfileUpdate`'s wire shape - * (`details.issues` on failure, `data.rejectedFields` on a partial - * success) — `path` is the schema field name, never shown verbatim. */ -export interface RejectedProfileField { - path: string; - code: string; - message?: string; -} - /** - * Maps a rejected field's schema `path` to the same i18n label already - * shown next to that input on this screen. The server only ever knows - * the field by its schema key (`heightCm`, `gender`, ...) — naming it - * in a person-facing sentence is the client's job, using labels that - * already exist and are already localized for this form. + * Naming a rejected profile field is shared with the onboarding + * baseline step — see `src/lib/profile/rejected-fields.ts`. This screen + * uses the default label map, which points at the labels these inputs + * already carry. */ -const PROFILE_FIELD_LABEL_KEYS: Record = { - email: "auth.email", - heightCm: "settings.height", - dateOfBirth: "settings.dateOfBirth", - gender: "settings.gender", - fullName: "settings.identity.fullName", - insurerName: "settings.identity.insurer", - insuranceNumber: "settings.identity.insuranceNumber", -}; - -/** - * Renders the first rejected field's label, falling back to its raw - * schema key for a field this screen has no input for (defensive — - * every field `applyProfileUpdate` accepts from this form is mapped - * above). - */ -export function describeRejectedProfileField( - fields: RejectedProfileField[] | undefined, - t: (key: string) => string, -): string | null { - const first = fields?.[0]; - if (!first) return null; - const labelKey = PROFILE_FIELD_LABEL_KEYS[first.path]; - return labelKey ? t(labelKey) : first.path; -} diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 4caf5bbb5..4cb6e4f89 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -39,9 +39,11 @@ import { detectBrowserTimezone } from "@/lib/tz/format"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { describeRejectedProfileField, + type RejectedProfileField, +} from "@/lib/profile/rejected-fields"; +import { resolveInitialTimezone, statusText, - type RejectedProfileField, type StatusMessage, } from "./account-section-utils"; import { AvatarSection } from "./avatar-section"; diff --git a/src/lib/profile/rejected-fields.ts b/src/lib/profile/rejected-fields.ts new file mode 100644 index 000000000..12cd82a20 --- /dev/null +++ b/src/lib/profile/rejected-fields.ts @@ -0,0 +1,62 @@ +/** + * Naming the profile fields a save declined. + * + * `applyProfileUpdate` (`src/lib/auth/profile-update.ts`) writes every + * field that validated and reports the rest back: `data.rejectedFields` + * on a partial success, `details.issues` when nothing could be written + * at all. Both carry the schema key (`heightCm`, `gender`, ...) and a + * machine-readable code — never a sentence a person should read. + * + * Turning that key into words is the client's job, and every screen + * that writes the profile has to do it the same way, or the same + * rejection reads differently depending on where the person happened + * to be standing. This module is that one way: the settings account + * form and the onboarding baseline step both resolve the key through + * it, each passing the labels its own inputs already carry. + */ + +/** A single rejected-field entry from `applyProfileUpdate`'s wire shape + * (`details.issues` on failure, `data.rejectedFields` on a partial + * success) — `path` is the schema field name, never shown verbatim. */ +export interface RejectedProfileField { + path: string; + code: string; + message?: string; +} + +/** + * Maps a rejected field's schema `path` to the same i18n label already + * shown next to that input on the settings account form. The server + * only ever knows the field by its schema key — naming it in a + * person-facing sentence is the client's job, using labels that already + * exist and are already localized for that form. + * + * A screen with different labels for the same fields (the onboarding + * baseline step calls them by its own names) passes its own map to + * `describeRejectedProfileField` instead. + */ +export const PROFILE_FIELD_LABEL_KEYS: Record = { + email: "auth.email", + heightCm: "settings.height", + dateOfBirth: "settings.dateOfBirth", + gender: "settings.gender", + fullName: "settings.identity.fullName", + insurerName: "settings.identity.insurer", + insuranceNumber: "settings.identity.insuranceNumber", +}; + +/** + * Renders the first rejected field's label, falling back to its raw + * schema key for a field the calling screen has no input for + * (defensive — every field a form submits is expected in its map). + */ +export function describeRejectedProfileField( + fields: RejectedProfileField[] | undefined, + t: (key: string) => string, + labelKeys: Record = PROFILE_FIELD_LABEL_KEYS, +): string | null { + const first = fields?.[0]; + if (!first) return null; + const labelKey = labelKeys[first.path]; + return labelKey ? t(labelKey) : first.path; +} From 80c8674ee1d7fb2600c49f2d103b3c3183fdbc6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 08:06:07 +0200 Subject: [PATCH 2/2] Remove the Health-Score explainer switch, which switched nothing The admin panel offered a toggle for the Health-Score explainer and described it as the ? next to the delta line. That ? had already been taken off the insights surface a release earlier, and the caption that replaced it went away entirely when the health score was replaced by the reference composite. Since then an operator could turn the switch off, get a saved confirmation, and change absolutely nothing. The toggle, its wording in all six languages, its place in the flag matrix the app and the iOS client both read, and its column on app_settings are all gone. Nothing is left to promise a control that does not exist. A guard goes in with it: every remaining assistant sub-flag has to be read by some surface, and the panel and the plumbing that carry all of them by construction do not count as proof. --- messages/de.json | 4 - messages/en.json | 4 - messages/es.json | 4 - messages/fr.json | 4 - messages/it.json | 4 - messages/pl.json | 4 - .../migration.sql | 13 ++ prisma/schema.prisma | 15 +- .../api/admin/backups/[id]/restore/route.ts | 2 - .../assistant-flags/__tests__/route.test.ts | 2 - .../admin/settings/assistant-flags/route.ts | 12 +- .../api/feature-flags/__tests__/route.test.ts | 6 - src/app/api/feature-flags/route.ts | 3 +- .../coach-route-gate-inventory.test.ts | 2 +- .../__tests__/route.test.ts | 1 - .../__tests__/route.test.ts | 1 - .../insights/cards/__tests__/route.test.ts | 2 - .../correlations/__tests__/route.test.ts | 2 - .../metric-status/__tests__/route.test.ts | 1 - .../pregenerate/__tests__/route.test.ts | 2 - src/components/admin/assistant-section.tsx | 20 +-- .../insight-status-card-flag-gate.test.tsx | 4 - .../__tests__/modules-section.test.tsx | 1 - .../__tests__/use-feature-flags.test.tsx | 3 - src/hooks/use-feature-flags.ts | 5 +- src/lib/dashboard/__tests__/snapshot.test.ts | 2 - .../__tests__/full-backup-payload.test.ts | 1 - .../__tests__/coach-cascade.test.tsx | 1 - src/lib/feature-flags/__tests__/index.test.ts | 12 -- .../__tests__/no-dead-assistant-flag.test.ts | 137 ++++++++++++++++++ src/lib/feature-flags/index.ts | 21 +-- src/lib/mcp/__tests__/tools.test.ts | 2 - src/lib/validations/admin.ts | 1 - src/lib/validations/backup.ts | 1 - .../admin-backups-canonical-roundtrip.test.ts | 1 - 35 files changed, 169 insertions(+), 131 deletions(-) create mode 100644 prisma/migrations/0288_drop_health_score_explainer_flag/migration.sql create mode 100644 src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts 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"),