Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions src/components/CodeLookup/CodeLookup.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { CodeLookup, type CodifyDomain } from './CodeLookup';
import {
CodeLookup,
type CodifyDomain,
type CodetypeOption,
} from './CodeLookup';
import type { CodifyResult } from './engine';

const meta: Meta<typeof CodeLookup> = {
Expand Down Expand Up @@ -50,10 +54,14 @@ type Story = StoryObj<typeof CodeLookup>;
function Template({
domains,
searchDomains,
searchCodetypes,
codetypeOptions,
locale,
}: {
domains?: CodifyDomain[];
searchDomains?: CodifyDomain[];
searchCodetypes?: string[];
codetypeOptions?: CodetypeOption[];
locale?: string;
}) {
const [selected, setSelected] = useState<CodifyResult | null>(null);
Expand All @@ -64,6 +72,8 @@ function Template({
locale={locale}
domains={domains}
searchDomains={searchDomains}
searchCodetypes={searchCodetypes}
codetypeOptions={codetypeOptions}
onSelect={setSelected}
/>
{selected && (
Expand All @@ -82,11 +92,38 @@ export const AllDomains: Story = {

/** Conditions only (ICD-10 + SNOMED, ~14 MB). Try "con hea fa", "chf", "lvhf".
* Results collapse to one row per condition family (ICD-10 code root); → lists
* the specific billable codes. Planned: the drill-down will also surface
* suggested orders (labs/procedures) for the condition. */
* the specific billable codes. The segmented control lets the user restrict
* the coding system (`codetypeOptions`); a fixed programmer-set filter is the
* `searchCodetypes` prop instead (see "Conditions ICD-10 Only"). An ICD-11
* option is one `{ label: 'ICD-11', codetypes: ['ICD11'] }` away once the
* shards carry ICD-11 rows (the source dataset has none yet). Planned: the
* drill-down will also surface suggested orders (labs/procedures) for the
* condition. */
export const ConditionsOnly: Story = {
render: (_args, { globals }) => (
<Template domains={['condition']} locale={globals.locale} />
<Template
domains={['condition']}
codetypeOptions={[
{ label: 'All' },
{ label: 'ICD-10', codetypes: ['ICD10'] },
{ label: 'SNOMED', codetypes: ['SNOMED US'] },
]}
locale={globals.locale}
/>
),
};

/** Programmer-fixed coding system: `searchCodetypes={['ICD10']}` — SNOMED
* synonyms never appear, no user toggle. Swap in `['ICD11']` when the shards
* include ICD-11. */
export const ConditionsIcd10Only: Story = {
name: 'Conditions ICD-10 Only',
render: (_args, { globals }) => (
<Template
domains={['condition']}
searchCodetypes={['ICD10']}
locale={globals.locale}
/>
),
};

Expand Down
116 changes: 114 additions & 2 deletions src/components/CodeLookup/CodeLookup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ export type CodifyDomain =
| 'occupational'
| 'quality';

/** One choice in the user-facing coding-system filter (`codetypeOptions`). */
export interface CodetypeOption {
/** User-visible label (externalize/translate in the consumer), e.g. 'ICD-10' */
label: string;
/** Coding systems this option restricts to (e.g. ['ICD10']); omit for "all" */
codetypes?: string[];
}

export interface CodeLookupProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
'className' | 'onSelect'
Expand Down Expand Up @@ -78,6 +86,20 @@ export interface CodeLookupProps extends Omit<
* and SNOMED entries are dropped. Other domains are unaffected.
*/
billableOnly?: boolean;
/**
* Restrict results to these coding systems at query time (e.g. ['ICD10']
* for an ICD-10-only picker; ['ICD11'] once the shards carry ICD-11 rows).
* Applies to the drill-down too. Ignored while `codetypeOptions` is
* rendered — the user's pick wins.
*/
searchCodetypes?: string[];
/**
* Let the user pick the coding-system filter: renders a segmented control
* above the search box. The first option is selected initially — give it
* no `codetypes` for "all systems". E.g.
* `[{ label: 'All' }, { label: 'ICD-10', codetypes: ['ICD10'] }]`.
*/
codetypeOptions?: CodetypeOption[];
/** Called when a result is picked */
onSelect?: (result: CodifyResult) => void;
/**
Expand Down Expand Up @@ -173,6 +195,8 @@ export const CodeLookup = React.forwardRef<HTMLDivElement, CodeLookupProps>(
preferDomains,
preferCodetypes,
billableOnly = false,
searchCodetypes,
codetypeOptions,
programsUrl,
onSelect,
onFreeText,
Expand Down Expand Up @@ -228,6 +252,22 @@ export const CodeLookup = React.forwardRef<HTMLDivElement, CodeLookupProps>(
const preferCodetypesKey = preferCodetypes
? preferCodetypes.join(',')
: null;
// coding-system filter: the user's segmented-control pick (when
// codetypeOptions is rendered) overrides the searchCodetypes prop. Guard
// against an empty options array (falls back to searchCodetypes) and an
// index left past the end when the options shrink.
const [codetypeIdx, setCodetypeIdx] = React.useState(0);
const hasCodetypeOptions = (codetypeOptions?.length ?? 0) > 0;
const activeCodetypeIdx = hasCodetypeOptions
? Math.min(codetypeIdx, codetypeOptions!.length - 1)
: 0;
const activeCodetypes = hasCodetypeOptions
? codetypeOptions![activeCodetypeIdx]?.codetypes
: searchCodetypes;
const codetypesKey = activeCodetypes ? activeCodetypes.join(',') : null;
/** active codetypes for the stable openDrill callback */
Comment on lines +264 to +268
const codetypesRef = React.useRef(activeCodetypes);
codetypesRef.current = activeCodetypes;

React.useEffect(() => {
// A new worker starts from scratch — reset all search state so a stale
Expand Down Expand Up @@ -325,6 +365,7 @@ export const CodeLookup = React.forwardRef<HTMLDivElement, CodeLookupProps>(
domains: keyToDomains(searchDomainsKey),
prefer: keyToDomains(preferDomainsKey),
boostCodetypes: keyToDomains(preferCodetypesKey),
codetypes: keyToDomains(codetypesKey),
billableOnly,
// one row per med family; variants live in the → drill-down
collapse: true,
Expand All @@ -338,6 +379,7 @@ export const CodeLookup = React.forwardRef<HTMLDivElement, CodeLookupProps>(
searchDomainsKey,
preferDomainsKey,
preferCodetypesKey,
codetypesKey,
billableOnly,
]);

Expand Down Expand Up @@ -370,6 +412,7 @@ export const CodeLookup = React.forwardRef<HTMLDivElement, CodeLookupProps>(
query: familyTerm(parent.domain, parent.label) || parent.label,
limit: 300,
domains: [parent.domain],
codetypes: codetypesRef.current,
billableOnly: billableOnlyRef.current,
});
}, []);
Expand Down Expand Up @@ -462,6 +505,74 @@ export const CodeLookup = React.forwardRef<HTMLDivElement, CodeLookupProps>(
HTMLDivElement
>({ open: dropdownOpen, matchWidth: true });

// user-facing coding-system filter (e.g. All | ICD-10 | SNOMED)
const codetypeBtnRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const selectCodetype = (i: number) => {
setCodetypeIdx(i);
setDrill(null);
};
// ARIA radiogroup keyboard model: arrows/Home/End move selection and
// focus (roving tabIndex); only the checked radio is in the tab order.
const onCodetypeKeyDown = (
e: React.KeyboardEvent<HTMLButtonElement>,
i: number
) => {
const n = codetypeOptions!.length;
let next: number;
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
next = (i + 1) % n;
break;
case 'ArrowLeft':
case 'ArrowUp':
next = (i - 1 + n) % n;
break;
case 'Home':
next = 0;
break;
case 'End':
next = n - 1;
break;
default:
return;
}
e.preventDefault();
selectCodetype(next);
codetypeBtnRefs.current[next]?.focus();
};
const codetypeToggle = hasCodetypeOptions && (
<div
role="radiogroup"
aria-label="Coding system"
className="flex flex-wrap items-center gap-1"
>
{codetypeOptions!.map((opt, i) => (
<button
key={i}
ref={(el) => {
codetypeBtnRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={i === activeCodetypeIdx}
tabIndex={i === activeCodetypeIdx ? 0 : -1}
onClick={() => selectCodetype(i)}
onKeyDown={(e) => onCodetypeKeyDown(e, i)}
className={cn(
'rounded-full border px-2.5 py-0.5 text-xs transition-colors',
'focus:ring-ring focus:ring-2 focus:outline-none',
i === activeCodetypeIdx
? 'border-primary-800 bg-primary-800 dark:border-primary-400 dark:bg-primary-400 dark:text-primary-950 text-white'
: 'border-border text-muted-foreground hover:text-foreground'
Comment on lines +563 to +567
)}
>
{opt.label}
</button>
))}
</div>
);

const searchBox = (
<div className="relative" ref={anchorRef}>
<SearchIcon
Expand Down Expand Up @@ -496,7 +607,6 @@ export const CodeLookup = React.forwardRef<HTMLDivElement, CodeLookupProps>(
'focus:ring-ring focus:ring-2 focus:outline-none'
)}
/>

{/* floating dropdown — portaled so no ancestor can clip it */}
{dropdownOpen &&
createPortal(
Expand Down Expand Up @@ -638,10 +748,11 @@ export const CodeLookup = React.forwardRef<HTMLDivElement, CodeLookupProps>(
return (
<div
ref={ref}
className={cn('w-full', className)}
className={cn('w-full space-y-2', className)}
data-testid={dataTestId}
{...props}
>
{codetypeToggle}
{searchBox}
</div>
);
Expand All @@ -657,6 +768,7 @@ export const CodeLookup = React.forwardRef<HTMLDivElement, CodeLookupProps>(
{...props}
>
<CardContent className="space-y-2 px-4 py-4">
{codetypeToggle}
{searchBox}

{/* status line */}
Expand Down
1 change: 1 addition & 0 deletions src/components/CodeLookup/codify.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ self.onmessage = (e: MessageEvent) => {
const collapse = msg.collapse === true;
const opts = {
boostCodetypes: msg.boostCodetypes as string[] | undefined,
codetypes: msg.codetypes as string[] | undefined,
billableOnly: msg.billableOnly === true,
};
let results;
Expand Down
27 changes: 27 additions & 0 deletions src/components/CodeLookup/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,33 @@ describe('searchShards', () => {
expect(r[0].fullcode).toBe('E11.9');
});

it('restricts label and code matches to opts.codetypes', () => {
const shard = makeShard('condition', [
{ label: 'Heart failure', code: 'I50.9', codetype: 'ICD10' },
{
label: 'Heart failure (disorder)',
code: '84114007',
codetype: 'SNOMED US',
},
]);
// label search
const labels = searchShards([shard], 'heart failure', 20, false, {
codetypes: ['ICD10'],
});
expect(labels).toHaveLength(1);
expect(labels[0].codetype).toBe('ICD10');
// code search
expect(
searchShards([shard], '8411', 20, false, { codetypes: ['ICD10'] })
).toHaveLength(0);
// a shard with none of the requested systems yields nothing
expect(
searchShards([shard], 'heart failure', 20, false, {
codetypes: ['ICD11'],
})
).toHaveLength(0);
});

it('still finds the exact code among many prefix matches (scan cap)', () => {
// 1500 codes share the prefix — beyond MAX_CODE_SCAN — but the exact
// match sits at the start of the sorted range and is always scanned
Expand Down
28 changes: 27 additions & 1 deletion src/components/CodeLookup/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,10 @@ export interface SearchOptions {
/** Multiply matching docs' scores by CODETYPE_BOOST (e.g. ['ICD10'] so
* billing codes outrank SNOMED synonyms in an assessment context) */
boostCodetypes?: string[];
/** Restrict results to these coding systems (e.g. ['ICD10'] for an
* ICD-10-only condition picker). Docs of other codetypes are dropped;
* shards containing none of them are skipped entirely. */
codetypes?: string[];
/** Conditions: only billable (leaf) ICD-10 codes — category roots and
* SNOMED entries are dropped. Other domains are unaffected. */
billableOnly?: boolean;
Expand All @@ -447,6 +451,18 @@ export interface SearchOptions {
* ICD-10 code outranks a shorter, leading-matched SNOMED synonym. */
const CODETYPE_BOOST = 3;

/** Shard-local codetype indices allowed by opts.codetypes (null = no filter;
* an empty set means the shard has none of the requested systems). */
function allowedCodetypes(
s: CodifyShard,
opts?: SearchOptions
): Set<number> | null {
if (!opts?.codetypes) return null;
return new Set(
opts.codetypes.map((ct) => s.codetypes.indexOf(ct)).filter((i) => i >= 0)
);
}

// =============================================================================
// Code search — only label/alias words are tokenized in the shards, so a
// typed code ("I50.9", "2345-7", "73211009") is matched by prefix against a
Expand Down Expand Up @@ -515,6 +531,8 @@ function searchShardCodes(
}
const billable =
opts?.billableOnly && s.domain === 'condition' ? billableMask(s) : null;
const allowed = allowedCodetypes(s, opts);
if (allowed && allowed.size === 0) return;
// codetype boost — same contract as searchShard (see SearchOptions)
let boostIdx: Set<number> | null = null;
if (opts?.boostCodetypes) {
Expand All @@ -527,6 +545,7 @@ function searchShardCodes(
for (let i = lo; i < end && keys[i].startsWith(q); i++) {
const d = docs[i];
if (billable && billable[d] !== 1) continue;
if (allowed && !allowed.has(s.docCodetype[d])) continue;
const score =
CODE_MATCH_BASE *
(q.length / keys[i].length) *
Expand Down Expand Up @@ -625,6 +644,9 @@ function searchShard(
opts?: SearchOptions
) {
const { scoreBuf, maskBuf, aliasBuf, fuzzyBuf } = s;
// codetype filter: skip the whole shard when none of its systems qualify
const allowed = allowedCodetypes(s, opts);
if (allowed && allowed.size === 0) return;
const N = s.docCount;
const fullMask = (1 << qTokens.length) - 1;
let touchedCount = 0;
Expand Down Expand Up @@ -732,7 +754,11 @@ function searchShard(
}
for (let i = 0; i < touchedCount; i++) {
const d = s.touched[i];
if (maskBuf[d] === fullMask && (!billable || billable[d] === 1)) {
if (
maskBuf[d] === fullMask &&
(!billable || billable[d] === 1) &&
(!allowed || allowed.has(s.docCodetype[d]))
) {
const lenNorm = 1 / (1 + 0.25 * Math.max(0, s.docLen[d] - 1));
// usage prior: frequently-used codes surface above obscure ones with
// equal text relevance (docPrior is log-quantized usage, 0-255)
Expand Down
1 change: 1 addition & 0 deletions src/components/CodeLookup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
CodeLookup,
type CodeLookupProps,
type CodifyDomain,
type CodetypeOption,
} from './CodeLookup';
export {
searchShards,
Expand Down
Loading