/** * WHAT THIS PERSON HAS LEFT UNFINISHED, AND WHETHER TO SAY SO AFTER AN ANSWER. * * Spencer, 2026-08-26: *"if a user hasn't finished onboarding their org or if a patient hasn't * completed all of their onboarding steps or if a staff user hasn't completed their profile or if a * practitioner hasn't completed their profile, services, specialties and if any staff member including * practitioners don't have a schedule created yet... our ai assistant should give them a gentle nudge * to finish this stuff after an answer even if it's not related."* * * THE REASON IS THE SECOND HALF OF THAT SENTENCE. These are not tidiness items. An unfinished profile * or a missing schedule stops OTHER PEOPLE working - a practitioner with no schedule cannot be booked * by the front desk, and nobody at the desk is told why. That is also the test for whether a gap * belongs in this file at all: name the person who is stopped. If nobody is, it is not a gap, it is a * preference, and a nudge about it is noise. * * IT FAILS TO SILENT, NEVER TO A GUESS, and this is the whole safety argument. * * The onboarding checklist already carried a weaker version of this rule and stated why: the lists it * read arrive asynchronously, so a surface that computed a count before they landed would tell an admin * *"0 of 11 done"* and report that their setup had regressed. **Telling a practice they have lost their * setup is far worse than showing them a help button.** * * A NUDGE RAISES THOSE STAKES, because a count is a number and a nudge is an ASSERTION. An assistant * telling somebody their setup is unfinished is making a claim about their practice, in a sentence, * unprompted. So a probe whose fact has not arrived is `undefined`, and `undefined` produces silence - * it never blocks a gap that IS known, and it is never read as "done". * * AND IT IS COMPUTED, NEVER COMPOSED BY THE MODEL. Same architecture as the assistant's destinations * and for the same reason: a model asked *"where are my results?"* will invent `/dashboard/results` and * it reads exactly like a correct answer. A model told *"nudge them if their setup is incomplete"* will * decide for itself that it is, and produce a confident, specific, wrong sentence about somebody's * practice. The sentence below is chosen from a closed set; nothing generates it. * * IT IS NOT THE CHECKLIST COMING BACK. A checklist is a list you read. This is one sentence about the * person asking, with one link. If it ever renders as a list of steps it has reintroduced what was * deliberately taken off this surface. */ /** * The kinds of unfinished thing, IN PRECEDENCE ORDER, most consequential first. * * Ordered by WHO IS STOPPED rather than by how much work is left, because that is the reason the nudge * exists. A practitioner with no schedule blocks the entire front desk from booking them; an * organization mid-setup limits the practice; a missing personal contact costs only its owner, and only * on the day they are locked out. */ export const SETUP_GAP_KINDS = [ /** * FIRST, because it is the CAUSE of the two below it rather than a peer of them. * * Without an active clinician seat a provider cannot be booked at all, and the schedule editor does * not render the services field - so nudging them about a schedule or a service names a control that * is not on the screen. A practice that fixed every other gap still ended with a provider nobody * could book, which is why this one leads. */ 'practitionerClinicianSeat', /** Nobody can book them, and the desk is not told why. */ 'practitionerSchedule', /** The practice cannot fully operate yet. */ 'organizationSetup', /** Bookable, but not for anything in particular, and never by a patient. */ 'practitionerServices', /** Referrals and the directory cannot place them. */ 'practitionerSpecialties', /** Their visit will start with paperwork somebody has to chase. */ 'patientOnboarding', /** * Credential recovery has one door instead of two. It ranks last deliberately: it costs nobody * anything until the day it costs its owner everything. */ 'staffPersonalContact', ] as const; export type SetupGapKind = (typeof SETUP_GAP_KINDS)[number]; /** * One candidate gap, as the surface that can see it reports it. * * `incomplete` IS DELIBERATELY TRI-STATE. `undefined` is *not known yet* - a fetch that has not landed, * a permission that makes the fact unreadable, a request that failed. It is not `false`, and the two * must never be collapsed: the first means say nothing, the second means say nothing AND stop looking. */ export interface SetupGapProbe { kind: SetupGapKind; /** `true` unfinished - `false` finished - `undefined` not known, which is silence. */ incomplete: boolean | undefined; /** One short sentence. Never a list, never generated. */ text: string; /** Where they finish it. In-app route, so the nudge is one click from being actionable. */ route: string; } export interface SetupNudge { kind: SetupGapKind; text: string; route: string; } const PRECEDENCE = new Map(SETUP_GAP_KINDS.map((kind, index) => [kind, index])); /** * The one thing worth saying, or nothing at all. * * ONE, NOT A LIST, and not the first one that happens to be in the array. A caller assembling probes * cannot be relied on to order them - it assembles whatever it can see, in whatever order the hooks * resolved - so the ranking belongs here where it can be read and tested rather than at each call site. * * A probe with no text or no route is dropped rather than rendered empty: a nudge that names nothing * is worse than none, and a nudge whose link goes nowhere is the dead-control class this codebase * already removes on sight. */ export const chooseSetupNudge = (probes: readonly SetupGapProbe[]): SetupNudge | undefined => { const actionable = probes.filter( (probe) => probe.incomplete === true && probe.text.trim() !== '' && probe.route.trim() !== '', ); if (actionable.length === 0) return undefined; const best = actionable.reduce((winner, probe) => (PRECEDENCE.get(probe.kind) ?? Number.MAX_SAFE_INTEGER) < (PRECEDENCE.get(winner.kind) ?? Number.MAX_SAFE_INTEGER) ? probe : winner, ); return { kind: best.kind, text: best.text, route: best.route }; }; /** * THE ONBOARDING STEPS A NUDGE MAY READ WITHOUT FETCHING ANYTHING. * * Both checklists take a mixture of inputs: some come off a resource the session already holds, the * rest are counts of lists that arrive asynchronously. A nudge runs on any page, after an answer, so * it has no list loaded - and an unloaded list reads as EMPTY, which reads as NOT DONE. * * Rather than fetch six lists behind a help answer, or guess, the nudge consults ONLY the steps whose * inputs it actually has. Everything else is unknown, and unknown is silence. That under-reports on * purpose: a practice with an unfinished list step and a finished profile gets no nudge, which is the * safe direction. Telling somebody their setup is unfinished when it is not is the failure that costs. * * Do not add an id here without checking what feeds it. The test beside this asserts that every id * below is answered with no list loaded, which is the property that makes the whole thing safe. */ export const ORG_TASKS_ANSWERED_BY_THE_ORGANIZATION = [ 'https', 'portal-pages', 'logo', 'contact', 'practice-types', ] as const; /** * The patient steps answered by the Patient resource alone. The other three - related persons, * medications, allergies - are counts of lists, and each has its own "I confirmed I have none" flag, so * an unloaded list is indistinguishable from a patient who genuinely has none. */ export const PATIENT_TASKS_ANSWERED_BY_THE_PATIENT = [ 'system-profile', 'system-communication-preferences', ] as const; /** * Is anything in `ids` still outstanding? * * ONLY `todo` COUNTS. A `locked` step is waiting on something else, so naming it sends somebody at a * door that will not open - the same rule the onboarding resting line followed. `done` and `skipped` * are both finished as far as a nudge is concerned: skipping is a decision, and re-raising it is * exactly the nagging this must not do. */ export const anyOutstanding = ( tasks: readonly { id: string; status: 'todo' | 'done' | 'skipped' | 'locked' }[], ids: readonly string[], ): boolean => tasks.some((task) => ids.includes(task.id) && task.status === 'todo'); /** * The same question of the patient checklist, which carries a plain `completed` rather than a status: * it has no dependencies between steps, so there is nothing for a `locked` to mean. */ export const anyIncomplete = ( tasks: readonly { id: string; completed: boolean }[], ids: readonly string[], ): boolean => tasks.some((task) => ids.includes(task.id) && !task.completed); /** * The words, per gap. * * KEPT HERE RATHER THAN AT THE CALL SITES so that "what the assistant says about your setup" is one * short list somebody can read end to end. Each names the CONSEQUENCE, not the task - *"the front desk * cannot book you"* tells somebody why to care in a way *"add a schedule"* does not. * * None of these may scold. The reader is mid-question about something else entirely, and the person * whose setup is unfinished is usually somebody who has been busy, not careless. */ export const SETUP_GAP_TEXT: Readonly> = { // Names the seat and the consequence, and says it is free - because the sentence's job is to stop // somebody concluding they have hit a paywall and giving up. The first seat costs nothing. practitionerClinicianSeat: 'This provider has no clinician seat yet, so nobody can book them. Your first seat is free.', practitionerSchedule: 'You have no schedule yet, so the front desk cannot book anyone in with you.', organizationSetup: 'Your practice has a few setup steps left before everything here works.', practitionerServices: 'You are not offering any services yet, so there is nothing for a visit with you to be booked as.', practitionerSpecialties: 'Your specialties are not set, so other practices cannot find you when they send a referral.', patientOnboarding: 'A few of your details are still missing, which the practice will need before your visit.', staffPersonalContact: 'Add your personal contact details so you have another way to recover access.', };