/** * Lightweight ticket search view-model for the {@link TicketPicker} popup (#18). * * A deliberately slim alternative to {@link useTickets}: it wraps only * `TicketGateway.list` with the status/priority facets and a debounced free-text * search, plus client-side `excludeRefs` filtering and opaque-cursor pagination. * It does NOT group by sprint, subscribe to domain events, or expose * create/assign — the picker only ever reads and selects. * * ── Contract guards ── * - G3-bis: `cursor` is an **opaque token**. We never build or increment it; we * only relay the `nextCursor` the previous page returned. A backend migration * to opaque cursors is therefore zero-change here. * - `excludeRefs` is filtered **client-side** after each page arrives (the * backend has no "exclude" criterion). * - `TicketSummary` from `ticket_list` carries no `id`/`number`, so {@link * TicketSearchViewModel.resolve} reads the ticket on selection to build the * full {@link TicketPickerResult}. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { GatewayError, TicketPriority, TicketRef, TicketStatus, TicketSummary, } from "@/domain"; import { isSprintEvent, isTicketEvent } from "@/domain"; import type { TicketListQuery, TicketListSort } from "@/ports"; import { useGateways } from "@/app/di"; /** * The selection payload the picker hands back (frozen contract, #18). `number` * is the numeric form of `ref` and `id` is the stable ticket id — both resolved * via a single `ticket_read` on selection since `ticket_list` omits them. */ export interface TicketPickerResult { ref: TicketRef; id: string; number: number; title: string; status: TicketStatus; priority: TicketPriority; } export interface UseTicketSearchOptions { /** * Seed query. `statuses`/`priorities`/`assignedAgentId`/`text`/`limit` are * honored; `cursor` is ignored (pagination always starts at the first page). */ initialQuery?: TicketListQuery; /** Refs to hide from the results, filtered client-side after each page. */ excludeRefs?: TicketRef[]; /** Debounce applied to the free-text search, in ms (default 200). */ debounceMs?: number; /** * When true, subscribe to `Issue*`/`sprint*` domain events and re-fetch the * first page on any ticket/sprint mutation. Off by default because the * {@link TicketPicker} popup is short-lived and only reads; the sprint view * (#37) enables it so its independent query stays in sync with add/remove. */ refreshOnEvents?: boolean; } export interface TicketSearchViewModel { /** Result rows for the current query, accumulated across loaded pages, with * `excludeRefs` removed. */ rows: TicketSummary[]; busy: boolean; error: string | null; /** Raw (un-debounced) search text — bind straight to the input. */ text: string; setText: (text: string) => void; statuses: TicketStatus[]; priorities: TicketPriority[]; toggleStatus: (status: TicketStatus) => void; togglePriority: (priority: TicketPriority) => void; clearFacets: () => void; /** Current sort (#21); `undefined` ⇒ the backend's historical order. */ sort: TicketListSort | undefined; /** Sets the sort (or clears it); resets pagination to the first page. */ setSort: (sort: TicketListSort | undefined) => void; /** True when the backend reported a further page (opaque cursor present). */ hasMore: boolean; /** Loads and appends the next page (no-op when exhausted or busy). */ loadMore: () => void; /** Forces a first-page re-fetch (e.g. after an external mutation). */ refresh: () => void; /** Resolves a row's `#ref` to the full picker result (reads id + number). */ resolve: (ref: TicketRef) => Promise; } function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); } return String(e); } /** Facet subset of the query we own locally (text is handled via debounce). */ interface Facets { statuses?: TicketStatus[]; priorities?: TicketPriority[]; assignedAgentId?: string; } export function useTicketSearch( projectId: string, options: UseTicketSearchOptions = {}, ): TicketSearchViewModel { const { initialQuery, excludeRefs, debounceMs = 200, refreshOnEvents } = options; const { ticket, system } = useGateways(); const [facets, setFacets] = useState({ statuses: initialQuery?.statuses, priorities: initialQuery?.priorities, assignedAgentId: initialQuery?.assignedAgentId, }); const [text, setText] = useState(initialQuery?.text ?? ""); const [debouncedText, setDebouncedText] = useState(text); const [sort, setSort] = useState( initialQuery?.sort, ); const [items, setItems] = useState([]); const [cursor, setCursor] = useState(undefined); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // Bumped to force a first-page re-fetch without changing the query (used by // `refresh` and the domain-event subscription). const [reloadNonce, setReloadNonce] = useState(0); const refresh = useCallback(() => setReloadNonce((n) => n + 1), []); const limit = initialQuery?.limit; // Stable set so identity only changes when the caller passes new refs. const excludeKey = (excludeRefs ?? []).join(","); const excluded = useMemo( () => new Set(excludeRefs ?? []), // eslint-disable-next-line react-hooks/exhaustive-deps [excludeKey], ); // Debounce the free-text search so typing doesn't refetch on every keystroke. useEffect(() => { const id = setTimeout(() => setDebouncedText(text), debounceMs); return () => clearTimeout(id); }, [text, debounceMs]); // A key that changes whenever the effective query (minus cursor) changes, so // the first-page effect refetches exactly when a criterion moves. const queryKey = useMemo( () => JSON.stringify({ facets, text: debouncedText.trim(), sort, limit }), [facets, debouncedText, sort, limit], ); // Guards against races: only the latest first-page/loadMore fetch may commit. const fetchToken = useRef(0); const buildQuery = useCallback( (nextCursor?: string): TicketListQuery => { const trimmed = debouncedText.trim(); return { ...(facets.statuses && facets.statuses.length > 0 ? { statuses: facets.statuses } : {}), ...(facets.priorities && facets.priorities.length > 0 ? { priorities: facets.priorities } : {}), ...(facets.assignedAgentId ? { assignedAgentId: facets.assignedAgentId } : {}), ...(trimmed ? { text: trimmed } : {}), ...(sort ? { sort } : {}), ...(limit ? { limit } : {}), // G3-bis: opaque token, relayed verbatim from the previous page only. ...(nextCursor ? { cursor: nextCursor } : {}), }; }, [facets, debouncedText, sort, limit], ); // First page: (re)fetch whenever the query key changes; replaces the list. useEffect(() => { const token = ++fetchToken.current; setBusy(true); setError(null); void ticket .list(projectId, buildQuery()) .then((page) => { if (token !== fetchToken.current) return; setItems(page.items); setCursor(page.nextCursor); }) .catch((e) => { if (token !== fetchToken.current) return; setError(describe(e)); setItems([]); setCursor(undefined); }) .finally(() => { if (token === fetchToken.current) setBusy(false); }); // `buildQuery`/`projectId`/`ticket` are captured via `queryKey`; listing // them would refetch on every render (fresh object identities). // `reloadNonce` is an explicit refetch trigger (refresh / domain events). // eslint-disable-next-line react-hooks/exhaustive-deps }, [queryKey, projectId, ticket, reloadNonce]); // Optional live refresh: on any ticket/sprint mutation, re-fetch the first // page so an externally-driven change (e.g. sprint add/remove) reflects here. useEffect(() => { if (!refreshOnEvents || !system) return; let unsubscribe: (() => void) | undefined; let cancelled = false; void system .onDomainEvent((event) => { if (isTicketEvent(event) || isSprintEvent(event)) { setReloadNonce((n) => n + 1); } }) .then((u) => { if (cancelled) u(); else unsubscribe = u; }); return () => { cancelled = true; unsubscribe?.(); }; }, [refreshOnEvents, system]); const loadMore = useCallback(() => { if (busy || !cursor) return; const token = ++fetchToken.current; setBusy(true); setError(null); void ticket .list(projectId, buildQuery(cursor)) .then((page) => { if (token !== fetchToken.current) return; setItems((prev) => [...prev, ...page.items]); setCursor(page.nextCursor); }) .catch((e) => { if (token !== fetchToken.current) return; setError(describe(e)); }) .finally(() => { if (token === fetchToken.current) setBusy(false); }); }, [busy, cursor, ticket, projectId, buildQuery]); const toggleStatus = useCallback((status: TicketStatus) => { setFacets((prev) => { const current = prev.statuses ?? []; const next = current.includes(status) ? current.filter((s) => s !== status) : [...current, status]; return { ...prev, statuses: next.length > 0 ? next : undefined }; }); }, []); const togglePriority = useCallback((priority: TicketPriority) => { setFacets((prev) => { const current = prev.priorities ?? []; const next = current.includes(priority) ? current.filter((p) => p !== priority) : [...current, priority]; return { ...prev, priorities: next.length > 0 ? next : undefined }; }); }, []); const clearFacets = useCallback(() => { setFacets((prev) => ({ ...prev, statuses: undefined, priorities: undefined, })); }, []); const resolve = useCallback( async (ref: TicketRef): Promise => { const t = await ticket.read(projectId, ref); return { ref: t.ref, id: t.id, number: t.number, title: t.title, status: t.status, priority: t.priority, }; }, [ticket, projectId], ); const rows = useMemo( () => (excluded.size === 0 ? items : items.filter((t) => !excluded.has(t.ref))), [items, excluded], ); return { rows, busy, error, text, setText, statuses: facets.statuses ?? [], priorities: facets.priorities ?? [], toggleStatus, togglePriority, clearFacets, sort, setSort, hasMore: cursor !== undefined, loadMore, refresh, resolve, }; }