mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(onboarding): persist goal selection and seed the dashboard
Wire the goals step to actually keep its promise. The picker now posts the chosen slugs on the step-2 submit; the step endpoint validates them against the closed slug enum (422 on an unknown slug), persists them to User.onboardingGoals field-by-field, and on completion seeds dashboardWidgetsJson from the selection — promoting the goal-mapped tiles to the top and forcing them visible. The seed is one-time, gated on a null dashboardWidgetsJson via the updateMany WHERE clause, so a user who already arranged tiles is never clobbered. An empty or general-wellness-only selection leaves the column untouched (default layout). The resulting layout is what both the web dashboard and the iOS widgets contract read — no client recompute. Skip posts an explicit empty array (deliberate no preference).
This commit is contained in:
1 parent
5b756f01f2
commit
5f601426a9
5 files changed
+431
-15
No files matched your search
@@ -21,6 +21,7 @@ vi.mock("@/lib/db", () => ({
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
toJson: (v: unknown) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
@@ -249,3 +250,147 @@ describe("POST /api/onboarding/step — rate limit", () => {
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/onboarding/step — goal persistence (v1.17.1)", () => {
|
||||
it("persists the selected goal slugs on the step-2 submit", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
onboardingStep: 1,
|
||||
onboardingCompletedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
||||
onboardingStep: 2,
|
||||
onboardingCompletedAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
req({ step: 2, goals: ["weight-management", "bp-tracking"] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
|
||||
expect(data).toMatchObject({
|
||||
onboardingStep: 2,
|
||||
onboardingGoals: ["weight-management", "bp-tracking"],
|
||||
});
|
||||
});
|
||||
|
||||
it("persists an explicit empty array on skip (no preference)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
onboardingStep: 1,
|
||||
onboardingCompletedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
||||
onboardingStep: 2,
|
||||
onboardingCompletedAt: null,
|
||||
} as never);
|
||||
|
||||
await POST(req({ step: 2, goals: [] }));
|
||||
const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
|
||||
expect(data).toMatchObject({ onboardingGoals: [] });
|
||||
});
|
||||
|
||||
it("rejects an unknown goal slug with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(req({ step: 2, goals: ["not-a-real-goal"] }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT write onboardingGoals when the field is omitted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
onboardingStep: 1,
|
||||
onboardingCompletedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
||||
onboardingStep: 2,
|
||||
onboardingCompletedAt: null,
|
||||
} as never);
|
||||
|
||||
await POST(req({ step: 2 }));
|
||||
const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
|
||||
expect(data).not.toHaveProperty("onboardingGoals");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/onboarding/step — dashboard seeding on completion", () => {
|
||||
it("seeds the dashboard from stored goals when dashboardWidgetsJson is null", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// 1st findUnique = the fresh step-read; 2nd = the seed-read.
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
onboardingStep: 3,
|
||||
onboardingCompletedAt: null,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
onboardingGoals: ["glucose-tracking"],
|
||||
dashboardWidgetsJson: null,
|
||||
} as never);
|
||||
// 1st updateMany = the step claim; 2nd = the gated seed write.
|
||||
vi.mocked(prisma.user.updateMany)
|
||||
.mockResolvedValueOnce({ count: 1 } as never)
|
||||
.mockResolvedValueOnce({ count: 1 } as never);
|
||||
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
||||
onboardingStep: 4,
|
||||
onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
|
||||
} as never);
|
||||
|
||||
const res = await POST(req({ step: 4 }));
|
||||
expect(res.status).toBe(200);
|
||||
// The second updateMany is the seed; its WHERE pins the null gate
|
||||
// and its data carries a dashboard layout.
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledTimes(2);
|
||||
const seedCall = vi.mocked(prisma.user.updateMany).mock.calls[1][0];
|
||||
expect(seedCall.data).toHaveProperty("dashboardWidgetsJson");
|
||||
expect(seedCall.where).toHaveProperty("dashboardWidgetsJson");
|
||||
});
|
||||
|
||||
it("does NOT seed when dashboardWidgetsJson is already set (gate holds)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
onboardingStep: 3,
|
||||
onboardingCompletedAt: null,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
onboardingGoals: ["glucose-tracking"],
|
||||
dashboardWidgetsJson: { version: 1, widgets: [] },
|
||||
} as never);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
||||
onboardingStep: 4,
|
||||
onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
|
||||
} as never);
|
||||
|
||||
const res = await POST(req({ step: 4 }));
|
||||
expect(res.status).toBe(200);
|
||||
// Only the step-claim updateMany fired — the seed was gated out
|
||||
// because the user already has a layout.
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT seed when stored goals are empty (no preference)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
onboardingStep: 3,
|
||||
onboardingCompletedAt: null,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
onboardingGoals: [],
|
||||
dashboardWidgetsJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
||||
onboardingStep: 4,
|
||||
onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
|
||||
} as never);
|
||||
|
||||
await POST(req({ step: 4 }));
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -10,9 +10,14 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
ONBOARDING_GOAL_SLUGS,
|
||||
buildGoalSeededDashboardLayout,
|
||||
} from "@/lib/onboarding/goals";
|
||||
|
||||
/**
|
||||
* v1.4.25 W14b — POST /api/onboarding/step.
|
||||
@@ -42,6 +47,16 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const stepBodySchema = z.object({
|
||||
step: z.number().int().min(1).max(4),
|
||||
// v1.17.1 — optional goal slugs from the GoalsChipPicker (step 2
|
||||
// submit). Validated against the closed slug enum so an unknown slug
|
||||
// 422s; deduped + capped at the full set size. Persisted to
|
||||
// `User.onboardingGoals` (field-by-field, no mass assignment) and on
|
||||
// completion (step 4) seeds the dashboard layout when the user never
|
||||
// customized it. Omitted leaves the stored goals untouched.
|
||||
goals: z
|
||||
.array(z.enum(ONBOARDING_GOAL_SLUGS))
|
||||
.max(ONBOARDING_GOAL_SLUGS.length)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
@@ -74,7 +89,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
return apiError("Invalid step payload", 422);
|
||||
}
|
||||
const { step } = parsed.data;
|
||||
const { step, goals } = parsed.data;
|
||||
|
||||
// Re-fetch the fresh User row so concurrent step submissions in
|
||||
// separate tabs can't race past each other — the session.user
|
||||
@@ -126,9 +141,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
onboardingCompletedAt: null,
|
||||
onboardingStep: { in: [current] },
|
||||
},
|
||||
// Field-by-field assembly — no mass assignment. `goals` only
|
||||
// lands when the client sent it (the step-2 submit); it is already
|
||||
// validated against the closed slug enum by the Zod schema, so the
|
||||
// array reaching Prisma can never contain an out-of-set value.
|
||||
data: {
|
||||
onboardingStep: step,
|
||||
...(completing ? { onboardingCompletedAt: new Date() } : {}),
|
||||
...(goals !== undefined ? { onboardingGoals: goals } : {}),
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) {
|
||||
@@ -149,11 +169,41 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
let goalsSeeded = false;
|
||||
if (completing) {
|
||||
// Mirror /api/onboarding/complete — clear the proxy-readable
|
||||
// pending cookie so the next navigation drops the /onboarding
|
||||
// redirect immediately.
|
||||
await setOnboardingPendingCookie(false);
|
||||
|
||||
// v1.17.1 — seed the dashboard from the stored goal selection.
|
||||
// ONE-TIME, gated on `dashboardWidgetsJson == null` so a user who
|
||||
// already arranged tiles is never clobbered. The seed promotes the
|
||||
// goal-mapped tiles to the top and forces them visible; an empty /
|
||||
// general-wellness-only selection produces null and leaves the
|
||||
// column untouched (default layout). The resulting layout is what
|
||||
// both the web dashboard and the iOS widgets contract read —
|
||||
// server-authoritative, no client recompute.
|
||||
const seedRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { onboardingGoals: true, dashboardWidgetsJson: true },
|
||||
});
|
||||
if (seedRow && seedRow.dashboardWidgetsJson == null) {
|
||||
const seededLayout = buildGoalSeededDashboardLayout(
|
||||
seedRow.onboardingGoals,
|
||||
);
|
||||
if (seededLayout) {
|
||||
// updateMany so the `dashboardWidgetsJson: null` precondition
|
||||
// rides in the WHERE clause — a concurrent Settings → Dashboard
|
||||
// save that lands first leaves `count = 0` and the seed is
|
||||
// skipped rather than overwriting the user's fresh layout.
|
||||
const seeded = await prisma.user.updateMany({
|
||||
where: { id: user.id, dashboardWidgetsJson: { equals: Prisma.JsonNull } },
|
||||
data: { dashboardWidgetsJson: toJson(seededLayout) },
|
||||
});
|
||||
goalsSeeded = seeded.count === 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog("onboarding.step", {
|
||||
@@ -167,7 +217,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({
|
||||
action: { name: "onboarding.step" },
|
||||
meta: { outcome: completing ? "completed" : "advanced", step },
|
||||
meta: {
|
||||
outcome: completing ? "completed" : "advanced",
|
||||
step,
|
||||
...(goals !== undefined ? { goals_count: goals.length } : {}),
|
||||
...(completing ? { goals_seeded: goalsSeeded } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
|
||||
@@ -24,22 +24,19 @@ import { apiPost } from "@/lib/api/api-fetch";
|
||||
* v1.4.25 W14b-Content — onboarding step 1 (goals).
|
||||
*
|
||||
* Multi-select chip picker for the metrics the user wants to track.
|
||||
* The goal slugs match a stable enum that v1.4.26 will land on the
|
||||
* `User` row (`User.onboardingGoals`). Until that column ships the
|
||||
* picker holds selection in component state only — there's no
|
||||
* persistence layer to drop into. The earlier localStorage block
|
||||
* had no consumer (no other surface reads the same key) and the
|
||||
* single-step wizard makes pre-selection on resume unnecessary, so
|
||||
* leaning on component state until the column lands keeps the code
|
||||
* surface honest.
|
||||
* The goal slugs are the closed enum `ONBOARDING_GOAL_SLUGS`
|
||||
* (`src/lib/onboarding/goals.ts`). Selection lives in component state
|
||||
* while the step is open; on advance it is persisted server-side to
|
||||
* `User.onboardingGoals` (v1.17.1), which seeds the dashboard tile
|
||||
* layout on completion and feeds reminder suggestions.
|
||||
*
|
||||
* CTAs:
|
||||
* - Back → `/onboarding/0`
|
||||
* - Skip → advance with empty goal set
|
||||
* - Skip → advance with empty goal set (explicit "no preference")
|
||||
* - Next → advance with chosen goal set
|
||||
*
|
||||
* Both Skip and Next call POST `/api/onboarding/step` with `{ step: 2 }`
|
||||
* and `router.push("/onboarding/2")`.
|
||||
* Both Skip and Next call POST `/api/onboarding/step` with
|
||||
* `{ step: 2, goals }` and `router.push("/onboarding/2")`.
|
||||
*/
|
||||
|
||||
export type OnboardingGoalSlug =
|
||||
@@ -122,7 +119,14 @@ export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) {
|
||||
if (advancing) return;
|
||||
setAdvancing(true);
|
||||
try {
|
||||
await apiPost("/api/onboarding/step", { step: 2 });
|
||||
// v1.17.1 — persist the chosen goal slugs alongside the step
|
||||
// write. Skip sends an empty array (explicit "no preference"),
|
||||
// not omission, so a resume can read the deliberate empty choice.
|
||||
// The server seeds the dashboard from these on completion.
|
||||
await apiPost("/api/onboarding/step", {
|
||||
step: 2,
|
||||
goals: Array.from(selected),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
router.push("/onboarding/2");
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* v1.17.1 — onboarding goal slug enum + dashboard seeding.
|
||||
*
|
||||
* Goals are a personalization seed, not a clinical target store: the
|
||||
* only leverage they have is which dashboard tiles get promoted and
|
||||
* made visible. These tests pin (a) the closed slug guard and (b) the
|
||||
* one-time seed builder's promote/forces-visible/no-op contracts.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
ONBOARDING_GOAL_SLUGS,
|
||||
GOAL_WIDGET_SEED_MAP,
|
||||
isOnboardingGoalSlug,
|
||||
buildGoalSeededDashboardLayout,
|
||||
} from "../goals";
|
||||
import {
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
type DashboardWidgetId,
|
||||
} from "@/lib/dashboard-layout";
|
||||
|
||||
describe("isOnboardingGoalSlug", () => {
|
||||
it("accepts every member of the closed slug set", () => {
|
||||
for (const slug of ONBOARDING_GOAL_SLUGS) {
|
||||
expect(isOnboardingGoalSlug(slug)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown / non-string values", () => {
|
||||
expect(isOnboardingGoalSlug("not-a-goal")).toBe(false);
|
||||
expect(isOnboardingGoalSlug("")).toBe(false);
|
||||
expect(isOnboardingGoalSlug(null)).toBe(false);
|
||||
expect(isOnboardingGoalSlug(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GOAL_WIDGET_SEED_MAP", () => {
|
||||
it("only maps to ids the dashboard layout actually knows", () => {
|
||||
const known = new Set<DashboardWidgetId>(
|
||||
DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id as DashboardWidgetId),
|
||||
);
|
||||
for (const ids of Object.values(GOAL_WIDGET_SEED_MAP)) {
|
||||
for (const id of ids) expect(known.has(id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves general-wellness without a tile preference", () => {
|
||||
expect(GOAL_WIDGET_SEED_MAP["general-wellness"]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGoalSeededDashboardLayout", () => {
|
||||
it("returns null for an empty selection (no seed)", () => {
|
||||
expect(buildGoalSeededDashboardLayout([])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a general-wellness-only selection", () => {
|
||||
expect(buildGoalSeededDashboardLayout(["general-wellness"])).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores unknown slugs and seeds nothing when only unknowns are passed", () => {
|
||||
expect(buildGoalSeededDashboardLayout(["bogus", "nope"])).toBeNull();
|
||||
});
|
||||
|
||||
it("promotes glucose to the top and forces it visible", () => {
|
||||
const layout = buildGoalSeededDashboardLayout(["glucose-tracking"]);
|
||||
expect(layout).not.toBeNull();
|
||||
const widgets = layout!.widgets;
|
||||
// glucose is default-invisible + ordered low; the seed lifts it to
|
||||
// order 0 and turns both surfaces on.
|
||||
const glucose = widgets.find((w) => w.id === "glucose");
|
||||
expect(glucose).toMatchObject({
|
||||
order: 0,
|
||||
visible: true,
|
||||
tileVisible: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes every mapped tile for a multi-goal selection", () => {
|
||||
const layout = buildGoalSeededDashboardLayout([
|
||||
"weight-management",
|
||||
"bp-tracking",
|
||||
]);
|
||||
expect(layout).not.toBeNull();
|
||||
const widgets = layout!.widgets;
|
||||
const promotedIds = new Set(["weight", "bodyFat", "bp", "bpInTarget"]);
|
||||
// Every promoted id sits in the leading block and is visible.
|
||||
for (const id of promotedIds) {
|
||||
const w = widgets.find((x) => x.id === id);
|
||||
expect(w?.visible).toBe(true);
|
||||
expect(w?.tileVisible).toBe(true);
|
||||
expect(w!.order).toBeLessThan(promotedIds.size);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a dense, complete widget set (no tiles dropped)", () => {
|
||||
const layout = buildGoalSeededDashboardLayout(["sleep-improvement"]);
|
||||
expect(layout!.widgets.length).toBe(
|
||||
DEFAULT_DASHBOARD_LAYOUT.widgets.length,
|
||||
);
|
||||
const orders = layout!.widgets.map((w) => w.order).sort((a, b) => a - b);
|
||||
expect(orders).toEqual(orders.map((_, i) => i));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* v1.17.1 — onboarding goal slugs + dashboard seeding.
|
||||
*
|
||||
* The closed set of goal slugs the GoalsChipPicker offers, plus the
|
||||
* server-authoritative seeding that turns a goal selection into a
|
||||
* personalization signal: which dashboard tiles get promoted to the
|
||||
* top and made visible. Goals are NOT a clinical target store — the
|
||||
* targets surface (`/api/insights/targets`) is fully derived from
|
||||
* height/age/gender + clinical references. Their only leverage is
|
||||
* (a) dashboard tile pinning (here) and (b) reminder suggestions
|
||||
* (read by the reminders panel from `User.onboardingGoals`).
|
||||
*
|
||||
* One home for the slug enum so the API validation
|
||||
* (`/api/onboarding/step`), the client picker, and the seeding all
|
||||
* read the same source — same-key drift was the v1.4.16 dashboard-enum
|
||||
* bug class.
|
||||
*/
|
||||
import {
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
serializeDashboardLayout,
|
||||
type DashboardLayout,
|
||||
type DashboardWidgetId,
|
||||
} from "@/lib/dashboard-layout";
|
||||
|
||||
/** Closed slug set — mirrors `OnboardingGoalSlug` in GoalsChipPicker. */
|
||||
export const ONBOARDING_GOAL_SLUGS = [
|
||||
"weight-management",
|
||||
"bp-tracking",
|
||||
"glucose-tracking",
|
||||
"sleep-improvement",
|
||||
"medication-compliance",
|
||||
"general-wellness",
|
||||
] as const;
|
||||
|
||||
export type OnboardingGoalSlug = (typeof ONBOARDING_GOAL_SLUGS)[number];
|
||||
|
||||
export function isOnboardingGoalSlug(
|
||||
value: unknown,
|
||||
): value is OnboardingGoalSlug {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(ONBOARDING_GOAL_SLUGS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slug → dashboard widget ids to promote. `general-wellness` maps to
|
||||
* nothing — it carries no tile preference, so a general-wellness-only
|
||||
* selection leaves the default layout untouched. Ids must be members
|
||||
* of `DASHBOARD_WIDGET_IDS`; the seed never invents an id.
|
||||
*/
|
||||
export const GOAL_WIDGET_SEED_MAP: Record<
|
||||
OnboardingGoalSlug,
|
||||
readonly DashboardWidgetId[]
|
||||
> = {
|
||||
"weight-management": ["weight", "bodyFat"],
|
||||
"bp-tracking": ["bp", "bpInTarget"],
|
||||
"glucose-tracking": ["glucose"],
|
||||
"sleep-improvement": ["sleep"],
|
||||
"medication-compliance": ["medications"],
|
||||
"general-wellness": [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the seeded dashboard layout for a goal selection.
|
||||
*
|
||||
* Returns a layout where the goal-mapped tiles are promoted to the top
|
||||
* of the order (preserving their relative order within the default
|
||||
* layout) and forced visible on both surfaces; every other tile keeps
|
||||
* its default config, shifted down. Returns `null` when there is
|
||||
* nothing to promote (empty selection or general-wellness only) so the
|
||||
* caller can skip the write entirely and leave the column null →
|
||||
* default layout.
|
||||
*
|
||||
* This is a ONE-TIME seed: the caller MUST gate it on
|
||||
* `User.dashboardWidgetsJson == null` so it never clobbers a user who
|
||||
* already arranged tiles.
|
||||
*/
|
||||
export function buildGoalSeededDashboardLayout(
|
||||
goals: readonly string[] | null | undefined,
|
||||
): DashboardLayout | null {
|
||||
if (!goals || goals.length === 0) return null;
|
||||
const promoted = new Set<DashboardWidgetId>();
|
||||
for (const slug of goals) {
|
||||
if (!isOnboardingGoalSlug(slug)) continue;
|
||||
for (const id of GOAL_WIDGET_SEED_MAP[slug]) promoted.add(id);
|
||||
}
|
||||
if (promoted.size === 0) return null;
|
||||
|
||||
const base = DEFAULT_DASHBOARD_LAYOUT.widgets;
|
||||
const promotedWidgets = base.filter((w) => promoted.has(w.id as DashboardWidgetId));
|
||||
const rest = base.filter((w) => !promoted.has(w.id as DashboardWidgetId));
|
||||
|
||||
const ordered = [...promotedWidgets, ...rest].map((w, index) => ({
|
||||
...w,
|
||||
// Force the goal-mapped tiles visible on both surfaces; leave the
|
||||
// rest exactly as the default layout had them.
|
||||
...(promoted.has(w.id as DashboardWidgetId)
|
||||
? { visible: true, tileVisible: true }
|
||||
: {}),
|
||||
order: index,
|
||||
}));
|
||||
|
||||
return serializeDashboardLayout({
|
||||
...DEFAULT_DASHBOARD_LAYOUT,
|
||||
widgets: ordered,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user