diff --git a/__tests__/components/ui/search/global-search.test.tsx b/__tests__/components/ui/search/global-search.test.tsx new file mode 100644 index 00000000..82720819 --- /dev/null +++ b/__tests__/components/ui/search/global-search.test.tsx @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRouter } from "next/navigation"; +import { GlobalSearch } from "@/components/ui/search/global-search"; +import { useSearch, type SearchState } from "@/lib/api/search"; +import { TestProviders } from "@/test-utils/providers"; +import { Some, None } from "@/types/option"; +import { ItemStatus } from "@/types/general"; +import { SearchHitType, type SearchHit } from "@/types/search"; + +vi.mock("@/lib/api/search", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useSearch: vi.fn() }; +}); + +const goalHit: SearchHit = { + type: SearchHitType.Goal, + id: "goal-1", + score: 0.6, + title: "Weekly planning habit", + snippet: Some("Build a weekly ritual"), + created_at: "2026-08-14T09:12:00Z", + updated_at: "2026-09-02T16:40:00Z", + organization_id: "org-1", + coaching_relationship_id: Some("rel-1"), + status: ItemStatus.InProgress, + created_in_session_id: Some("sess-1"), +}; + +const actionHit: SearchHit = { + type: SearchHitType.Action, + id: "action-1", + score: 0.3, + title: "Draft the weekly plan template", + snippet: None, + created_at: "2026-09-01T10:00:00Z", + updated_at: "2026-09-10T11:30:00Z", + organization_id: "org-1", + coaching_relationship_id: Some("rel-1"), + coaching_session_id: Some("sess-2"), + goal_id: Some("goal-1"), + status: ItemStatus.NotStarted, + due_by: None, + session_date: Some("2026-09-01T09:00:00"), + session_title: Some("Weekly sync"), +}; + +const loaded = (hits: SearchHit[], nextCursor = None as ReturnType> | typeof None): SearchState => ({ + kind: "loaded", + query: "weekly", + hits, + nextCursor, + isLoadingMore: false, +}); + +function mockSearch(state: SearchState, loadMore = vi.fn()) { + vi.mocked(useSearch).mockReturnValue({ state, loadMore }); + return loadMore; +} + +async function typeQuery(text: string) { + const input = screen.getByRole("combobox", { name: "Search" }); + await userEvent.type(input, text); + return input; +} + +describe("GlobalSearch", () => { + const push = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useRouter).mockReturnValue({ + push, + replace: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn(), + prefetch: vi.fn(), + }); + }); + + it("shows nothing below the field until the query is searchable", async () => { + mockSearch({ kind: "idle" }); + render(, { wrapper: TestProviders }); + await typeQuery("w"); + expect(screen.queryByRole("status", { name: "Searching" })).not.toBeInTheDocument(); + }); + + it("shows skeleton rows while a search is in flight", async () => { + mockSearch({ kind: "loading", query: "weekly" }); + render(, { wrapper: TestProviders }); + await typeQuery("weekly"); + expect(screen.getByRole("status", { name: "Searching" })).toBeInTheDocument(); + }); + + it("renders hits with highlighted snippet text as marks, not HTML", async () => { + mockSearch(loaded([goalHit, actionHit])); + render(, { wrapper: TestProviders }); + await typeQuery("weekly"); + + expect(screen.getByText("Weekly planning habit")).toBeInTheDocument(); + const mark = screen.getByText("weekly", { selector: "mark" }); + expect(mark.tagName).toBe("MARK"); + expect(screen.queryByText("")).not.toBeInTheDocument(); + expect(screen.getByText("Not Started · Weekly sync")).toBeInTheDocument(); + }); + + it("navigates to the hit's page on select and clears the field", async () => { + mockSearch(loaded([actionHit])); + render(, { wrapper: TestProviders }); + const input = await typeQuery("weekly"); + + await userEvent.click(screen.getByText("Draft the weekly plan template")); + + expect(push).toHaveBeenCalledWith("/coaching-sessions/sess-2?panel=actions&highlight=action-1"); + expect(input).toHaveValue(""); + }); + + it("keeps the dropdown open while the pointer is pressed on a result", async () => { + mockSearch(loaded([actionHit])); + render(, { wrapper: TestProviders }); + const input = await typeQuery("weekly"); + const row = screen.getByText("Draft the weekly plan template"); + + const mouseDown = fireEvent.mouseDown(row); + expect(mouseDown).toBe(false); + expect(input).toHaveFocus(); + // Simulate the browser moving focus away without preventDefault. + fireEvent.blur(input, { relatedTarget: null }); + expect(screen.queryByText("Draft the weekly plan template")).not.toBeInTheDocument(); + }); + + it("shows an empty state naming the query", async () => { + mockSearch(loaded([])); + render(, { wrapper: TestProviders }); + await typeQuery("weekly"); + expect(screen.getByText("No results for “weekly”")).toBeInTheDocument(); + }); + + it("shows a friendly message for a rate-limited search", async () => { + mockSearch({ kind: "error", query: "weekly", error: { kind: "rate_limited" } }); + render(, { wrapper: TestProviders }); + await typeQuery("weekly"); + expect(screen.getByRole("alert")).toHaveTextContent("Too many searches at once"); + }); + + it("offers to load more when a cursor is pending", async () => { + const loadMore = mockSearch(loaded([goalHit], Some("CUR"))); + render(, { wrapper: TestProviders }); + await typeQuery("weekly"); + + await userEvent.click(screen.getByText("Show more results")); + expect(loadMore).toHaveBeenCalledTimes(1); + }); + + it("focuses the field on Cmd+K and clears on Escape", async () => { + mockSearch(loaded([goalHit])); + render(, { wrapper: TestProviders }); + const input = screen.getByRole("combobox", { name: "Search" }); + + fireEvent.keyDown(document.body, { key: "k", metaKey: true }); + expect(input).toHaveFocus(); + + await userEvent.type(input, "weekly"); + await userEvent.keyboard("{Escape}"); + expect(input).toHaveValue(""); + }); +}); diff --git a/__tests__/lib/api/search.test.tsx b/__tests__/lib/api/search.test.tsx new file mode 100644 index 00000000..aabf59b6 --- /dev/null +++ b/__tests__/lib/api/search.test.tsx @@ -0,0 +1,229 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { AxiosError, type AxiosRequestConfig } from "axios"; +import { EntityApi } from "@/lib/api/entity-api"; +import { EntityApiError } from "@/types/entity-api-error"; +import { + SearchApi, + buildSearchQuery, + toSearchError, + useSearch, +} from "@/lib/api/search"; +import { SearchErrorCode, SearchableType } from "@/types/search"; + +vi.mock("@/lib/api/entity-api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, EntityApi: { getFn: vi.fn() } }; +}); + +vi.mock("@/site.config", () => ({ + siteConfig: { env: { backendServiceURL: "http://localhost:3000" } }, +})); + +const goalHit = (id: string) => ({ + type: "goal", + id, + score: 0.5, + title: `Goal ${id}`, + snippet: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + organization_id: "org-1", + coaching_relationship_id: "rel-1", + status: "InProgress", + created_in_session_id: null, +}); + +const httpError = (status: number, data?: unknown): EntityApiError => + new EntityApiError( + "get", + "http://localhost:3000/search", + new AxiosError("failed", "ERR_BAD_REQUEST", undefined, undefined, { + status, + statusText: "", + headers: {}, + config: {} as never, + data, + }) + ); + +describe("buildSearchQuery", () => { + it("serializes only defined params and joins types with commas", () => { + const qs = buildSearchQuery({ + q: "plan", + types: [SearchableType.Goals, SearchableType.Actions], + limit: 10, + organization_id: undefined, + }); + expect(qs.get("q")).toBe("plan"); + expect(qs.get("types")).toBe("goals,actions"); + expect(qs.get("limit")).toBe("10"); + expect(qs.has("organization_id")).toBe(false); + }); + + it("truncates the query at the backend's limit", () => { + const qs = buildSearchQuery({ q: "x".repeat(300) }); + expect(qs.get("q")).toHaveLength(256); + }); +}); + +describe("toSearchError", () => { + it("maps structured 400 bodies to validation errors", () => { + expect( + toSearchError(httpError(400, { error: "malformed_cursor", message: "bad cursor" })) + ).toEqual({ kind: "validation", code: SearchErrorCode.MalformedCursor, message: "bad cursor" }); + }); + + it("maps 429 and 503", () => { + expect(toSearchError(httpError(429))).toEqual({ kind: "rate_limited" }); + expect(toSearchError(httpError(503))).toEqual({ kind: "unavailable" }); + }); + + it("maps network failures", () => { + const error = new EntityApiError( + "get", + "http://localhost:3000/search", + new AxiosError("Network Error", "ERR_NETWORK") + ); + expect(toSearchError(error)).toEqual({ kind: "network" }); + }); + + it("falls back to unknown for anything else", () => { + expect(toSearchError(new Error("boom"))).toEqual({ kind: "unknown", message: "boom" }); + expect(toSearchError(httpError(400, { error: "not_a_code" })).kind).toBe("unknown"); + }); +}); + +describe("SearchApi.search", () => { + beforeEach(() => vi.clearAllMocks()); + + it("calls the search endpoint with the encoded query and passes the abort signal", async () => { + vi.mocked(EntityApi.getFn).mockResolvedValue({ query: "weekly plan", limit: 25, hits: [], next_cursor: null }); + const controller = new AbortController(); + + const result = await SearchApi.search({ q: "weekly plan" }, controller.signal); + + expect(result.isOk()).toBe(true); + const [url, config] = vi.mocked(EntityApi.getFn).mock.calls[0] as [string, AxiosRequestConfig]; + expect(url).toBe("http://localhost:3000/search?q=weekly+plan"); + expect(config.signal).toBe(controller.signal); + }); +}); + +describe("useSearch", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + afterEach(() => vi.useRealTimers()); + + it("stays idle without a request for queries under two characters", () => { + const { result } = renderHook(() => useSearch("a", {}, 0)); + act(() => vi.advanceTimersByTime(10)); + expect(result.current.state).toEqual({ kind: "idle" }); + expect(EntityApi.getFn).not.toHaveBeenCalled(); + }); + + it("debounces: only the last query within the window is sent", async () => { + vi.mocked(EntityApi.getFn).mockResolvedValue({ query: "plan", limit: 25, hits: [goalHit("g1")], next_cursor: null }); + const { result, rerender } = renderHook(({ q }) => useSearch(q, {}, 250), { + initialProps: { q: "pl" }, + }); + expect(result.current.state).toEqual({ kind: "loading", query: "pl" }); + + rerender({ q: "pla" }); + act(() => vi.advanceTimersByTime(100)); + rerender({ q: "plan" }); + act(() => vi.advanceTimersByTime(250)); + + expect(EntityApi.getFn).toHaveBeenCalledTimes(1); + expect(vi.mocked(EntityApi.getFn).mock.calls[0][0]).toContain("q=plan"); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + expect(result.current.state.kind).toBe("loaded"); + if (result.current.state.kind === "loaded") { + expect(result.current.state.hits.map((h) => h.id)).toEqual(["g1"]); + } + }); + + it("aborts the in-flight request when the query changes", async () => { + vi.mocked(EntityApi.getFn).mockImplementation( + (_url: string, config?: AxiosRequestConfig) => + new Promise((_resolve, reject) => { + config?.signal?.addEventListener("abort", () => reject(new Error("aborted"))); + }) + ); + const { rerender } = renderHook(({ q }) => useSearch(q, {}, 0), { + initialProps: { q: "first" }, + }); + act(() => vi.advanceTimersByTime(1)); + const firstSignal = (vi.mocked(EntityApi.getFn).mock.calls[0][1] as AxiosRequestConfig).signal; + + rerender({ q: "second" }); + act(() => vi.advanceTimersByTime(1)); + + expect(firstSignal?.aborted).toBe(true); + expect(EntityApi.getFn).toHaveBeenCalledTimes(2); + }); + + it("surfaces typed errors", async () => { + vi.mocked(EntityApi.getFn).mockRejectedValue(httpError(429)); + const { result } = renderHook(() => useSearch("plan", {}, 0)); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.state).toEqual({ + kind: "error", + query: "plan", + error: { kind: "rate_limited" }, + }); + }); + + it("loadMore appends the next page using the cursor", async () => { + vi.mocked(EntityApi.getFn) + .mockResolvedValueOnce({ query: "plan", limit: 1, hits: [goalHit("g1")], next_cursor: "CUR" }) + .mockResolvedValueOnce({ query: "plan", limit: 1, hits: [goalHit("g2")], next_cursor: null }); + const { result } = renderHook(() => useSearch("plan", {}, 0)); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + expect(result.current.state.kind).toBe("loaded"); + + act(() => result.current.loadMore()); + expect(result.current.state).toMatchObject({ kind: "loaded", isLoadingMore: true }); + expect(vi.mocked(EntityApi.getFn).mock.calls[1][0]).toContain("cursor=CUR"); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + expect(result.current.state).toMatchObject({ kind: "loaded", isLoadingMore: false }); + if (result.current.state.kind === "loaded") { + expect(result.current.state.hits.map((h) => h.id)).toEqual(["g1", "g2"]); + expect(result.current.state.nextCursor.none).toBe(true); + } + }); + + it("a filter change resets to page one", async () => { + vi.mocked(EntityApi.getFn).mockResolvedValue({ query: "plan", limit: 25, hits: [], next_cursor: null }); + const { result, rerender } = renderHook( + ({ types }) => useSearch("plan", { types }, 0), + { initialProps: { types: [SearchableType.Goals] } } + ); + await act(async () => { + await vi.runAllTimersAsync(); + }); + expect(result.current.state.kind).toBe("loaded"); + + rerender({ types: [SearchableType.Actions] }); + expect(result.current.state).toEqual({ kind: "loading", query: "plan" }); + await act(async () => { + await vi.runAllTimersAsync(); + }); + expect(vi.mocked(EntityApi.getFn).mock.calls[1][0]).toContain("types=actions"); + }); +}); diff --git a/__tests__/lib/utils/search-hit-href.test.ts b/__tests__/lib/utils/search-hit-href.test.ts new file mode 100644 index 00000000..26209cad --- /dev/null +++ b/__tests__/lib/utils/search-hit-href.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { searchHitHref } from "@/lib/utils/search-hit-href"; +import { Some, None } from "@/types/option"; +import { ItemStatus } from "@/types/general"; +import { TopicStatus } from "@/types/coaching-session-topic"; +import { SearchHitType, type SearchHit } from "@/types/search"; + +const base = { + id: "hit-1", + score: 1, + title: "t", + snippet: None, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + organization_id: "org-1", + coaching_relationship_id: Some("rel-1"), +}; + +describe("searchHitHref", () => { + it("links sessions to their page", () => { + const hit: SearchHit = { + ...base, + type: SearchHitType.CoachingSession, + date: "2026-01-01T00:00:00", + }; + expect(searchHitHref(hit)).toEqual(Some("/coaching-sessions/hit-1")); + }); + + it("links goals to the goals panel of their creating session, else the dashboard", () => { + const inSession: SearchHit = { + ...base, + type: SearchHitType.Goal, + status: ItemStatus.InProgress, + created_in_session_id: Some("sess-1"), + }; + expect(searchHitHref(inSession)).toEqual(Some("/coaching-sessions/sess-1?panel=goals")); + expect(searchHitHref({ ...inSession, created_in_session_id: None })).toEqual( + Some("/dashboard") + ); + }); + + it("links actions to the actions panel with a highlight", () => { + const hit: SearchHit = { + ...base, + type: SearchHitType.Action, + coaching_session_id: Some("sess-1"), + goal_id: None, + status: ItemStatus.NotStarted, + due_by: None, + session_date: None, + session_title: None, + }; + expect(searchHitHref(hit)).toEqual( + Some("/coaching-sessions/sess-1?panel=actions&highlight=hit-1") + ); + expect(searchHitHref({ ...hit, coaching_session_id: None })).toEqual(Some("/actions")); + }); + + it("returns None for agreements and topics without a session", () => { + const agreement: SearchHit = { + ...base, + type: SearchHitType.Agreement, + coaching_session_id: None, + session_date: None, + session_title: None, + }; + expect(searchHitHref(agreement).none).toBe(true); + expect(searchHitHref({ ...agreement, coaching_session_id: Some("s") })).toEqual( + Some("/coaching-sessions/s?panel=agreements") + ); + + const topic: SearchHit = { + ...base, + type: SearchHitType.Topic, + coaching_session_id: Some("s"), + status: TopicStatus.Open, + priority: None, + }; + expect(searchHitHref(topic)).toEqual(Some("/coaching-sessions/s")); + }); +}); diff --git a/__tests__/types/search.test.ts b/__tests__/types/search.test.ts new file mode 100644 index 00000000..f153988d --- /dev/null +++ b/__tests__/types/search.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from "vitest"; +import { + SearchHitType, + isSearchableQuery, + parseSearchHit, + parseSearchResponse, + splitSnippet, +} from "@/types/search"; +import { ItemStatus } from "@/types/general"; +import { TopicPriority, TopicStatus } from "@/types/coaching-session-topic"; + +const base = { + id: "hit-1", + score: 0.5, + title: "Weekly planning habit", + snippet: "Build a weekly ritual", + created_at: "2026-08-14T09:12:00Z", + updated_at: "2026-09-02T16:40:00Z", + organization_id: "org-1", + coaching_relationship_id: "rel-1", +}; + +describe("parseSearchHit", () => { + it("parses a coaching session hit", () => { + const hit = parseSearchHit({ + ...base, + type: "coaching_session", + date: "2026-09-01T09:00:00", + }); + expect(hit.some && hit.val.type === SearchHitType.CoachingSession).toBe(true); + if (hit.some && hit.val.type === SearchHitType.CoachingSession) { + expect(hit.val.date).toBe("2026-09-01T09:00:00"); + expect(hit.val.snippet).toEqual({ some: true, none: false, val: base.snippet }); + } + }); + + it("wraps nullable goal fields in Option", () => { + const hit = parseSearchHit({ + ...base, + type: "goal", + status: "InProgress", + created_in_session_id: null, + snippet: null, + }); + expect(hit.some).toBe(true); + if (hit.some && hit.val.type === SearchHitType.Goal) { + expect(hit.val.status).toBe(ItemStatus.InProgress); + expect(hit.val.created_in_session_id.none).toBe(true); + expect(hit.val.snippet.none).toBe(true); + } + }); + + it("parses action extras", () => { + const hit = parseSearchHit({ + ...base, + type: "action", + coaching_session_id: "sess-1", + goal_id: "goal-1", + status: "NotStarted", + due_by: null, + session_date: "2026-09-01T09:00:00", + session_title: "Weekly sync", + }); + if (!hit.some || hit.val.type !== SearchHitType.Action) throw new Error("expected action"); + expect(hit.val.coaching_session_id).toEqual({ some: true, none: false, val: "sess-1" }); + expect(hit.val.session_title).toEqual({ some: true, none: false, val: "Weekly sync" }); + expect(hit.val.goal_id).toEqual({ some: true, none: false, val: "goal-1" }); + expect(hit.val.due_by.none).toBe(true); + }); + + it("parses topic status and priority", () => { + const hit = parseSearchHit({ + ...base, + type: "topic", + coaching_session_id: "sess-1", + status: "Discussed", + priority: "High", + }); + if (!hit.some || hit.val.type !== SearchHitType.Topic) throw new Error("expected topic"); + expect(hit.val.status).toBe(TopicStatus.Discussed); + expect(hit.val.priority).toEqual({ some: true, none: false, val: TopicPriority.High }); + }); + + it("returns None for hit types this client does not know", () => { + expect(parseSearchHit({ ...base, type: "note" }).none).toBe(true); + expect(parseSearchHit({ ...base, type: "transcript" }).none).toBe(true); + }); + + it("throws on a hit missing required fields", () => { + expect(() => parseSearchHit({ type: "goal", id: "x" })).toThrow(); + expect(() => parseSearchHit("nope")).toThrow(); + }); +}); + +describe("parseSearchResponse", () => { + it("drops unknown types and keeps the cursor", () => { + const response = parseSearchResponse({ + query: "weekly", + limit: 25, + hits: [ + { ...base, type: "goal", status: "InProgress", created_in_session_id: null }, + { ...base, id: "u-1", type: "user" }, + { ...base, id: "ag-1", type: "agreement", coaching_session_id: "sess-1" }, + ], + next_cursor: "AT2c", + }); + expect(response.hits.map((h) => h.type)).toEqual([ + SearchHitType.Goal, + SearchHitType.Agreement, + ]); + expect(response.next_cursor).toEqual({ some: true, none: false, val: "AT2c" }); + }); + + it("maps a null cursor to None", () => { + const response = parseSearchResponse({ query: "x", limit: 25, hits: [], next_cursor: null }); + expect(response.next_cursor.none).toBe(true); + }); + + it("throws when hits is not an array", () => { + expect(() => parseSearchResponse({ query: "x" })).toThrow(); + }); +}); + +describe("splitSnippet", () => { + it("splits marks into highlighted segments", () => { + expect(splitSnippet("Build a weekly plan ritual")).toEqual([ + { text: "Build a ", highlighted: false }, + { text: "weekly", highlighted: true }, + { text: " ", highlighted: false }, + { text: "plan", highlighted: true }, + { text: " ritual", highlighted: false }, + ]); + }); + + it("returns one plain segment when there are no marks", () => { + expect(splitSnippet("plain text")).toEqual([{ text: "plain text", highlighted: false }]); + }); + + it("unescapes HTML entities in both plain and highlighted segments", () => { + expect(splitSnippet("Plan Q3 & Q4")).toEqual([ + { text: "Plan", highlighted: true }, + { text: " Q3 & Q4", highlighted: false }, + ]); + expect(splitSnippet("if a < b then retry")).toEqual([ + { text: "if a < b then ", highlighted: false }, + { text: "retry", highlighted: true }, + ]); + expect(splitSnippet("a > b")).toEqual([{ text: "a > b", highlighted: true }]); + }); + + it("unescapes & last so a doubly escaped entity stays literal", () => { + expect(splitSnippet("&lt;")).toEqual([{ text: "<", highlighted: false }]); + }); +}); + +describe("isSearchableQuery", () => { + it("requires two characters after trimming", () => { + expect(isSearchableQuery(" a ")).toBe(false); + expect(isSearchableQuery("ab")).toBe(true); + expect(isSearchableQuery(" ab ")).toBe(true); + }); +}); diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 165ec9cb..484a9cce 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -117,7 +117,7 @@ const CommandItem = React.forwardRef< = { + [SearchHitType.CoachingSession]: "Session", + [SearchHitType.Goal]: "Goal", + [SearchHitType.Action]: "Action", + [SearchHitType.Agreement]: "Agreement", + [SearchHitType.Topic]: "Topic", +}; + +function HitIcon({ type, className }: { type: SearchHitType; className?: string }) { + switch (type) { + case SearchHitType.CoachingSession: + return ; + case SearchHitType.Goal: + return ; + case SearchHitType.Action: + return ; + case SearchHitType.Agreement: + return ; + case SearchHitType.Topic: + return ; + default: { + const _exhaustive: never = type; + throw new Error(`Unhandled search hit type: ${_exhaustive}`); + } + } +} + +// Session dates arrive as naive ISO strings; read them as UTC so the calendar day is stable. +const formatDate = (iso: string): string => + DateTime.fromISO(iso, { zone: "utc" }).toFormat("MMM d, yyyy"); + +/** The secondary line under a hit's title, or None when there is nothing worth showing. */ +function hitMeta(hit: SearchHit): Option { + const parts: string[] = []; + switch (hit.type) { + case SearchHitType.CoachingSession: + parts.push(formatDate(hit.date)); + break; + case SearchHitType.Goal: + parts.push(actionStatusToString(hit.status)); + break; + case SearchHitType.Action: + parts.push(actionStatusToString(hit.status)); + if (hit.session_title.some) parts.push(hit.session_title.val); + break; + case SearchHitType.Agreement: + if (hit.session_title.some) parts.push(hit.session_title.val); + if (hit.session_date.some) parts.push(formatDate(hit.session_date.val)); + break; + case SearchHitType.Topic: + parts.push(hit.status); + if (hit.priority.some) parts.push(`${hit.priority.val} priority`); + break; + default: { + const _exhaustive: never = hit; + throw new Error(`Unhandled search hit type: ${_exhaustive}`); + } + } + return parts.length > 0 ? Some(parts.join(" · ")) : None; +} + +function errorMessage(error: SearchError): string { + switch (error.kind) { + case "validation": + return error.message; + case "rate_limited": + return "Too many searches at once. Pause a moment and try again."; + case "unavailable": + return "Search is temporarily unavailable."; + case "network": + return "Can't reach the server. Check your connection."; + case "unknown": + return "Something went wrong while searching."; + default: { + const _exhaustive: never = error; + throw new Error(`Unhandled search error: ${_exhaustive}`); + } + } +} + +const isTypingTarget = (target: EventTarget | null): boolean => + (target instanceof HTMLElement && target.isContentEditable) || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement; + +interface GlobalSearchProps { + className?: string; +} + +/** + * Header search field: debounced keyword search across sessions, goals, + * actions, agreements and topics, with results in a dropdown beneath the + * field. Cmd/Ctrl+K or "/" focuses it from anywhere outside a text input. + */ +export function GlobalSearch({ className }: GlobalSearchProps) { + const router = useRouter(); + const setCurrentCoachingRelationshipId = useCoachingRelationshipStateStore( + (state) => state.setCurrentCoachingRelationshipId + ); + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const inputRef = useRef(null); + const rootRef = useRef(null); + const { state, loadMore } = useSearch(query); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const isShortcut = + (event.key === "k" && (event.metaKey || event.ctrlKey)) || event.key === "/"; + if (!isShortcut || isTypingTarget(event.target)) return; + event.preventDefault(); + inputRef.current?.focus(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, []); + + const reset = useCallback(() => { + setQuery(""); + setOpen(false); + inputRef.current?.blur(); + }, []); + + const handleBlur = useCallback((event: FocusEvent) => { + if (!rootRef.current?.contains(event.relatedTarget)) setOpen(false); + }, []); + + const handleSelect = useCallback( + (hit: SearchHit) => { + const href = searchHitHref(hit); + if (href.none) return; + // The dashboard fallback for a goal only shows it under its own relationship. + if (hit.type === SearchHitType.Goal && hit.coaching_relationship_id.some) { + setCurrentCoachingRelationshipId(hit.coaching_relationship_id.val); + } + reset(); + router.push(href.val); + }, + [reset, router, setCurrentCoachingRelationshipId] + ); + + const showResults = open && isSearchableQuery(query); + + return ( +
+ { + if (event.key === "Escape") { + event.preventDefault(); + if (query) reset(); + else inputRef.current?.blur(); + } + }} + > +
+ + { + setQuery(value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + placeholder="Search sessions, notes, actions…" + className={cn( + "h-8 w-full rounded-md border border-input bg-background pl-8 text-sm", + "placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + query ? "pr-8" : "pr-14" + )} + /> + {query ? ( + + ) : ( + + ⌘K + + )} +
+ + {showResults && ( +
event.preventDefault()} + > + +
+ )} +
+
+ ); +} + +interface SearchResultsProps { + state: SearchState; + onSelect: (hit: SearchHit) => void; + onLoadMore: () => void; +} + +function SearchResults({ state, onSelect, onLoadMore }: SearchResultsProps) { + switch (state.kind) { + case "idle": + return null; + case "loading": + return ; + case "error": + return ( +

+ {errorMessage(state.error)} +

+ ); + case "loaded": + if (state.hits.length === 0) { + return ( +

+ No results for “{state.query.trim()}” +

+ ); + } + return ( + + + {state.hits.map((hit) => ( + + ))} + + {state.nextCursor.some && ( + + {state.isLoadingMore ? ( + <> + + Loading more… + + ) : ( + "Show more results" + )} + + )} + + ); + default: { + const _exhaustive: never = state; + throw new Error(`Unhandled search state: ${_exhaustive}`); + } + } +} + +const SKELETON_ROW_COUNT = 3; + +/** Placeholder rows shaped like results so the dropdown height holds steady when hits land. */ +function SearchResultsSkeleton() { + return ( +
+ {Array.from({ length: SKELETON_ROW_COUNT }, (_, index) => ( +
+ +
+ + + +
+
+ ))} +
+ ); +} + +interface SearchResultRowProps { + hit: SearchHit; + onSelect: (hit: SearchHit) => void; +} + +function SearchResultRow({ hit, onSelect }: SearchResultRowProps) { + const href = searchHitHref(hit); + const meta = hitMeta(hit); + return ( + onSelect(hit)} + disabled={href.none} + className="cursor-pointer items-start gap-3 px-2 py-2" + > + +
+
+ {hit.title} + {TYPE_LABELS[hit.type]} +
+ {hit.snippet.some && ( + + )} + {meta.some && ( +
+ {meta.val} +
+ )} +
+
+ ); +} diff --git a/src/components/ui/search/search-snippet.tsx b/src/components/ui/search/search-snippet.tsx new file mode 100644 index 00000000..792b5b76 --- /dev/null +++ b/src/components/ui/search/search-snippet.tsx @@ -0,0 +1,26 @@ +import { splitSnippet } from "@/types/search"; + +interface SearchSnippetProps { + snippet: string; + className?: string; +} + +/** Renders a backend snippet's `` markers as text nodes, never as HTML. */ +export function SearchSnippet({ snippet, className }: SearchSnippetProps) { + return ( + + {splitSnippet(snippet).map((segment, index) => + segment.highlighted ? ( + + {segment.text} + + ) : ( + {segment.text} + ) + )} + + ); +} diff --git a/src/components/ui/site-header.tsx b/src/components/ui/site-header.tsx index 3468ec3a..15ce2cdd 100644 --- a/src/components/ui/site-header.tsx +++ b/src/components/ui/site-header.tsx @@ -5,6 +5,7 @@ import { MainNav } from "@/components/ui/main-nav"; import { ModeToggle } from "@/components/ui/mode-toggle"; import { JoinSessionPopover } from "@/components/ui/join-session-popover"; import { UserNav } from "@/components/ui/user-nav"; +import { GlobalSearch } from "@/components/ui/search/global-search"; import { useCoachingRelationshipStateStore } from "@/lib/providers/coaching-relationship-state-store-provider"; import { useShowSessionSwitcher } from "@/lib/hooks/use-show-session-switcher"; @@ -18,10 +19,11 @@ export function SiteHeader() {
-
+
{/*
*/} +