mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(api): publish the setup state on the account payload, and read it
Additive: `onboarding` carries the nine steps with their status, the answers as given, the flow's own completion stamp and the one task it offered. Always present — a record that never entered the flow reads as empty answers with nine pending steps, so a client branches on the statuses rather than on whether the field exists, and an unknown step id is skipped rather than refused. It is resolved for the CALLER's own record rather than for the active one, unlike the two module fields beside it. The three write routes are actor-only, and publishing a setup state the browser cannot write would be a read it could act on and never complete. Both ends land here, because a field with no reader is the failure this repository keeps rediscovering. The getting-started checklist orders its rows from the answers — medication first for somebody who takes one daily, the data-source row first for somebody who named a wearable — and it stays on screen while the flow is unfinished. That is the second half: five readings were never evidence that the setup finished, and now a pending step, an unconfirmed set of answers or an offered task that produced nothing keeps the list where it is. The ordering only applies once the answers are confirmed. Re-ordering the dashboard under somebody who is still mid-question would be movement they did not ask for.
This commit is contained in:
1 parent
021ea8f1ff
commit
dbfad0f28e
7 files changed
+852
-35
No files matched your search
@@ -16,7 +16,9 @@ const completeProfile = {
|
||||
gender: "MALE",
|
||||
};
|
||||
|
||||
function inputs(overrides: Partial<Parameters<typeof buildChecklist>[0]> = {}) {
|
||||
function inputs(
|
||||
overrides: Partial<Parameters<typeof buildChecklist>[0]> = {},
|
||||
): Parameters<typeof buildChecklist>[0] {
|
||||
return {
|
||||
profile: completeProfile,
|
||||
measurementCount: 0,
|
||||
@@ -25,6 +27,7 @@ function inputs(overrides: Partial<Parameters<typeof buildChecklist>[0]> = {}) {
|
||||
notificationsConfigured: false,
|
||||
insightsConfigured: false,
|
||||
dismissedIds: new Set<ChecklistItemId>(),
|
||||
onboarding: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -145,6 +148,7 @@ describe("visibleChecklist + checklistProgress", () => {
|
||||
it("returns 0% when nothing done", () => {
|
||||
const items = buildChecklist({
|
||||
profile: { heightCm: null, dateOfBirth: null, gender: null },
|
||||
onboarding: null,
|
||||
measurementCount: 0,
|
||||
medicationCount: 0,
|
||||
dataSourceConnected: false,
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
* status + dismissed-ids set) and renders against the result.
|
||||
*/
|
||||
|
||||
import {
|
||||
isOnboardingSettled,
|
||||
type OnboardingNeeds,
|
||||
type OnboardingStateDto,
|
||||
} from "./needs";
|
||||
|
||||
export const CHECKLIST_ITEM_IDS = [
|
||||
"profile",
|
||||
"measurement",
|
||||
@@ -64,6 +70,55 @@ export interface ChecklistInputs {
|
||||
insightsConfigured: boolean;
|
||||
/** Dismissed item ids (per-item localStorage state). */
|
||||
dismissedIds: ReadonlySet<ChecklistItemId>;
|
||||
/**
|
||||
* v1.39 (C1) — the needs-based setup state from `GET /api/auth/me`, or null
|
||||
* for a record that never entered the flow.
|
||||
*
|
||||
* Required rather than optional: the ordering below is the whole reason the
|
||||
* answers are published, and a caller that stops passing them should fail to
|
||||
* compile rather than quietly fall back to the fixed order.
|
||||
*/
|
||||
onboarding: OnboardingStateDto | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39 (C1) — the order the answers imply.
|
||||
*
|
||||
* The rows themselves are fixed; what the questionnaire decides is which of
|
||||
* them a person sees first. Someone who said they take medication daily should
|
||||
* not have to scroll past "connect a data source" to find it, and someone who
|
||||
* named a wearable should meet that row before the manual-entry one.
|
||||
*
|
||||
* `profile` stays first whatever the answers say — it is the identity the
|
||||
* clinical surfaces derive from, not a domain choice — and any row the answers
|
||||
* do not speak to keeps its position relative to the others. The result is a
|
||||
* reordering, never a removal: nothing here drops a row.
|
||||
*/
|
||||
export function checklistOrderFromNeeds(
|
||||
needs: OnboardingNeeds,
|
||||
): ChecklistItemId[] {
|
||||
const promoted: ChecklistItemId[] = [];
|
||||
const promote = (id: ChecklistItemId) => {
|
||||
if (!promoted.includes(id)) promoted.push(id);
|
||||
};
|
||||
|
||||
if (needs.medication === "yes" || needs.medication === "sometimes") {
|
||||
promote("medication");
|
||||
}
|
||||
// "I type them in" and "a file I already have" are not connections, so they
|
||||
// say nothing about the data-source row.
|
||||
if (needs.sources.some((s) => s !== "manual" && s !== "file")) {
|
||||
promote("dataSource");
|
||||
}
|
||||
if (needs.areas.length > 0) promote("measurement");
|
||||
|
||||
return [
|
||||
"profile",
|
||||
...promoted,
|
||||
...CHECKLIST_ITEM_IDS.filter(
|
||||
(id) => id !== "profile" && !promoted.includes(id),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,7 +167,15 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] {
|
||||
dismissed: inputs.dismissedIds.has("insights"),
|
||||
},
|
||||
];
|
||||
return items;
|
||||
if (!inputs.onboarding) return items;
|
||||
// Ordered from the answers only once the flow has been confirmed: before
|
||||
// that the answers are still being given, and re-ordering the dashboard
|
||||
// under someone mid-question would be movement they did not ask for.
|
||||
if (inputs.onboarding.completedAt === null) return items;
|
||||
const order = checklistOrderFromNeeds(inputs.onboarding.needs);
|
||||
return order
|
||||
.map((id) => items.find((item) => item.id === id))
|
||||
.filter((item): item is ChecklistItem => item !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,10 +220,23 @@ export function shouldShowChecklist(args: {
|
||||
measurementCount: number;
|
||||
dismissedAll: boolean;
|
||||
items: ChecklistItem[];
|
||||
/**
|
||||
* v1.39 (C1) — the needs-based setup state. While it is unsettled — a step
|
||||
* still pending, the flow never confirmed, or a first-result task offered
|
||||
* and never produced — the checklist stays whatever the reading count says.
|
||||
* That is the design spec's "it no longer disappears at five measurements":
|
||||
* five readings are not evidence that the setup finished.
|
||||
*
|
||||
* Optional, and absence means nothing is outstanding: a record with no
|
||||
* onboarding row has no unfinished flow to keep the list open for.
|
||||
*/
|
||||
onboarding?: OnboardingStateDto | null;
|
||||
}): boolean {
|
||||
if (args.dismissedAll) return false;
|
||||
const stillInSetup =
|
||||
args.onboardingCompletedAt == null || args.measurementCount < 5;
|
||||
args.onboardingCompletedAt == null ||
|
||||
args.measurementCount < 5 ||
|
||||
!isOnboardingSettled(args.onboarding);
|
||||
if (!stillInSetup) return false;
|
||||
const visible = visibleChecklist(args.items);
|
||||
if (visible.length === 0) return false;
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
inviteManagedProfileGuardianSchema,
|
||||
updateManagedProfileSchema,
|
||||
} from "@/lib/validations/managed-profiles";
|
||||
import { onboardingStateResource } from "./onboarding";
|
||||
import { moduleAccessMap } from "./profile";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
@@ -462,6 +463,14 @@ const accountPayload = z
|
||||
notificationPrefs: z.looseObject({
|
||||
medication: z.looseObject({ clientManaged: z.boolean() }),
|
||||
}),
|
||||
// v1.39 (C1) — enumerated for the same reason `notificationPrefs` is: a
|
||||
// client DECIDES on it. The getting-started checklist orders its rows from
|
||||
// `needs` and stays on screen while the flow is unfinished, and a future
|
||||
// native client renders the steps it knows. Always present, resolved for
|
||||
// the CALLER's own record rather than the active one — the three write
|
||||
// routes are actor-only, and publishing a setup state the caller cannot
|
||||
// write would be a read a browser could act on and never complete.
|
||||
onboarding: onboardingStateResource,
|
||||
})
|
||||
.meta({
|
||||
id: "AccountPayload",
|
||||
|
||||
Reference in new issue
Block a user