import type { Combination } from './bookabilityScopes'; import { gapBlockers } from './bookabilityReasons'; // - WHICH BUCKET a combination belongs in, and therefore how the why-not panel FRAMES it. // // THE DEFECT THIS FIXES: the panel bucketed on `staffBookable`, which is the AUDIENCE axis ("who does // this stop?"), to decide FRAMING ("deliberate, or broken?"). Those are two different questions, and card // 113's own comment predicted the confusion. Until telehealth arrived every by-design blocker was also // patient-only, so one field answered both and was accidentally right. // // The telehealth gates break the coincidence: `ORG_TELEHEALTH_DISABLED` and // `SERVICE_NOT_TELEHEALTH_ELIGIBLE` are BY DESIGN and they stop EVERYONE - a practice with video off // cannot take a video booking from the front desk either. So `staffBookable` goes false, and on the old // bucketing the row landed in `gaps`: amber `#b45309`, with *"Change this under Organization // Healthcare Services"* beside it, counted in the headline *"N things patients can't book"*. // `bookabilityReasons.ts` says of exactly these codes: **"Never colour these amber, and never count them // as problems in a rollup."** A practice manager was being told that "this service can't be done as a // video visit" is a setup fault to go and fix. // // THE WRONG FIX, fenced by the card and worth repeating: do NOT add the telehealth codes to // `PATIENT_ONLY`. That set answers the audience question, and deliberately made it an ALLOWLIST // so an unclassified blocker fails CLOSED. Widening it would report a video booking as staff-bookable at // a practice that does not offer video - the exact bug fixed. // // The framing axis already had a name: `gapBlockers()` - "blockers that represent something missing or // broken". `UnbookableSaveDialog` has always used it correctly. This is the panel catching up, not a new // concept. export interface BookabilityBuckets { /** Patients can self-book. */ bookable: Combination[]; /** Something is missing or broken amber, fix guidance, counted as a problem. */ gaps: Combination[]; /** Deliberate configuration neutral, never amber, NEVER counted as a problem. */ byDesign: Combination[]; } /** * Is this combination unbookable *on purpose*? * * REQUIRES AT LEAST ONE BLOCKER, and that is a fail-safe rather than a formality. `gapBlockers([])` is * `[]`, so a combination that is somehow not bookable while reporting NO blockers would otherwise be * declared "deliberate" - the panel would quietly assure a practice that an unexplained failure is * working as intended. An unexplained non-bookable belongs in `gaps`, where someone looks at it. * Same direction of failure as allowlist: unclassified means "not yet blessed", never "fine". */ const isDeliberate = (c: Combination): boolean => c.blockers.length > 0 && gapBlockers(c.blockers).length === 0; /** * Split combinations into the three things an operator can act on differently. * * The buckets are DISJOINT and EXHAUSTIVE - every combination lands in exactly one, which is what lets * the panel render them as three sections without double-counting a row in the headline. */ export const bucketCombinations = (combinations: readonly Combination[]): BookabilityBuckets => ({ bookable: combinations.filter((c) => c.bookable), gaps: combinations.filter((c) => !c.bookable && !isDeliberate(c)), byDesign: combinations.filter((c) => !c.bookable && isDeliberate(c)), });