// - reading the server's completeness BOOLEANS. There is no value here and there never will be. // // Spencer's ruling, 2026-08-10: a staff member's personal mobile and email live on their own home // gear, an employing org never sees them - *"we just have to hound them from the staff view side until // it's done and clearly explain why it's important they do it."* So the staff surfaces get a nudge built // entirely out of yes/no answers the server derived. // // THE WHOLE RISK IN THIS FILE IS CLAIMING SOMETHING IS MISSING WHEN WE SIMPLY DID NOT LOOK. // That has produced a shipped defect on this codebase four times - `?? 100` appointments, `?? 2` seats // (which hard-blocked every org with two staff), `?? 1` questionnaires, and a page rendering "0 // questionnaires" directly above two listed rows. Every one was a fallback turning "unknown" into a // confident number. Here the same mistake would send an admin to chase somebody who already complied, // which is how a nag surface earns itself ignored. Hence the two rules below, both pinned by tests: // // 1. **No completeness object NOTHING is missing.** A non-staff caller receives no flags at all, and // absence is not evidence. // 2. **`scope: 'summary'` the home-gear items are NOT reported.** The staff LIST resolves only what // is free off the Practitioner; the personal phone and email are answered on the by-id read. The // discriminant exists so this file cannot read an unevaluated field as `false`. // export type StaffProfileCompleteness = | { scope: 'summary'; hasNpi: boolean; hasHomeProfile: boolean } | { scope: 'full'; hasNpi: boolean; hasHomeProfile: boolean; hasPersonalPhone: boolean; hasPersonalEmail: boolean; }; export interface MissingProfileItem { key: 'homeProfile' | 'npi' | 'personalPhone' | 'personalEmail'; label: string; /** Shown on the detail surface - the reason, because a nag with no reason gets zero adoption. */ why: string; } export interface MissingProfileOptions { /** * Spencer: *"npi helps satisfy practitioners but what about non-practitioner staff? that's where * a personal mobile number will matter."* A receptionist has no NPI and never will, so chasing one * would be a permanent false red on exactly the people the badge needs to stay credible for. */ isProvider: boolean; } /** * What this person still has not supplied - never why we could not tell. * * Returns `[]` when there is nothing to report AND when there was nothing to read. Those two are * deliberately indistinguishable to the caller: both mean "do not nag", which is the only safe default. */ export function missingProfileItems( completeness: StaffProfileCompleteness | undefined, { isProvider }: MissingProfileOptions ): MissingProfileItem[] { if (!completeness) return []; const missing: MissingProfileItem[] = []; if (!completeness.hasHomeProfile) { missing.push({ key: 'homeProfile', label: 'Personal profile not set up', why: 'Their personal details live in their own patient profile, separate from this staff record. Until it exists there is nowhere for a personal phone or email to be stored.' }); } if (isProvider && !completeness.hasNpi) { missing.push({ key: 'npi', label: 'No NPI on file', why: 'The NPI is how we recognise a clinician who already works at another practice on Akashic, so their record is not duplicated.' }); } // The home-gear items are reported ONLY on a full read. On the list they were never evaluated, and // saying "no personal phone on file" there would be a claim about a question nobody asked. if (completeness.scope === 'full') { if (completeness.hasHomeProfile && !completeness.hasPersonalPhone) { missing.push({ key: 'personalPhone', label: 'No personal phone on file', why: 'Stored in their own personal profile — never shown to this organisation or to patients. It is what lets us recognise the same person across practices.' }); } if (completeness.hasHomeProfile && !completeness.hasPersonalEmail) { missing.push({ key: 'personalEmail', label: 'No personal email on file', why: 'A work email changes when they change jobs. A personal one is what keeps their record together across practices.' }); } } return missing; } /** The list-level nudge: is there anything at all to chase? */ export function isProfileIncomplete( completeness: StaffProfileCompleteness | undefined, options: MissingProfileOptions ): boolean { return missingProfileItems(completeness, options).length > 0; } /** * SPENCER, 2026-08-11 - option C. *"It's all just dummy data in staging right now so I'm really just * concerned about testing and making sure everything works as expected as we walk the UI."* * * THE PROBLEM THIS SOLVES IS NOT A STAGING ONE, and that is why it is a code change rather than a data * sweep. The `account-home-patient` link is created at HIRE, so on the day this feature reaches any * practice **every one of their existing staff has nothing set up** - a page of amber warnings on first * open. A one-off sweep would have hidden that on staging while leaving every real customer to meet it. * (It could not even have delivered a clean page: 22 of the 54 login tags measured on 2026-08-10 name * Cognito users that no longer exist, and `ensureStaffHomePatient` writes Cognito attributes, so it throws * for each of them.) * * THE DISTINCTION IS REAL, not cosmetic softening. Two different facts were wearing one amber badge: * - **`pending`** - this person has NO personal profile at all. Expected until they act, true of every * staff member who predates the feature, and not something the admin can fix. * - **`attention`** - they HAVE a profile and something is still missing. They engaged and left a gap, * which is worth chasing and is what the badge was built for. * Collapsing them meant the badge said "act on this" about a state nobody could act on - and a nag that * fires on everybody stops being read at all, which costs more than it buys. * * `pending` is NOT "nothing to report". The item list is unchanged in both states; only its WEIGHT * differs. Suppressing it would hide the gap matcher actually depends on. */ /** * `'unmeasured'` - ADDED FOR FINDING B49, AND IT IS THE WHOLE FIX. The walk saw Marketing One badge as * fine in the staff DIRECTORY and *"This person's profile is incomplete"* on their own PROFILE. One fact, * two answers. * * BOTH SURFACES WERE INDIVIDUALLY CORRECT, which is why this was not a badge bug. They ask the same * question at two DEPTHS, and the depth is deliberate: * - the **directory** reads the list projection, `scope: 'summary'` - it knows whether a home profile * EXISTS, and nothing about what is inside it; * - the **profile page** reads by id, `scope: 'full'` - it also knows about the personal phone and email. * `missingProfileItems` gates the phone/email checks on `scope === 'full'` precisely so the LIST spends no * extra read per row. Do NOT "fix" this by making the directory check everything: that is an N+1 read on * every staff page, and the scope split exists to prevent it. * * So the defect was that a summary read could return **`'complete'`** - a claim it has no evidence for. * Silence then reads as *"this one is fine"* beside rows that say *"not set up"*, and a reader concludes * Marketing One is the only complete profile in the org. **The absence of a measurement was rendering as a * measurement** - the same family as the questionnaire header's `?? 0` and `statusLabel` * returning `undefined` rather than a raw code. * * IT CHANGES NO PIXELS TODAY: both call sites already render nothing for a non-`pending`, * non-`attention` status. What it changes is that the TYPE can no longer be used to assert completeness * from a list read - so a future " Complete" badge cannot be built on a value that never measured it. */ export type StaffProfileStatus = 'complete' | 'pending' | 'attention' | 'unmeasured'; export function staffProfileStatus( completeness: StaffProfileCompleteness | undefined, options: MissingProfileOptions ): StaffProfileStatus { const missing = missingProfileItems(completeness, options); if (missing.length === 0) { // Nothing missing AT THIS DEPTH. Only a full read has looked at everything, so only a full read // may say "complete". `undefined` completeness is unmeasured too - it is the flags never having // arrived, not a person with nothing to fix. return completeness?.scope === 'full' ? 'complete' : 'unmeasured'; } // `hasHomeProfile === false` is the whole test: no home gear means nothing downstream could have been // filled in, so every item on the list is a consequence of one un-started step rather than N gaps. return completeness && !completeness.hasHomeProfile ? 'pending' : 'attention'; }