feat(frontend): global Cmd-K search across claims/remits/activity
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Set the act-aware env flag so React Query / state updates flush
|
||||
// without noisy console warnings. Mirrors the convention in the
|
||||
// sibling hook tests (useDrawerKeyboard, useReconciliation).
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { useSearch, destinationFor, flattenResults } from "./useSearch";
|
||||
import { useAppStore } from "@/store";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Activity, Claim, Remittance } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The hook reads from the in-memory zustand store via
|
||||
// `useSyncExternalStore` when `api.isConfigured` is false. Default
|
||||
// vitest env (`VITE_API_BASE_URL: "http://test.local"`) flips
|
||||
// `api.isConfigured` to TRUE — we want to test the fallback code
|
||||
// path here (it's the one the demo runs in), so mock `api` to report
|
||||
// itself unconfigured.
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
isConfigured: false,
|
||||
listClaims: vi.fn(),
|
||||
listRemittances: vi.fn(),
|
||||
listActivity: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CLAIMS: Claim[] = [
|
||||
{
|
||||
id: "CLM-10428",
|
||||
patientName: "Avery Nguyen",
|
||||
providerNpi: "1730187395",
|
||||
payerName: "Blue Cross Blue Shield",
|
||||
cptCode: "99213",
|
||||
billedAmount: 240,
|
||||
receivedAmount: 240,
|
||||
status: "paid",
|
||||
submissionDate: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "CLM-10429",
|
||||
patientName: "Jordan Patel",
|
||||
providerNpi: "1528471902",
|
||||
payerName: "United Healthcare",
|
||||
cptCode: "99214",
|
||||
billedAmount: 410,
|
||||
receivedAmount: 0,
|
||||
status: "denied",
|
||||
submissionDate: "2026-05-02T00:00:00Z",
|
||||
denialReason: "CO-97: Service included in another service",
|
||||
},
|
||||
{
|
||||
id: "CLM-10430",
|
||||
patientName: "Riley Garcia",
|
||||
providerNpi: "1982036471",
|
||||
payerName: "Aetna",
|
||||
cptCode: "85025",
|
||||
billedAmount: 95,
|
||||
receivedAmount: 0,
|
||||
status: "submitted",
|
||||
submissionDate: "2026-05-03T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const REMITS: Remittance[] = [
|
||||
{
|
||||
id: "REM-7782",
|
||||
claimId: "CLM-10428",
|
||||
payerName: "Blue Cross Blue Shield",
|
||||
paidAmount: 240,
|
||||
adjustmentAmount: 0,
|
||||
receivedDate: "2026-05-10T00:00:00Z",
|
||||
checkNumber: "EFT-100123",
|
||||
status: "reconciled",
|
||||
},
|
||||
{
|
||||
id: "REM-7783",
|
||||
claimId: "CLM-10429",
|
||||
payerName: "United Healthcare",
|
||||
paidAmount: 0,
|
||||
adjustmentAmount: 410,
|
||||
receivedDate: "2026-05-11T00:00:00Z",
|
||||
checkNumber: "EFT-100124",
|
||||
status: "posted",
|
||||
denialReason: "CO-97: Service included in another service",
|
||||
},
|
||||
];
|
||||
|
||||
const ACTIVITY: Activity[] = [
|
||||
{
|
||||
id: "ACT-CLM-10428",
|
||||
kind: "claim_paid",
|
||||
message: "Paid CLM-10428 · Avery Nguyen",
|
||||
timestamp: "2026-05-10T00:00:00Z",
|
||||
npi: "1730187395",
|
||||
amount: 240,
|
||||
},
|
||||
{
|
||||
id: "ACT-CLM-10429",
|
||||
kind: "claim_denied",
|
||||
message: "Denied CLM-10429 · Jordan Patel",
|
||||
timestamp: "2026-05-11T00:00:00Z",
|
||||
npi: "1528471902",
|
||||
amount: 410,
|
||||
},
|
||||
{
|
||||
id: "ACT-CLM-10430",
|
||||
kind: "claim_submitted",
|
||||
message: "Submitted CLM-10430 · Riley Garcia",
|
||||
timestamp: "2026-05-12T00:00:00Z",
|
||||
npi: "1982036471",
|
||||
amount: 95,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimal renderHook shim using createRoot — matches the pattern
|
||||
* in the sibling hook tests. */
|
||||
function renderHook<TResult>(
|
||||
useHook: () => TResult
|
||||
): { result: { current: TResult | undefined }; unmount: () => void } {
|
||||
const result: { current: TResult | undefined } = { current: undefined };
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
// A QueryClient is only strictly needed when `api.isConfigured` is
|
||||
// true, but supplying one is harmless either way and future-proofs
|
||||
// the test if the mock flips.
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
function Probe() {
|
||||
result.current = useHook();
|
||||
return null;
|
||||
}
|
||||
|
||||
const root: Root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: qc },
|
||||
React.createElement(Probe)
|
||||
)
|
||||
);
|
||||
});
|
||||
return {
|
||||
result,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Yield to the timer queue so the 150ms debounce in `useSearch`
|
||||
* can flush. `act` wraps the wait so React state updates triggered
|
||||
* by the debounced setter are visible to subsequent assertions. */
|
||||
async function flushDebounce(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useSearch", () => {
|
||||
beforeEach(() => {
|
||||
// Seed the in-memory zustand store with our fixtures so the
|
||||
// hook's `useSyncExternalStore` snapshot returns them.
|
||||
useAppStore.setState({
|
||||
claims: CLAIMS,
|
||||
remittances: REMITS,
|
||||
activity: ACTIVITY,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns empty results for an empty query", () => {
|
||||
const { result, unmount } = renderHook(() => useSearch());
|
||||
expect(result.current?.query).toBe("");
|
||||
expect(result.current?.results.total).toBe(0);
|
||||
expect(result.current?.results.claims).toEqual([]);
|
||||
expect(result.current?.results.remittances).toEqual([]);
|
||||
expect(result.current?.results.activity).toEqual([]);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("debounces the query — results stay empty within the debounce window", async () => {
|
||||
const { result, unmount } = renderHook(() => useSearch());
|
||||
act(() => {
|
||||
result.current?.setQuery("Nguyen");
|
||||
});
|
||||
// Query is updated synchronously (so the input can echo it),
|
||||
// but results should not have run yet — debounce is 150ms.
|
||||
expect(result.current?.query).toBe("Nguyen");
|
||||
expect(result.current?.results.total).toBe(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("matches claims by patient name", async () => {
|
||||
const { result, unmount } = renderHook(() => useSearch());
|
||||
act(() => {
|
||||
result.current?.setQuery("Nguyen");
|
||||
});
|
||||
await flushDebounce();
|
||||
expect(result.current?.results.claims.length).toBeGreaterThan(0);
|
||||
expect(result.current?.results.claims[0]?.id).toBe("CLM-10428");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("matches claims by id fragment", async () => {
|
||||
const { result, unmount } = renderHook(() => useSearch());
|
||||
act(() => {
|
||||
result.current?.setQuery("CLM-10430");
|
||||
});
|
||||
await flushDebounce();
|
||||
expect(result.current?.results.claims[0]?.id).toBe("CLM-10430");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("matches claims by payer name", async () => {
|
||||
const { result, unmount } = renderHook(() => useSearch());
|
||||
act(() => {
|
||||
result.current?.setQuery("Aetna");
|
||||
});
|
||||
await flushDebounce();
|
||||
expect(result.current?.results.claims.length).toBeGreaterThan(0);
|
||||
expect(result.current?.results.claims[0]?.id).toBe("CLM-10430");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("matches remittances by check number", async () => {
|
||||
const { result, unmount } = renderHook(() => useSearch());
|
||||
act(() => {
|
||||
result.current?.setQuery("EFT-100124");
|
||||
});
|
||||
await flushDebounce();
|
||||
expect(result.current?.results.remittances.length).toBeGreaterThan(0);
|
||||
expect(result.current?.results.remittances[0]?.id).toBe("REM-7783");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("matches activity by message text", async () => {
|
||||
const { result, unmount } = renderHook(() => useSearch());
|
||||
act(() => {
|
||||
result.current?.setQuery("Garcia");
|
||||
});
|
||||
await flushDebounce();
|
||||
expect(result.current?.results.activity.length).toBeGreaterThan(0);
|
||||
expect(result.current?.results.activity[0]?.id).toBe("ACT-CLM-10430");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("ranks exact-id matches above substring matches", async () => {
|
||||
const { result, unmount } = renderHook(() => useSearch());
|
||||
act(() => {
|
||||
result.current?.setQuery("CLM-10428");
|
||||
});
|
||||
await flushDebounce();
|
||||
const flat = flattenResults(result.current!.results);
|
||||
// The exact-id claim should be the first result.
|
||||
expect(flat[0]?.id).toBe("CLM-10428");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("caps each group at MAX_PER_GROUP (10)", async () => {
|
||||
// Build a store with many claims all matching the same query.
|
||||
const many: Claim[] = Array.from({ length: 25 }, (_, i) => ({
|
||||
...CLAIMS[0]!,
|
||||
id: `CLM-${20000 + i}`,
|
||||
patientName: `Patient ${i}`,
|
||||
}));
|
||||
useAppStore.setState({ claims: many, remittances: [], activity: [] });
|
||||
|
||||
const { result, unmount } = renderHook(() => useSearch());
|
||||
act(() => {
|
||||
result.current?.setQuery("Patient");
|
||||
});
|
||||
await flushDebounce();
|
||||
// 25 matching claims, but the cap should hold at 10.
|
||||
expect(result.current?.results.claims.length).toBe(10);
|
||||
expect(result.current?.results.total).toBe(10);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("exposes the loading flag (false in fallback mode)", () => {
|
||||
const { result, unmount } = renderHook(() => useSearch());
|
||||
expect(result.current?.isLoading).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("returns total === 0 when nothing matches", async () => {
|
||||
const { result, unmount } = renderHook(() => useSearch());
|
||||
act(() => {
|
||||
result.current?.setQuery("zzzzzz-no-such-string");
|
||||
});
|
||||
await flushDebounce();
|
||||
expect(result.current?.results.total).toBe(0);
|
||||
expect(result.current?.results.claims).toEqual([]);
|
||||
expect(result.current?.results.remittances).toEqual([]);
|
||||
expect(result.current?.results.activity).toEqual([]);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("flattens results in display order: claims → remittances → activity", async () => {
|
||||
const { result, unmount } = renderHook(() => useSearch());
|
||||
act(() => {
|
||||
// A query that hits all three entity types.
|
||||
result.current?.setQuery("10428");
|
||||
});
|
||||
await flushDebounce();
|
||||
const flat = flattenResults(result.current!.results);
|
||||
expect(flat.length).toBeGreaterThan(0);
|
||||
// The first three should be claims (we have one CLM-10428 match);
|
||||
// remits with claimId=CLM-10428 come next; activity with that
|
||||
// claim id comes last.
|
||||
const kinds = flat.map((r) => r.kind);
|
||||
// Find the first index of each kind, in order:
|
||||
const firstClaim = kinds.indexOf("claim");
|
||||
const firstRemit = kinds.indexOf("remittance");
|
||||
const firstActivity = kinds.indexOf("activity");
|
||||
if (firstRemit !== -1) expect(firstRemit).toBeGreaterThan(firstClaim);
|
||||
if (firstActivity !== -1) expect(firstActivity).toBeGreaterThan(firstClaim);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("destinationFor", () => {
|
||||
it("routes claim results to /claims?claim=<id>", () => {
|
||||
const url = destinationFor({
|
||||
kind: "claim",
|
||||
id: "CLM-10428",
|
||||
title: "",
|
||||
subtitle: "",
|
||||
badge: "paid",
|
||||
score: 1,
|
||||
});
|
||||
expect(url).toBe("/claims?claim=CLM-10428");
|
||||
});
|
||||
|
||||
it("URL-encodes claim ids with special characters", () => {
|
||||
const url = destinationFor({
|
||||
kind: "claim",
|
||||
id: "CLM/A B",
|
||||
title: "",
|
||||
subtitle: "",
|
||||
badge: "paid",
|
||||
score: 1,
|
||||
});
|
||||
expect(url).toBe("/claims?claim=CLM%2FA%20B");
|
||||
});
|
||||
|
||||
it("routes remittance results to /remittances", () => {
|
||||
const url = destinationFor({
|
||||
kind: "remittance",
|
||||
id: "REM-7782",
|
||||
title: "",
|
||||
subtitle: "",
|
||||
badge: "reconciled",
|
||||
score: 1,
|
||||
});
|
||||
expect(url).toBe("/remittances");
|
||||
});
|
||||
|
||||
it("routes activity results to /activity?kind=<kind>", () => {
|
||||
const url = destinationFor({
|
||||
kind: "activity",
|
||||
id: "ACT-CLM-10428",
|
||||
title: "",
|
||||
subtitle: "",
|
||||
badge: "claim_paid",
|
||||
score: 1,
|
||||
});
|
||||
expect(url).toBe("/activity?kind=claim_paid");
|
||||
});
|
||||
});
|
||||
|
||||
// `api` is mocked at the top of the file — silence the "imported but
|
||||
// unused" lint with this re-export, which also documents that the
|
||||
// mock is the load-bearing piece of the test setup.
|
||||
void api;
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Global search hook — Cmd-K palette backing logic.
|
||||
*
|
||||
* Loads a generous initial slice of claims, remittances, and activity
|
||||
* from the existing `api.list*` endpoints (or the in-memory zustand
|
||||
* store in unconfigured mode) once when the hook mounts, then runs
|
||||
* an in-memory filter+rank against whatever the user has typed.
|
||||
*
|
||||
* Design notes:
|
||||
*
|
||||
* - **Why fetch a slice, not everything?** The persistence layer
|
||||
* supports a `limit` param; 200 is small enough to be fast and
|
||||
* large enough to cover the demo dataset. We don't paginate the
|
||||
* search corpus — typing should feel instant.
|
||||
*
|
||||
* - **Why in-memory and not a dedicated `/api/search` endpoint?**
|
||||
* Avoids backend churn for a UI feature. The in-memory filter is
|
||||
* cheap (three arrays of ≤200 entries each) and works against the
|
||||
* sample-data fallback identically.
|
||||
*
|
||||
* - **Why debounce?** Each keystroke would otherwise trigger three
|
||||
* ranking passes plus a re-render. 150ms is the sweet spot where
|
||||
* typing feels uninterrupted but we don't recompute on every
|
||||
* character.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { api, type PaginatedResponse } from "@/lib/api";
|
||||
import { useAppStore } from "@/store";
|
||||
import type { Activity, Claim, Remittance } from "@/types";
|
||||
|
||||
/** Max items we pull into the in-memory search corpus per entity type. */
|
||||
const SEARCH_CORPUS_LIMIT = 200;
|
||||
|
||||
/** Debounce window for the query input. */
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
/** Hard cap on results returned to the UI. Keeps the palette snappy. */
|
||||
const MAX_RESULTS = 50;
|
||||
|
||||
/** Per-group cap so a single entity type can't dominate the palette. */
|
||||
const MAX_PER_GROUP = 10;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SearchResultKind = "claim" | "remittance" | "activity";
|
||||
|
||||
/** Discriminated union — each variant carries the entity id + a display row. */
|
||||
export type SearchResult =
|
||||
| {
|
||||
kind: "claim";
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
score: number;
|
||||
}
|
||||
| {
|
||||
kind: "remittance";
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
score: number;
|
||||
}
|
||||
| {
|
||||
kind: "activity";
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
/** Result list grouped by entity type — what the palette renders. */
|
||||
export interface SearchResultsGrouped {
|
||||
claims: SearchResult[];
|
||||
remittances: SearchResult[];
|
||||
activity: SearchResult[];
|
||||
total: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
/** Return shape of {@link useSearch}. */
|
||||
export interface UseSearchResult {
|
||||
/** Debounced query — what the ranking is actually run against. */
|
||||
query: string;
|
||||
/** Setter for the input field. */
|
||||
setQuery: (q: string) => void;
|
||||
/** Grouped, scored results (already capped). */
|
||||
results: SearchResultsGrouped;
|
||||
/** True while the corpus is being fetched. */
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scoring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Score a single candidate string against the (lower-cased) query.
|
||||
*
|
||||
* Priority (highest → lowest):
|
||||
*
|
||||
* 1. **exact match** — query equals the candidate (`42` === `42`). The
|
||||
* single most useful hit when typing a numeric ID fragment.
|
||||
* 2. **prefix match** — candidate starts with the query (`CLM-10…` for
|
||||
* `CLM-10`). The next best "I'm typing the start of an ID" case.
|
||||
* 3. **substring match** — query appears anywhere (`Garcia` matching
|
||||
* `Avery Garcia`).
|
||||
* 4. **fuzzy subsequence** — every character of the query appears in
|
||||
* the candidate in order. Catches typos like `nguyne` → `Nguyen`.
|
||||
*
|
||||
* Returns 0 when there's no match at all.
|
||||
*/
|
||||
function scoreField(haystack: string | undefined, needle: string): number {
|
||||
if (!haystack) return 0;
|
||||
const hay = haystack.toLowerCase();
|
||||
if (hay === needle) return 100;
|
||||
if (hay.startsWith(needle)) return 60;
|
||||
const idx = hay.indexOf(needle);
|
||||
if (idx >= 0) {
|
||||
// Earlier matches score slightly higher — leading `Bills` matches
|
||||
// `Blue Cross Blue Shield` more meaningfully than trailing.
|
||||
const positionBonus = Math.max(0, 20 - idx);
|
||||
return 30 + positionBonus;
|
||||
}
|
||||
// Subsequence fuzzy match — every char of needle appears in hay in
|
||||
// order, with no minimum-length requirement (1-char queries would
|
||||
// otherwise match everything and starve exact matches).
|
||||
let hi = 0;
|
||||
for (let ni = 0; ni < needle.length; ni++) {
|
||||
const ch = needle[ni];
|
||||
const found = hay.indexOf(ch, hi);
|
||||
if (found === -1) return 0;
|
||||
hi = found + 1;
|
||||
}
|
||||
return 10;
|
||||
}
|
||||
|
||||
/** Sum the best score across a set of candidate fields. */
|
||||
function scoreAgainstFields(
|
||||
query: string,
|
||||
fields: (string | undefined)[]
|
||||
): number {
|
||||
let best = 0;
|
||||
for (const f of fields) {
|
||||
const s = scoreField(f, query);
|
||||
if (s > best) best = s;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-entity rankers — each returns a SearchResult or null when no match
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rankClaim(c: Claim, query: string): SearchResult | null {
|
||||
// Order matters loosely — ID first so typing "CLM-" jumps to claim
|
||||
// rows, then patient, then payer, then code. scoreAgainstFields
|
||||
// picks the best across all of them so the order here is purely
|
||||
// about which fields carry more weight in practice.
|
||||
const score = scoreAgainstFields(query, [
|
||||
c.id,
|
||||
c.patientName,
|
||||
c.payerName,
|
||||
c.cptCode,
|
||||
c.providerNpi,
|
||||
c.denialReason,
|
||||
]);
|
||||
if (score === 0) return null;
|
||||
return {
|
||||
kind: "claim",
|
||||
id: c.id,
|
||||
title: `${c.id} · ${c.patientName}`,
|
||||
subtitle: `${c.payerName} · ${c.cptCode} · $${c.billedAmount.toLocaleString()}`,
|
||||
badge: c.status,
|
||||
score,
|
||||
};
|
||||
}
|
||||
|
||||
function rankRemittance(r: Remittance, query: string): SearchResult | null {
|
||||
const score = scoreAgainstFields(query, [
|
||||
r.id,
|
||||
r.claimId,
|
||||
r.payerName,
|
||||
r.checkNumber,
|
||||
r.denialReason ?? undefined,
|
||||
]);
|
||||
if (score === 0) return null;
|
||||
return {
|
||||
kind: "remittance",
|
||||
id: r.id,
|
||||
title: `${r.id} · ${r.payerName}`,
|
||||
subtitle: `Claim ${r.claimId} · Check ${r.checkNumber} · $${r.paidAmount.toLocaleString()}`,
|
||||
badge: r.status,
|
||||
score,
|
||||
};
|
||||
}
|
||||
|
||||
function rankActivity(a: Activity, query: string): SearchResult | null {
|
||||
const score = scoreAgainstFields(query, [
|
||||
a.id,
|
||||
a.message,
|
||||
a.kind,
|
||||
a.npi,
|
||||
]);
|
||||
if (score === 0) return null;
|
||||
return {
|
||||
kind: "activity",
|
||||
id: a.id,
|
||||
title: a.message,
|
||||
subtitle: a.timestamp,
|
||||
badge: a.kind,
|
||||
score,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Corpus fetch (uses existing hooks so fallback mode "just works")
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SearchCorpus {
|
||||
claims: Claim[];
|
||||
remittances: Remittance[];
|
||||
activity: Activity[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the search corpus. Mirrors the fallback-mode strategy used by
|
||||
* `useClaims` / `useRemittances` / `useActivity` so the palette works
|
||||
* identically with the in-memory zustand store and with a live backend.
|
||||
*
|
||||
* We use `useQuery` for the configured path so react-query handles
|
||||
* caching/refetching, and `useSyncExternalStore` for the fallback path
|
||||
* so re-renders fire when the store changes (e.g. after a new claim
|
||||
* submission).
|
||||
*/
|
||||
function useSearchCorpus(): { data: SearchCorpus; isLoading: boolean } {
|
||||
// Fallback snapshot from the zustand store. We pull the three arrays
|
||||
// we care about — `useSyncExternalStore` returns the same snapshot
|
||||
// reference until the store mutates, so this is cheap.
|
||||
const fallbackClaims = useSyncExternalStore(
|
||||
(cb) => useAppStore.subscribe(cb),
|
||||
() => useAppStore.getState().claims,
|
||||
() => useAppStore.getState().claims
|
||||
);
|
||||
const fallbackRemits = useSyncExternalStore(
|
||||
(cb) => useAppStore.subscribe(cb),
|
||||
() => useAppStore.getState().remittances,
|
||||
() => useAppStore.getState().remittances
|
||||
);
|
||||
const fallbackActivity = useSyncExternalStore(
|
||||
(cb) => useAppStore.subscribe(cb),
|
||||
() => useAppStore.getState().activity,
|
||||
() => useAppStore.getState().activity
|
||||
);
|
||||
|
||||
const claimsQ = useQuery<PaginatedResponse<Claim>>({
|
||||
queryKey: ["search-corpus", "claims"],
|
||||
queryFn: () => api.listClaims<Claim>({ limit: SEARCH_CORPUS_LIMIT }),
|
||||
enabled: api.isConfigured,
|
||||
// Keep the corpus cached for a minute — re-fetching on every
|
||||
// palette open would defeat the "instant" goal of in-memory search.
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const remitsQ = useQuery<PaginatedResponse<Remittance>>({
|
||||
queryKey: ["search-corpus", "remittances"],
|
||||
queryFn: () => api.listRemittances<Remittance>({ limit: SEARCH_CORPUS_LIMIT }),
|
||||
enabled: api.isConfigured,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const activityQ = useQuery<PaginatedResponse<Activity>>({
|
||||
queryKey: ["search-corpus", "activity"],
|
||||
queryFn: () => api.listActivity<Activity>({ limit: SEARCH_CORPUS_LIMIT }),
|
||||
enabled: api.isConfigured,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (!api.isConfigured) {
|
||||
return {
|
||||
data: {
|
||||
claims: fallbackClaims,
|
||||
remittances: fallbackRemits,
|
||||
activity: fallbackActivity,
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
claims: claimsQ.data?.items ?? [],
|
||||
remittances: remitsQ.data?.items ?? [],
|
||||
activity: activityQ.data?.items ?? [],
|
||||
},
|
||||
isLoading:
|
||||
claimsQ.isLoading || remitsQ.isLoading || activityQ.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The Cmd-K search hook.
|
||||
*
|
||||
* Returns the debounced query (so consumers can render the input value
|
||||
* without lag), the setter to wire to the input, the grouped/capped
|
||||
* results, and a loading flag for the corpus fetch.
|
||||
*
|
||||
* The hook is intentionally pure-ish: it does NOT own the dialog
|
||||
* open/close state, navigation, or keyboard wiring. Those belong in
|
||||
* `<SearchBar>` so the hook stays easy to unit test (no Radix
|
||||
* dependencies, no router, no DOM).
|
||||
*/
|
||||
export function useSearch(): UseSearchResult {
|
||||
const [rawQuery, setRawQuery] = useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Debounce raw → debounced. We use a ref-tracked timer rather than
|
||||
// a `useDebouncedCallback` library so this file has zero deps
|
||||
// beyond what `useClaims` already pulls in.
|
||||
useEffect(() => {
|
||||
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
setDebouncedQuery(rawQuery);
|
||||
timerRef.current = null;
|
||||
}, DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [rawQuery]);
|
||||
|
||||
const corpus = useSearchCorpus();
|
||||
|
||||
// The ranking pass. Memoized on the corpus + the *debounced* query
|
||||
// so we don't re-rank on every keystroke — only after the 150ms
|
||||
// debounce window settles.
|
||||
const results = useMemo<SearchResultsGrouped>(() => {
|
||||
const q = debouncedQuery.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return {
|
||||
claims: [],
|
||||
remittances: [],
|
||||
activity: [],
|
||||
total: 0,
|
||||
query: debouncedQuery,
|
||||
};
|
||||
}
|
||||
|
||||
const claims = corpus.data.claims
|
||||
.map((c) => rankClaim(c, q))
|
||||
.filter((r): r is SearchResult => r !== null)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, MAX_PER_GROUP);
|
||||
|
||||
const remittances = corpus.data.remittances
|
||||
.map((r) => rankRemittance(r, q))
|
||||
.filter((r): r is SearchResult => r !== null)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, MAX_PER_GROUP);
|
||||
|
||||
const activity = corpus.data.activity
|
||||
.map((a) => rankActivity(a, q))
|
||||
.filter((r): r is SearchResult => r !== null)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, MAX_PER_GROUP);
|
||||
|
||||
return {
|
||||
claims,
|
||||
remittances,
|
||||
activity,
|
||||
total: claims.length + remittances.length + activity.length,
|
||||
query: debouncedQuery,
|
||||
};
|
||||
}, [corpus.data.claims, corpus.data.remittances, corpus.data.activity, debouncedQuery]);
|
||||
|
||||
// Stable setter reference — the consumer (an `<input>`) doesn't
|
||||
// need to re-render when our hook internals change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const setQuery = useCallback((q: string) => setRawQuery(q), []);
|
||||
|
||||
return {
|
||||
query: rawQuery,
|
||||
setQuery,
|
||||
results,
|
||||
isLoading: corpus.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Navigation destinations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map a search hit to a URL the router can push. Keeps all
|
||||
* "where does an entity go?" knowledge in one place so the SearchBar
|
||||
* component doesn't have to know per-entity routing rules.
|
||||
*
|
||||
* - claim → /claims?claim={id} (opens the drawer deep-link)
|
||||
* - remittance → /remittances (Remittances page; the page
|
||||
* already lets you expand a row
|
||||
* for the adjustment detail)
|
||||
* - activity → /activity?kind={kind} (Activity Log, pre-filtered
|
||||
* to the matching event kind)
|
||||
*/
|
||||
export function destinationFor(result: SearchResult): string {
|
||||
switch (result.kind) {
|
||||
case "claim":
|
||||
return `/claims?claim=${encodeURIComponent(result.id)}`;
|
||||
case "remittance":
|
||||
return "/remittances";
|
||||
case "activity": {
|
||||
// The activity id is "ACT-<claim-id>" by convention; the
|
||||
// ActivityLog page filters by `kind`, so we surface that.
|
||||
const u = new URLSearchParams();
|
||||
u.set("kind", result.badge);
|
||||
return `/activity?${u.toString()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Flat list of results in display order — what the keyboard nav steps through. */
|
||||
export function flattenResults(g: SearchResultsGrouped): SearchResult[] {
|
||||
return [...g.claims, ...g.remittances, ...g.activity];
|
||||
}
|
||||
|
||||
/** Total cap sentinel — exported for tests so they don't have to hard-code 50. */
|
||||
export const SEARCH_MAX_RESULTS = MAX_RESULTS;
|
||||
Reference in New Issue
Block a user