All files / src/events searchEvents.ts

100% Statements 31/31
100% Branches 4/4
100% Functions 3/3
100% Lines 31/31

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 1241x                               1x   1x         1x 1x 1x   1x   1x   1x   1x                                                                           1x                                 1x 1x 95x 95x   95x 95x   1x                         1x 1x 80x 80x   80x 32x 32x 32x 32x 32x 80x 80x 80x  
/**
 * Typed search-lifecycle events, dispatched on `window` as `CustomEvent`s.
 *
 * Mirrors the pattern in {@link "../hotkeys/events"} — we use `window`-dispatched
 * events rather than lifting state into context so producers (the search hook,
 * the results table, the popup load path) stay decoupled from consumers. The
 * sole consumer today is the badge controller ({@link "../utils/badgeController"}),
 * but these are general-purpose: history, analytics, or telemetry could subscribe
 * later without touching the producers.
 *
 * {@link emitSearchEvent} / {@link onSearchEvent} are thin typed wrappers over
 * `dispatchEvent`/`addEventListener` so payloads are checked at the call site
 * (no new dependency, unlike a dedicated emitter library).
 *
 * @category Events
 * @source
 */
 
/**
 * Search-lifecycle event names. The string values are the actual `CustomEvent`
 * types dispatched on `window` (kept in the `chempal:` namespace, matching the
 * hotkey events). Use the enum member everywhere — never the raw string.
 * @source
 */
export enum SearchEvent {
  /** A search has started; the loading animation should begin. */
  STARTED = 'chempal:search-started',
  /** The current visible/filtered result count changed (streaming, filtering, exclusion). */
  RESULTS_COUNT = 'chempal:search-results-count',
  /** A search finished (successfully or with zero results). */
  COMPLETED = 'chempal:search-completed',
  /** A search was aborted by the user. */
  ABORTED = 'chempal:search-aborted',
  /** A search failed with an error. */
  FAILED = 'chempal:search-failed',
}
 
/**
 * What every terminal search event reports about the run that just ended, so a
 * consumer can tell a full search from one that stopped early without knowing
 * how the search itself is orchestrated.
 * @source
 */
export interface SearchOutcomeDetail {
  /** Products the search produced before it ended. */
  count: number;
  /** Wall-clock milliseconds from the search starting to it ending. */
  durationMs: number;
  /** Suppliers the search actually queried (post shipping/permission filtering). */
  suppliersQueried: number;
  /** How many of those had finished when the search ended. */
  suppliersCompleted: number;
}
 
/**
 * Maps each {@link SearchEvent} to the shape of its `CustomEvent.detail`.
 * `undefined` means the event carries no payload.
 */
export interface SearchEventDetailMap {
  [SearchEvent.STARTED]: { query: string };
  [SearchEvent.RESULTS_COUNT]: { count: number };
  // `abortReason` is set when the stream drained only because the search was
  // aborted mid-flight — the run completed, but not all of the work did.
  [SearchEvent.COMPLETED]: SearchOutcomeDetail & { abortReason?: string };
  [SearchEvent.ABORTED]: SearchOutcomeDetail & { reason?: string };
  [SearchEvent.FAILED]: SearchOutcomeDetail & { error?: string };
}
 
/** Union of all search-event names (equivalent to {@link SearchEvent}). */
export type SearchEventType = keyof SearchEventDetailMap;
 
/**
 * Dispatch a search-lifecycle event on `window` with a typed payload.
 * @param type - The event ({@link SearchEvent} member).
 * @param detail - The payload matching {@link SearchEventDetailMap} for `type`.
 * @example
 * ```ts
 * emitSearchEvent(SearchEvent.STARTED, { query: "acetone" });
 * emitSearchEvent(SearchEvent.RESULTS_COUNT, { count: 12 });
 * emitSearchEvent(SearchEvent.ABORTED, {
 *   count: 3,
 *   durationMs: 1240,
 *   suppliersQueried: 8,
 *   suppliersCompleted: 2,
 *   reason: "user_aborted",
 * });
 * ```
 * @source
 */
export function emitSearchEvent<K extends SearchEventType>(
  type: K,
  ...[detail]: SearchEventDetailMap[K] extends undefined ? [] : [SearchEventDetailMap[K]]
): void {
  window.dispatchEvent(new CustomEvent(type, { detail }));
}
 
/**
 * Subscribe to a search-lifecycle event. Returns an unsubscribe function, so it
 * drops straight into a `useEffect` cleanup.
 * @param type - The event ({@link SearchEvent} member).
 * @param handler - Receives the typed `detail` for `type`.
 * @returns A function that removes the listener.
 * @example
 * ```ts
 * useEffect(() => onSearchEvent(SearchEvent.COMPLETED, ({ count, durationMs }) => {
 *   console.log("done", count, durationMs);
 * }), []);
 * ```
 * @source
 */
export function onSearchEvent<K extends SearchEventType>(
  type: K,
  handler: (detail: SearchEventDetailMap[K]) => void,
): () => void {
  const listener = (event: Event) => {
    // DOM `addEventListener` types the callback arg as `Event`; this event is
    // only ever dispatched via `emitSearchEvent` as a `CustomEvent` with the
    // matching `detail`, so narrowing to `CustomEvent` is safe.
    handler((event as CustomEvent<SearchEventDetailMap[K]>).detail);
  };
  window.addEventListener(type, listener);
  return () => window.removeEventListener(type, listener);
}