mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge work/onboarding-honesty
This commit is contained in:
commit
af70312306
42 files changed
+632
-189
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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";
|
||||
@@ -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 /
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
* "coach": true,
|
||||
* "briefing": true,
|
||||
* "insightStatus": true,
|
||||
* "correlations": true,
|
||||
* "healthScoreExplainer": true
|
||||
* "correlations": true
|
||||
* }
|
||||
* },
|
||||
* "error": null
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.healthScoreExplainer.title")}
|
||||
description={t("admin.assistant.healthScoreExplainer.description")}
|
||||
checked={raw?.assistantHealthScoreExplainerEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({
|
||||
assistantHealthScoreExplainerEnabled: checked,
|
||||
})
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -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("<InsightStatusCard> — assistant.insightStatus gate", () => {
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
correlations: true,
|
||||
healthScoreExplainer: true,
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("Your pulse is stable");
|
||||
@@ -67,7 +65,6 @@ describe("<InsightStatusCard> — assistant.insightStatus gate", () => {
|
||||
briefing: true,
|
||||
insightStatus: false,
|
||||
correlations: true,
|
||||
healthScoreExplainer: true,
|
||||
}),
|
||||
);
|
||||
expect(html).toBe("");
|
||||
@@ -82,7 +79,6 @@ describe("<InsightStatusCard> — assistant.insightStatus gate", () => {
|
||||
briefing: false,
|
||||
insightStatus: false,
|
||||
correlations: false,
|
||||
healthScoreExplainer: false,
|
||||
}),
|
||||
);
|
||||
expect(html).toBe("");
|
||||
|
||||
@@ -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<string, string | number>) => {
|
||||
const raw = resolveKey(bundle as Record<string, unknown>, 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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
const body: Record<string, unknown> = {};
|
||||
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<string, unknown>,
|
||||
): Promise<BaselineProfileResponse> {
|
||||
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, string | number>) => string,
|
||||
labelKeys: Record<string, string>,
|
||||
): 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 | number>) => string,
|
||||
): string {
|
||||
if (errorCode) {
|
||||
const key = `apiErrors.${errorCode}`;
|
||||
const translated = t(key);
|
||||
if (translated !== key) return translated;
|
||||
}
|
||||
return t("onboarding.errorGeneric");
|
||||
}
|
||||
@@ -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<string, unknown> = {};
|
||||
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
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<FeatureFlagsPayload> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -82,7 +82,6 @@ function buildClient(flags: Partial<AssistantFlagSet>): QueryClient {
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
correlations: true,
|
||||
healthScoreExplainer: true,
|
||||
...flags,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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<string, unknown> } };
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<AssistantFlagSet> {
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -91,8 +83,6 @@ export async function getAssistantFlags(): Promise<AssistantFlagSet> {
|
||||
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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = 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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -614,7 +614,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+$/),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user