diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 3edfc78d..f86bfa88 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -249,10 +249,21 @@ const withBrand: Decorator = (Story, context) => { // explicit `codeLookup` / `renderCodeSearch` prop) demonstrates offline coded // search. Stories that inject their own config still win (explicit overrides // context); pass `codeLookup={false}` in a story to demo the plain-text opt-out. +// +// The `user` / `device` toolbar globals drive the memory picklist's two gates, +// and double as the reference for how an app wires them: one decision at the +// mount point, not per component. const withCodeLookup: Decorator = (Story, context) => { const locale = (context.globals.locale as string) || 'en'; + const userId = (context.globals.user as string) || 'anonymous'; + const trusted = context.globals.device === 'trusted'; return ( - + ); @@ -264,7 +275,12 @@ const preview: Preview = { theme: 'light', density: 'standard', locale: 'en', + user: 'anonymous', + device: 'public', }, + // The bar stays one glyph wide but still shows the current value: `title` is + // the emoji (or a per-item icon) and the wording moves to the dropdown's + // `right` column. globalTypes: { brand: { name: 'Brand', @@ -272,16 +288,15 @@ const preview: Preview = { toolbar: { icon: 'paintbrush', items: [ - { value: 'bluehive', title: '🐝 BlueHive' }, - { value: 'ccme', title: '🌿 ccMe' }, - { value: 'default', title: 'βšͺ Default' }, - { value: 'enterprise-health', title: 'πŸ₯ Enterprise Health' }, - { value: 'mieweb', title: '🟒 MIE Web' }, - { value: 'ozwell', title: 'πŸ€– Ozwell' }, - { value: 'waggleline', title: '🍯 Waggleline' }, - { value: 'webchart', title: '🟠 WebChart' }, + { value: 'bluehive', title: '🐝', right: 'BlueHive' }, + { value: 'ccme', title: '🌿', right: 'ccMe' }, + { value: 'default', title: 'βšͺ', right: 'Default' }, + { value: 'enterprise-health', title: 'πŸ₯', right: 'Enterprise Health' }, + { value: 'mieweb', title: '🟒', right: 'MIE Web' }, + { value: 'ozwell', title: 'πŸ€–', right: 'Ozwell' }, + { value: 'waggleline', title: '🍯', right: 'Waggleline' }, + { value: 'webchart', title: '🟠', right: 'WebChart' }, ], - dynamicTitle: true, }, }, theme: { @@ -293,7 +308,7 @@ const preview: Preview = { { value: 'light', icon: 'sun', title: 'Light' }, { value: 'dark', icon: 'moon', title: 'Dark' }, ], - dynamicTitle: true, + dynamicTitle: false, }, }, density: { @@ -302,10 +317,10 @@ const preview: Preview = { toolbar: { icon: 'collapse', items: [ - { value: 'standard', title: 'Standard' }, - { value: 'condensed', title: 'Condensed' }, + { value: 'standard', icon: 'grow', title: 'Standard' }, + { value: 'condensed', icon: 'collapse', title: 'Condensed' }, ], - dynamicTitle: true, + dynamicTitle: false, }, }, locale: { @@ -314,10 +329,46 @@ const preview: Preview = { toolbar: { icon: 'globe', items: [ - { value: 'en', title: 'πŸ‡ΊπŸ‡Έ English' }, - { value: 'es', title: 'πŸ‡ͺπŸ‡Έ EspaΓ±ol (sample)' }, + { value: 'en', title: 'πŸ‡ΊπŸ‡Έ', right: 'English' }, + { value: 'es', title: 'πŸ‡ͺπŸ‡Έ', right: 'EspaΓ±ol (sample)' }, ], - dynamicTitle: true, + }, + }, + user: { + name: 'Signed in as', + description: + 'Simulated session identity. CodeLookup only remembers picked codes for a real user.', + toolbar: { + icon: 'user', + items: [ + { value: 'anonymous', title: '🚫', right: 'Not signed in' }, + { value: 'alice', title: 'πŸ‘©β€βš•οΈ', right: 'Dr. Alice' }, + { value: 'bob', title: 'πŸ‘¨β€βš•οΈ', right: 'Dr. Bob' }, + { value: 'nurse', title: 'πŸ’‰', right: 'Nurse Nia' }, + { value: 'reception', title: 'πŸ§‘β€πŸ’Ό', right: 'Reception Rae' }, + { value: 'patient', title: 'πŸ€’', right: 'Patient Pat' }, + ], + }, + }, + device: { + name: 'Device', + description: + 'Simulates the deployment’s device-trust decision (not an end-user setting): whether picked codes may be cached on this machine.', + toolbar: { + icon: 'lock', + items: [ + { + value: 'public', + icon: 'unlock', + title: 'Public kiosk β€” nothing stored', + }, + { + value: 'trusted', + icon: 'lock', + title: 'Trusted workstation β€” cached', + }, + ], + dynamicTitle: false, }, }, }, diff --git a/code-memory-plan.md b/code-memory-plan.md new file mode 100644 index 00000000..ba07b4cf --- /dev/null +++ b/code-memory-plan.md @@ -0,0 +1,105 @@ +# CodeLookup "Memory" β€” frequently-used codes picklist + +Personal MRU/frequency picklist for CodeLookup: on focus with an **empty query**, show a +"Frequently used" menu of the user's most-picked codes, scoped by **(userId, context)**. +Counts persist in IndexedDB; an optional `serverUrl` seeds the list and receives lazily +flushed count deltas. On by default wherever the provider names a signed-in user β€” +`memory={false}` opts a provider or one box out. + +> Working agreement: complete a milestone β†’ check its boxes β†’ run its verification β†’ +> commit with the listed message (ask before committing, per repo policy). + +## Decisions (locked) + +- **Storage: IndexedDB** β€” house pattern (`src/components/AI/voiceprintStore.ts`, + `src/components/SuperChat/render/attachmentCache.ts`): promise-wrapped, graceful + no-IDB degradation, testable with `fake-indexeddb`. +- **Server sync: full sync** β€” GET seed once per (user, context) per session, merge + `max(count)`; debounced POST of count deltas; `sendBeacon` on `pagehide`. +- **Identity: developer-supplied `userId`** β€” missing β†’ `'anonymous'` bucket. + Multi-user in one browser = separate buckets keyed `userId|context|fullid`. +- **Phase 1 = empty-query menu only** β€” personal ranking boost while typing is phase 2. +- **API: single prop** β€” `memory?: false | CodeLookupMemoryConfig`; absent/false = off. + +## API sketch + +- `CodeLookupMemoryConfig { context: string; userId?: string; serverUrl?: string; limit?: number /* default 8 */ }` +- `CodeLookupProps` gains `memory?: false | CodeLookupMemoryConfig` +- `CodeLookupProviderConfig` gains optional `memory` defaults (`userId`, `serverUrl`); + per-instance `context` always comes from the prop. +- IDB: db `mieweb-codelookup-memory`, store `usage`, key `` `${userId}|${context}|${fullid}` ``, + record `{ userId, context, fullid, label, codetype, fullcode, domain, count, lastUsed, pendingDelta }` + (full display fields stored β€” no shard rehydration needed). +- Sync protocol: + - `GET {serverUrl}?user={userId}&context={context}` β†’ `[{fullid,label,codetype,fullcode,domain,count}]` + - `POST {serverUrl}` body `{user, context, deltas:[{fullid,label,codetype,fullcode,domain,delta}]}` + +--- + +## Milestone 1 β€” memory store (pure TS, no React) + +`src/components/CodeLookup/memoryStore.ts`, modeled on `voiceprintStore.ts`. + +- [x] IDB open/upgrade helper with `hasIndexedDB()` guard; all APIs no-op cleanly without IDB (SSR/tests) +- [x] `recordUse(scope, result: CodifyResult)` β€” increment `count` + `pendingDelta`, set `lastUsed` +- [x] `getTopCodes(scope, limit): Promise` β€” sort `count` desc, `lastUsed` tiebreak +- [x] `seedFromServer(scope, serverUrl)` β€” once per scope per session; merge `max(count)` +- [x] `scheduleFlush(scope, serverUrl)` β€” debounced POST of pending deltas; `sendBeacon` on `pagehide`; reset `pendingDelta` on success +- [x] `clearMemory(scope)` β€” reset for a (user, context) bucket + +**Commit:** `feat(CodeLookup): memory store β€” IndexedDB usage counts with lazy server sync` + +## Milestone 2 β€” store tests + +`src/components/CodeLookup/memoryStore.test.ts` (vitest + `fake-indexeddb`, already a devDep). + +- [x] record β†’ getTopCodes ordering (count desc, lastUsed tiebreak, limit) +- [x] multi-user isolation (alice vs bob, same browser/db) +- [x] context isolation (`med-orders` vs `presenting-meds`, same user) +- [x] seed merge (server counts vs local counts β†’ max wins; new entries added) +- [x] delta flush: mocked fetch receives correct payload; `pendingDelta` reset; failure keeps deltas +- [x] IDB-unavailable β†’ all APIs resolve as no-ops (no throw) + +**Verify:** `pnpm vitest run src/components/CodeLookup/memoryStore.test.ts` +**Commit:** `test(CodeLookup): memory store coverage β€” isolation, seed merge, delta flush` + +## Milestone 3 β€” CodeLookup wiring + +`src/components/CodeLookup/CodeLookup.tsx` + `context.tsx` + `index.ts`. + +- [x] `memory` prop; resolve scope from prop + provider defaults (`context.tsx`: add `memory` defaults to `CodeLookupProviderConfig`, instance prop wins) +- [x] On mount / scope change: `seedFromServer` (if `serverUrl`) then load `getTopCodes` into state +- [x] Empty-query branch (`!q` in the debounced search effect): show memory entries flagged `viaMemory` instead of clearing/closing; `onFocus` opens when entries exist; `dropdownOpen` gains memory disjunct +- [x] "Frequently used" group header row (aria-labelled listbox group); existing keyboard-nav machinery reused unchanged +- [x] `pick(r)`: `recordUse` + `scheduleFlush` + refresh cached top list +- [x] Respect `initialQuery`/`skipSearchRef` seeded path β€” menu only on true empty-query focus +- [x] Export `CodeLookupMemoryConfig`, `MemoryEntry`, `clearMemory` from the component barrel (`index.ts`) β€” NOT `src/index.ts` (worker bundling constraint) + +**Verify:** `pnpm vitest run src/components/CodeLookup/engine.test.ts` (no regressions) + typecheck +**Commit:** `feat(CodeLookup): memory picklist β€” frequently-used codes menu on empty-query focus` + +## Milestone 4 β€” story + docs + +- [x] `WithMemory` story in `CodeLookup.stories.tsx`: alice/bob user toggle Γ— orders/presenting context toggle demonstrating isolation; stubbed `fetch` for `serverUrl` (or omit serverUrl and demo local-only) +- [x] `README.md`: document prop, scope semantics (user + context), sync protocol, storage, disable, multi-user +- [x] Manual pass in Storybook (http://localhost:6006): pick codes β†’ refocus shows count-ordered picklist; user/context switch isolates; page reload persists + +**Commit:** `docs(CodeLookup): memory picklist story and README` + +## Milestone 5 β€” final checks + +- [x] `pnpm typecheck` +- [x] `pnpm lint` +- [x] `pnpm format:fix` +- [x] Full `pnpm vitest run src/components/CodeLookup/` +- [ ] Open PR + +--- + +## Phase 2 (explicitly deferred β€” do not build now) + +- Personal-count boost in `searchShards` ranking while typing (compose with population `docPrior`) + β€” *partially delivered*: matching remembered codes are merged ahead of index hits in the + component (`matchMemory`); in-engine score composition is still deferred. +- Wrapper components (MedicationEditor, OrderEditor, AllergyManager, esheet fields) forwarding `memory` contexts through their `codeLookup` configs +- Reference server endpoint implementation diff --git a/eslint.config.js b/eslint.config.js index e4d628c5..5ee721be 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -125,6 +125,8 @@ export default [ // IndexedDB indexedDB: 'readonly', IDBDatabase: 'readonly', + IDBFactory: 'readonly', + IDBKeyRange: 'readonly', IDBObjectStore: 'readonly', IDBTransactionMode: 'readonly', IDBOpenDBRequest: 'readonly', diff --git a/src/components/CodeLookup/CodeLookup.stories.tsx b/src/components/CodeLookup/CodeLookup.stories.tsx index 4fb510b8..48db7373 100644 --- a/src/components/CodeLookup/CodeLookup.stories.tsx +++ b/src/components/CodeLookup/CodeLookup.stories.tsx @@ -2,6 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; import { CodeLookup, type CodifyDomain } from './CodeLookup'; import type { CodifyResult } from './engine'; +import { exportMemoryYaml, importMemoryYaml } from './memoryYaml'; +import { Button } from '../Button'; const meta: Meta = { title: 'Healthcare/CodeLookup', @@ -160,3 +162,151 @@ export const QualityMeasures: Story = { /> ), }; + +function MemoryTemplate({ + locale, + userId, + device, +}: { + locale?: string; + userId: string; + device: string; +}) { + const [context, setContext] = useState('med-orders'); + const [selected, setSelected] = useState(null); + const [yamlText, setYamlText] = useState(''); + const [yamlNote, setYamlNote] = useState(''); + // bump to remount CodeLookup so an import shows up in the picklist + const [reloadKey, setReloadKey] = useState(0); + const toggle = ( + label: string, + value: string, + options: string[], + onChange: (v: string) => void + ) => ( + + ); + return ( +
+
+ {toggle( + 'Context', + context, + ['med-orders', 'presenting-meds'], + setContext + )} +

+ Signed in as {userId} on a{' '} + {device === 'trusted' ? 'trusted' : 'public'} device + β€” set both in the toolbar. +

+
+ + {selected && ( +
+          {JSON.stringify(selected, null, 2)}
+        
+ )} + +
+
+ + + + + {yamlNote} + +
+