Merge work/onboarding-honesty

This commit is contained in:
Marc-André Bombeck committed 2026-07-31 08:10:39 +02:00
commit af70312306
42 files changed
+632 -189

No files matched your search

-4
View File
@@ -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": {
-4
View File
@@ -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": {
-4
View File
@@ -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": {
-4
View File
@@ -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": {
-4
View File
@@ -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": {
-4
View File
@@ -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";
+7 -8
View File
@@ -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,
});
});
+1 -2
View File
@@ -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);
+3 -17
View File
@@ -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");
}
+33 -14
View File
@@ -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("&quot;coach&quot;:false");
expect(html).toContain("&quot;insightStatus&quot;:false");
expect(html).toContain("&quot;healthScoreExplainer&quot;:false");
});
});
+1 -4
View File
@@ -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 -16
View File
@@ -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 };
-2
View File
@@ -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;
+62
View File
@@ -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;
}
-1
View File
@@ -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();
-1
View File
@@ -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"),