diff --git a/docs-site/docs/features/tracer-links.md b/docs-site/docs/features/tracer-links.md index e374702..32a5b36 100644 --- a/docs-site/docs/features/tracer-links.md +++ b/docs-site/docs/features/tracer-links.md @@ -145,7 +145,7 @@ Fix: ## Related pages -- [Settings](/docs/features/settings) -- [Reactive Resume](/docs/features/reactive-resume) -- [Find Jobs and Apply Workflow](/docs/workflows/find-jobs-and-apply-workflow) -- [Post-Application Tracking](/docs/features/post-application-tracking) +- [Settings](/docs/next/features/settings) +- [Reactive Resume](/docs/next/features/reactive-resume) +- [Find Jobs and Apply Workflow](/docs/next/workflows/find-jobs-and-apply-workflow) +- [Post-Application Tracking](/docs/next/features/post-application-tracking) diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts index 8c69311..0fd5780 100644 --- a/docs-site/sidebars.ts +++ b/docs-site/sidebars.ts @@ -35,6 +35,7 @@ const sidebars: SidebarsConfig = { "features/ghostwriter", "features/post-application-tracking", "features/visa-sponsors", + "features/tracer-links", ], }, { diff --git a/docs-site/versioned_sidebars/version-0.1.24-sidebars.json b/docs-site/versioned_sidebars/version-0.1.24-sidebars.json index 5b2e8f3..25cff74 100644 --- a/docs-site/versioned_sidebars/version-0.1.24-sidebars.json +++ b/docs-site/versioned_sidebars/version-0.1.24-sidebars.json @@ -32,7 +32,8 @@ "features/in-progress-board", "features/ghostwriter", "features/post-application-tracking", - "features/visa-sponsors" + "features/visa-sponsors", + "features/tracer-links" ] }, { diff --git a/orchestrator/package.json b/orchestrator/package.json index 3d7ed48..4f31008 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -44,6 +44,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-query": "^5.90.21", "@types/canvas-confetti": "^1.9.0", "better-sqlite3": "^11.6.0", "canvas-confetti": "^1.9.4", diff --git a/orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx b/orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx index ccfc091..5451bcc 100644 --- a/orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx +++ b/orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx @@ -1,12 +1,16 @@ import { createJob } from "@shared/testing/factories.js"; import type { Job } from "@shared/types.js"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import type React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../api"; import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness"; +import { renderWithQueryClient } from "../test/renderWithQueryClient"; import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer"; +const render = (ui: Parameters[0]) => + renderWithQueryClient(ui); + vi.mock("@/components/ui/sheet", () => ({ Sheet: ({ open, children }: { open: boolean; children: React.ReactNode }) => open ?
{children}
: null, @@ -166,7 +170,11 @@ describe("JobDetailsEditDrawer", () => { ); await waitFor(() => expect(api.getTracerReadiness).toHaveBeenCalled()); - fireEvent.click(screen.getByLabelText("Enable tracer links for this job")); + const tracerToggle = await screen.findByRole("checkbox", { + name: "Enable tracer links for this job", + }); + await waitFor(() => expect(tracerToggle).toBeEnabled()); + fireEvent.click(tracerToggle); fireEvent.click(screen.getByRole("button", { name: /save details/i })); await waitFor(() => diff --git a/orchestrator/src/client/components/OnboardingGate.test.tsx b/orchestrator/src/client/components/OnboardingGate.test.tsx index 143414c..9d21683 100644 --- a/orchestrator/src/client/components/OnboardingGate.test.tsx +++ b/orchestrator/src/client/components/OnboardingGate.test.tsx @@ -1,10 +1,14 @@ import * as api from "@client/api"; import { useSettings } from "@client/hooks/useSettings"; -import { render, screen, waitFor } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import type React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithQueryClient } from "../test/renderWithQueryClient"; import { OnboardingGate } from "./OnboardingGate"; +const render = (ui: Parameters[0]) => + renderWithQueryClient(ui); + vi.mock("@client/api", () => ({ getDemoInfo: vi.fn(), validateLlm: vi.fn(), diff --git a/orchestrator/src/client/components/ReadyPanel.test.tsx b/orchestrator/src/client/components/ReadyPanel.test.tsx index 64dc99e..74b2576 100644 --- a/orchestrator/src/client/components/ReadyPanel.test.tsx +++ b/orchestrator/src/client/components/ReadyPanel.test.tsx @@ -1,13 +1,17 @@ import { createJob } from "@shared/testing/factories.js"; import type { Job } from "@shared/types.js"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import type React from "react"; import { MemoryRouter } from "react-router-dom"; import { toast } from "sonner"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../api"; +import { renderWithQueryClient } from "../test/renderWithQueryClient"; import { ReadyPanel } from "./ReadyPanel"; +const render = (ui: Parameters[0]) => + renderWithQueryClient(ui); + vi.mock("@/components/ui/dropdown-menu", () => { return { DropdownMenu: ({ children }: { children: React.ReactNode }) => ( diff --git a/orchestrator/src/client/components/TailoringEditor.test.tsx b/orchestrator/src/client/components/TailoringEditor.test.tsx index 631b6c9..0787c71 100644 --- a/orchestrator/src/client/components/TailoringEditor.test.tsx +++ b/orchestrator/src/client/components/TailoringEditor.test.tsx @@ -1,12 +1,16 @@ import { createJob as createBaseJob } from "@shared/testing/factories.js"; import type { Job } from "@shared/types.js"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../api"; import { useProfile } from "../hooks/useProfile"; import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness"; +import { renderWithQueryClient } from "../test/renderWithQueryClient"; import { TailoringEditor } from "./TailoringEditor"; +const render = (ui: Parameters[0]) => + renderWithQueryClient(ui); + vi.mock("../api", () => ({ getResumeProjectsCatalog: vi.fn().mockResolvedValue([]), updateJob: vi.fn().mockResolvedValue({}), diff --git a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx index ee94cd9..edfdf09 100644 --- a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx +++ b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx @@ -1,13 +1,17 @@ import { createJob } from "@shared/testing/factories.js"; import type { Job } from "@shared/types.js"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import type React from "react"; import { MemoryRouter } from "react-router-dom"; import { toast } from "sonner"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../../api"; +import { renderWithQueryClient } from "../../test/renderWithQueryClient"; import { DiscoveredPanel } from "./DiscoveredPanel"; +const render = (ui: Parameters[0]) => + renderWithQueryClient(ui); + vi.mock("@/components/ui/dropdown-menu", () => { return { DropdownMenu: ({ children }: { children: React.ReactNode }) => ( diff --git a/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx b/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx index cf21ae0..f2b1ca8 100644 --- a/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx +++ b/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx @@ -1,12 +1,16 @@ import { createJob as createBaseJob } from "@shared/testing/factories.js"; import type { Job } from "@shared/types.js"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../../api"; import { useProfile } from "../../hooks/useProfile"; import { _resetTracerReadinessCache } from "../../hooks/useTracerReadiness"; +import { renderWithQueryClient } from "../../test/renderWithQueryClient"; import { TailorMode } from "./TailorMode"; +const render = (ui: Parameters[0]) => + renderWithQueryClient(ui); + vi.mock("../../api", () => ({ getResumeProjectsCatalog: vi.fn().mockResolvedValue([]), updateJob: vi.fn(), diff --git a/orchestrator/src/client/hooks/queries/invalidate.test.ts b/orchestrator/src/client/hooks/queries/invalidate.test.ts new file mode 100644 index 0000000..4922378 --- /dev/null +++ b/orchestrator/src/client/hooks/queries/invalidate.test.ts @@ -0,0 +1,17 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import { queryKeys } from "@/client/lib/queryKeys"; +import { invalidateJobData } from "./invalidate"; + +describe("invalidateJobData", () => { + it("invalidates in-progress board when invalidating a specific job", async () => { + const invalidateQueries = vi.fn().mockResolvedValue(undefined); + const queryClient = { invalidateQueries } as unknown as QueryClient; + + await invalidateJobData(queryClient, "job-1"); + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.jobs.inProgressBoard(), + }); + }); +}); diff --git a/orchestrator/src/client/hooks/queries/invalidate.ts b/orchestrator/src/client/hooks/queries/invalidate.ts new file mode 100644 index 0000000..6619616 --- /dev/null +++ b/orchestrator/src/client/hooks/queries/invalidate.ts @@ -0,0 +1,38 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { queryKeys } from "@/client/lib/queryKeys"; + +export async function invalidateJobData( + queryClient: QueryClient, + jobId?: string | null, +): Promise { + if (!jobId) { + await queryClient.invalidateQueries({ queryKey: queryKeys.jobs.all }); + return; + } + + await queryClient.invalidateQueries({ + queryKey: [...queryKeys.jobs.all, "list"] as const, + }); + await queryClient.invalidateQueries({ + queryKey: [...queryKeys.jobs.all, "revision"] as const, + }); + await queryClient.invalidateQueries({ + queryKey: queryKeys.jobs.inProgressBoard(), + }); + await queryClient.invalidateQueries({ + queryKey: queryKeys.jobs.detail(jobId), + }); + await queryClient.invalidateQueries({ + queryKey: queryKeys.jobs.stageEvents(jobId), + }); + await queryClient.invalidateQueries({ + queryKey: queryKeys.jobs.tasks(jobId), + }); +} + +export async function invalidateSettingsData( + queryClient: QueryClient, +): Promise { + await queryClient.invalidateQueries({ queryKey: queryKeys.settings.all }); + await queryClient.invalidateQueries({ queryKey: queryKeys.tracer.all }); +} diff --git a/orchestrator/src/client/hooks/queries/useJobMutations.ts b/orchestrator/src/client/hooks/queries/useJobMutations.ts new file mode 100644 index 0000000..b8fa400 --- /dev/null +++ b/orchestrator/src/client/hooks/queries/useJobMutations.ts @@ -0,0 +1,102 @@ +import * as api from "@client/api"; +import type { Job } from "@shared/types"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "@/client/lib/queryKeys"; +import { invalidateJobData } from "./invalidate"; + +export function useUpdateJobMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, update }: { id: string; update: Partial }) => + api.updateJob(id, update), + onSuccess: async (_data, variables) => { + await invalidateJobData(queryClient, variables.id); + }, + }); +} + +export function useMarkAsAppliedMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.markAsApplied(id), + onMutate: async (id) => { + await queryClient.cancelQueries({ queryKey: queryKeys.jobs.detail(id) }); + const previousJob = queryClient.getQueryData( + queryKeys.jobs.detail(id), + ); + queryClient.setQueryData(queryKeys.jobs.detail(id), (current) => + current ? { ...current, status: "applied" } : current, + ); + return { previousJob, id }; + }, + onError: (_error, _id, context) => { + if (context?.id) { + queryClient.setQueryData( + queryKeys.jobs.detail(context.id), + context.previousJob, + ); + } + }, + onSettled: async (_data, _error, id) => { + await invalidateJobData(queryClient, id); + }, + }); +} + +export function useSkipJobMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.skipJob(id), + onMutate: async (id) => { + await queryClient.cancelQueries({ queryKey: queryKeys.jobs.detail(id) }); + const previousJob = queryClient.getQueryData( + queryKeys.jobs.detail(id), + ); + queryClient.setQueryData(queryKeys.jobs.detail(id), (current) => + current ? { ...current, status: "skipped" } : current, + ); + return { previousJob, id }; + }, + onError: (_error, _id, context) => { + if (context?.id) { + queryClient.setQueryData( + queryKeys.jobs.detail(context.id), + context.previousJob, + ); + } + }, + onSettled: async (_data, _error, id) => { + await invalidateJobData(queryClient, id); + }, + }); +} + +export function useRescoreJobMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.rescoreJob(id), + onSuccess: async (_data, id) => { + await invalidateJobData(queryClient, id); + }, + }); +} + +export function useGenerateJobPdfMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.generateJobPdf(id), + onSuccess: async (_data, id) => { + await invalidateJobData(queryClient, id); + }, + }); +} + +export function useCheckSponsorMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.checkSponsor(id), + onSuccess: async (_data, id) => { + await invalidateJobData(queryClient, id); + }, + }); +} diff --git a/orchestrator/src/client/hooks/queries/useSettingsMutation.ts b/orchestrator/src/client/hooks/queries/useSettingsMutation.ts new file mode 100644 index 0000000..7d12192 --- /dev/null +++ b/orchestrator/src/client/hooks/queries/useSettingsMutation.ts @@ -0,0 +1,14 @@ +import * as api from "@client/api"; +import type { UpdateSettingsInput } from "@shared/settings-schema"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { invalidateSettingsData } from "./invalidate"; + +export function useUpdateSettingsMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: UpdateSettingsInput) => api.updateSettings(payload), + onSuccess: async () => { + await invalidateSettingsData(queryClient); + }, + }); +} diff --git a/orchestrator/src/client/hooks/useDemoInfo.ts b/orchestrator/src/client/hooks/useDemoInfo.ts index 41a090c..558c31e 100644 --- a/orchestrator/src/client/hooks/useDemoInfo.ts +++ b/orchestrator/src/client/hooks/useDemoInfo.ts @@ -1,30 +1,18 @@ import * as api from "@client/api"; import type { DemoInfoResponse } from "@shared/types"; -import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "@/client/lib/queryKeys"; export function useDemoInfo() { - const [demoInfo, setDemoInfo] = useState(null); - - useEffect(() => { - let isCancelled = false; - - void api - .getDemoInfo() - .then((info) => { - if (!isCancelled) { - setDemoInfo(info); - } - }) - .catch(() => { - if (!isCancelled) { - setDemoInfo(null); - } - }); - - return () => { - isCancelled = true; - }; - }, []); - - return demoInfo; + const { data } = useQuery({ + queryKey: queryKeys.demo.info(), + queryFn: async () => { + try { + return await api.getDemoInfo(); + } catch { + return null; + } + }, + }); + return data ?? null; } diff --git a/orchestrator/src/client/hooks/useProfile.ts b/orchestrator/src/client/hooks/useProfile.ts index 1f13dd9..6743ea5 100644 --- a/orchestrator/src/client/hooks/useProfile.ts +++ b/orchestrator/src/client/hooks/useProfile.ts @@ -1,98 +1,35 @@ import type { ResumeProfile } from "@shared/types"; -import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { queryClient as appQueryClient } from "@/client/lib/queryClient"; +import { queryKeys } from "@/client/lib/queryKeys"; import * as api from "../api"; -let profileCache: ResumeProfile | null = null; -let profileError: Error | null = null; -const subscribers: Set< - (profile: ResumeProfile | null, error: Error | null) => void -> = new Set(); -let isFetching = false; - /** * Hook to get the full profile data from base.json. * Caches the result to avoid re-fetching. */ export function useProfile() { - const [profile, setProfile] = useState(profileCache); - const [error, setError] = useState(profileError); - - useEffect(() => { - if (profileCache) { - setProfile(profileCache); - } - if (profileError) { - setError(profileError); - } - - const handleUpdate = ( - newProfile: ResumeProfile | null, - newError: Error | null, - ) => { - setProfile(newProfile); - setError(newError); - }; - - subscribers.add(handleUpdate); - - if (!profileCache && !isFetching) { - isFetching = true; - profileError = null; - api - .getProfile() - .then((data) => { - profileCache = data; - profileError = null; - subscribers.forEach((sub) => { - sub(data, null); - }); - }) - .catch((err) => { - profileError = err instanceof Error ? err : new Error(String(err)); - subscribers.forEach((sub) => { - sub(profileCache, profileError); - }); - }) - .finally(() => { - isFetching = false; - }); - } - - return () => { - subscribers.delete(handleUpdate); - }; - }, []); + const { + data: profile = null, + error, + isLoading, + isFetching, + refetch, + } = useQuery({ + queryKey: queryKeys.profile.current(), + queryFn: api.getProfile, + }); const refreshProfile = async () => { - isFetching = true; - profileError = null; - subscribers.forEach((sub) => { - sub(profileCache, null); - }); - - try { - const data = await api.getProfile(); - profileCache = data; - profileError = null; - subscribers.forEach((sub) => { - sub(data, null); - }); - return data; - } catch (err) { - profileError = err instanceof Error ? err : new Error(String(err)); - subscribers.forEach((sub) => { - sub(profileCache, profileError); - }); - throw profileError; - } finally { - isFetching = false; - } + const result = await refetch(); + if (result.error) throw result.error; + return result.data ?? null; }; return { profile, - error, - isLoading: !profile && isFetching && !error, + error: error ?? null, + isLoading: isLoading || (!!isFetching && !profile && !error), personName: profile?.basics?.name || "Resume", refreshProfile, }; @@ -100,8 +37,5 @@ export function useProfile() { /** @internal For testing only */ export function _resetProfileCache() { - profileCache = null; - profileError = null; - isFetching = false; - subscribers.clear(); + appQueryClient.removeQueries({ queryKey: queryKeys.profile.all }); } diff --git a/orchestrator/src/client/hooks/useQueryErrorToast.ts b/orchestrator/src/client/hooks/useQueryErrorToast.ts new file mode 100644 index 0000000..517a8d4 --- /dev/null +++ b/orchestrator/src/client/hooks/useQueryErrorToast.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; + +/** + * Shows a toast when a React Query `error` becomes non-null. + * Deduplicates repeated firings for the same error message so the toast + * does not reappear on every re-render while the query stays in error state. + * + * @param error The `error` value from `useQuery` / `useInfiniteQuery`. + * @param fallback Fallback message used when the error is not an Error instance. + */ +export function useQueryErrorToast(error: unknown, fallback: string): void { + const lastKeyRef = useRef(null); + + useEffect(() => { + if (!error) { + lastKeyRef.current = null; + return; + } + const message = error instanceof Error ? error.message : fallback; + if (lastKeyRef.current === message) return; + lastKeyRef.current = message; + toast.error(message); + }, [error, fallback]); +} diff --git a/orchestrator/src/client/hooks/useRescoreJob.test.ts b/orchestrator/src/client/hooks/useRescoreJob.test.ts index 0501278..123331d 100644 --- a/orchestrator/src/client/hooks/useRescoreJob.test.ts +++ b/orchestrator/src/client/hooks/useRescoreJob.test.ts @@ -1,7 +1,8 @@ -import { act, renderHook } from "@testing-library/react"; +import { act } from "@testing-library/react"; import { toast } from "sonner"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../api"; +import { renderHookWithQueryClient } from "../test/renderWithQueryClient"; import { useRescoreJob } from "./useRescoreJob"; vi.mock("../api", () => ({ @@ -24,7 +25,9 @@ describe("useRescoreJob", () => { const onJobUpdated = vi.fn().mockResolvedValue(undefined); vi.mocked(api.rescoreJob).mockResolvedValue({} as any); - const { result } = renderHook(() => useRescoreJob(onJobUpdated)); + const { result } = renderHookWithQueryClient(() => + useRescoreJob(onJobUpdated), + ); await act(async () => { await result.current.rescoreJob("job-1"); diff --git a/orchestrator/src/client/hooks/useRescoreJob.ts b/orchestrator/src/client/hooks/useRescoreJob.ts index 4ddf2ca..a5cae5c 100644 --- a/orchestrator/src/client/hooks/useRescoreJob.ts +++ b/orchestrator/src/client/hooks/useRescoreJob.ts @@ -1,10 +1,10 @@ import { useCallback, useState } from "react"; import { toast } from "sonner"; - -import * as api from "../api"; +import { useRescoreJobMutation } from "@/client/hooks/queries/useJobMutations"; export function useRescoreJob(onJobUpdated: () => void | Promise) { const [isRescoring, setIsRescoring] = useState(false); + const rescoreMutation = useRescoreJobMutation(); const rescoreJob = useCallback( async (jobId?: string | null) => { @@ -12,7 +12,7 @@ export function useRescoreJob(onJobUpdated: () => void | Promise) { try { setIsRescoring(true); - await api.rescoreJob(jobId); + await rescoreMutation.mutateAsync(jobId); toast.success("Match recalculated"); await onJobUpdated(); } catch (error) { @@ -25,7 +25,7 @@ export function useRescoreJob(onJobUpdated: () => void | Promise) { setIsRescoring(false); } }, - [onJobUpdated], + [onJobUpdated, rescoreMutation], ); return { isRescoring, rescoreJob }; diff --git a/orchestrator/src/client/hooks/useSettings.test.ts b/orchestrator/src/client/hooks/useSettings.test.ts index 6d33c36..b853160 100644 --- a/orchestrator/src/client/hooks/useSettings.test.ts +++ b/orchestrator/src/client/hooks/useSettings.test.ts @@ -1,6 +1,7 @@ -import { act, renderHook, waitFor } from "@testing-library/react"; +import { act, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../api"; +import { renderHookWithQueryClient } from "../test/renderWithQueryClient"; import { _resetSettingsCache, useSettings } from "./useSettings"; vi.mock("../api", () => ({ @@ -17,7 +18,7 @@ describe("useSettings", () => { const mockSettings = { showSponsorInfo: false }; vi.mocked(api.getSettings).mockResolvedValue(mockSettings as any); - const { result } = renderHook(() => useSettings()); + const { result } = renderHookWithQueryClient(() => useSettings()); // Should start in loading state expect(result.current.settings).toBeNull(); @@ -33,7 +34,7 @@ describe("useSettings", () => { it("uses default values when settings are null", async () => { vi.mocked(api.getSettings).mockResolvedValue(null as any); - const { result } = renderHook(() => useSettings()); + const { result } = renderHookWithQueryClient(() => useSettings()); await waitFor(() => { // settings is null, so showSponsorInfo should default to true @@ -48,7 +49,7 @@ describe("useSettings", () => { vi.mocked(api.getSettings).mockResolvedValueOnce(initialSettings as any); vi.mocked(api.getSettings).mockResolvedValueOnce(updatedSettings as any); - const { result } = renderHook(() => useSettings()); + const { result } = renderHookWithQueryClient(() => useSettings()); await waitFor(() => { expect(result.current.settings).toEqual(initialSettings); @@ -71,7 +72,7 @@ describe("useSettings", () => { const mockError = new Error("Failed to fetch"); vi.mocked(api.getSettings).mockRejectedValue(mockError); - const { result } = renderHook(() => useSettings()); + const { result } = renderHookWithQueryClient(() => useSettings()); await waitFor(() => { expect(result.current.error).toEqual(mockError); diff --git a/orchestrator/src/client/hooks/useSettings.ts b/orchestrator/src/client/hooks/useSettings.ts index f236154..5af7794 100644 --- a/orchestrator/src/client/hooks/useSettings.ts +++ b/orchestrator/src/client/hooks/useSettings.ts @@ -1,94 +1,31 @@ import type { AppSettings } from "@shared/types"; -import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { queryClient as appQueryClient } from "@/client/lib/queryClient"; +import { queryKeys } from "@/client/lib/queryKeys"; import * as api from "../api"; -let settingsCache: AppSettings | null = null; -let settingsError: Error | null = null; -const subscribers: Set< - (settings: AppSettings | null, error: Error | null) => void -> = new Set(); -let isFetching = false; - export function useSettings() { - const [settings, setSettings] = useState(settingsCache); - const [error, setError] = useState(settingsError); - - useEffect(() => { - if (settingsCache) { - setSettings(settingsCache); - } - if (settingsError) { - setError(settingsError); - } - - const handleUpdate = ( - newSettings: AppSettings | null, - newError: Error | null, - ) => { - setSettings(newSettings); - setError(newError); - }; - - subscribers.add(handleUpdate); - - if (!settingsCache && !isFetching) { - isFetching = true; - settingsError = null; - api - .getSettings() - .then((data) => { - settingsCache = data; - settingsError = null; - subscribers.forEach((sub) => { - sub(data, null); - }); - }) - .catch((err) => { - settingsError = err instanceof Error ? err : new Error(String(err)); - subscribers.forEach((sub) => { - sub(settingsCache, settingsError); - }); - }) - .finally(() => { - isFetching = false; - }); - } - - return () => { - subscribers.delete(handleUpdate); - }; - }, []); + const { + data: settings = null, + error, + isLoading, + isFetching, + refetch, + } = useQuery({ + queryKey: queryKeys.settings.current(), + queryFn: api.getSettings, + }); const refreshSettings = async () => { - isFetching = true; - settingsError = null; - subscribers.forEach((sub) => { - sub(settingsCache, null); - }); - - try { - const data = await api.getSettings(); - settingsCache = data; - settingsError = null; - subscribers.forEach((sub) => { - sub(data, null); - }); - return data; - } catch (err) { - settingsError = err instanceof Error ? err : new Error(String(err)); - subscribers.forEach((sub) => { - sub(settingsCache, settingsError); - }); - throw settingsError; - } finally { - isFetching = false; - } + const result = await refetch(); + if (result.error) throw result.error; + return result.data ?? null; }; return { settings, - error, - isLoading: !settings && isFetching && !error, + error: error ?? null, + isLoading: isLoading || (!!isFetching && !settings && !error), showSponsorInfo: settings?.showSponsorInfo ?? true, refreshSettings, }; @@ -96,8 +33,5 @@ export function useSettings() { /** @internal For testing only */ export function _resetSettingsCache() { - settingsCache = null; - settingsError = null; - isFetching = false; - subscribers.clear(); + appQueryClient.removeQueries({ queryKey: queryKeys.settings.all }); } diff --git a/orchestrator/src/client/hooks/useTracerReadiness.ts b/orchestrator/src/client/hooks/useTracerReadiness.ts index c492d83..ba597e4 100644 --- a/orchestrator/src/client/hooks/useTracerReadiness.ts +++ b/orchestrator/src/client/hooks/useTracerReadiness.ts @@ -1,101 +1,46 @@ import type { TracerReadinessResponse } from "@shared/types"; -import { useEffect, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { queryClient as appQueryClient } from "@/client/lib/queryClient"; +import { queryKeys } from "@/client/lib/queryKeys"; import * as api from "../api"; -let readinessCache: TracerReadinessResponse | null = null; -let readinessError: Error | null = null; -let isFetching = false; -const subscribers: Set< - ( - readiness: TracerReadinessResponse | null, - error: Error | null, - loading: boolean, - ) => void -> = new Set(); - -function notifySubscribers( - readiness: TracerReadinessResponse | null, - error: Error | null, - loading: boolean, -) { - for (const subscriber of subscribers) { - subscriber(readiness, error, loading); - } -} - -async function runReadinessFetch( - force: boolean, -): Promise { - isFetching = true; - readinessError = null; - notifySubscribers(readinessCache, null, true); - - try { - const data = await api.getTracerReadiness({ force }); - readinessCache = data; - readinessError = null; - notifySubscribers(data, null, false); - return data; - } catch (error) { - readinessError = error instanceof Error ? error : new Error(String(error)); - notifySubscribers(readinessCache, readinessError, false); - throw readinessError; - } finally { - isFetching = false; - } -} - export function useTracerReadiness() { - const [readiness, setReadiness] = useState( - readinessCache, - ); - const [error, setError] = useState(readinessError); - const [loading, setLoading] = useState( - !readinessCache && isFetching, - ); - - useEffect(() => { - if (readinessCache) setReadiness(readinessCache); - if (readinessError) setError(readinessError); - - const handleUpdate = ( - nextReadiness: TracerReadinessResponse | null, - nextError: Error | null, - nextLoading: boolean, - ) => { - setReadiness(nextReadiness); - setError(nextError); - setLoading(nextLoading); - }; - - subscribers.add(handleUpdate); - - if (!readinessCache && !isFetching) { - void runReadinessFetch(false); - } - - return () => { - subscribers.delete(handleUpdate); - }; - }, []); + const queryClient = useQueryClient(); + const { + data: readiness = null, + error, + isLoading, + isFetching, + refetch, + } = useQuery({ + queryKey: queryKeys.tracer.readiness(false), + queryFn: () => api.getTracerReadiness({ force: false }), + }); const refreshReadiness = async (force = true) => { - return await runReadinessFetch(force); + if (!force) { + const result = await refetch(); + if (result.error) throw result.error; + return result.data ?? null; + } + + const data = await api.getTracerReadiness({ force: true }); + await queryClient.invalidateQueries({ + queryKey: queryKeys.tracer.readiness(false), + }); + return data; }; return { readiness, - error, - isLoading: loading && !readiness, - isChecking: loading, + error: error ?? null, + isLoading: isLoading && !readiness, + isChecking: isFetching, refreshReadiness, }; } /** @internal For testing only */ export function _resetTracerReadinessCache() { - readinessCache = null; - readinessError = null; - isFetching = false; - subscribers.clear(); + appQueryClient.removeQueries({ queryKey: queryKeys.tracer.all }); } diff --git a/orchestrator/src/client/lib/queryClient.ts b/orchestrator/src/client/lib/queryClient.ts new file mode 100644 index 0000000..37c3d94 --- /dev/null +++ b/orchestrator/src/client/lib/queryClient.ts @@ -0,0 +1,18 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + gcTime: 10 * 60_000, + retry: 1, + refetchOnWindowFocus: false, + }, + mutations: { + retry: 0, + }, + }, + }); + +export const queryClient = createQueryClient(); diff --git a/orchestrator/src/client/lib/queryKeys.ts b/orchestrator/src/client/lib/queryKeys.ts new file mode 100644 index 0000000..6a1ced8 --- /dev/null +++ b/orchestrator/src/client/lib/queryKeys.ts @@ -0,0 +1,103 @@ +import type { JobStatus, PostApplicationProvider } from "@shared/types"; + +export const queryKeys = { + settings: { + all: ["settings"] as const, + current: () => [...queryKeys.settings.all, "current"] as const, + }, + profile: { + all: ["profile"] as const, + current: () => [...queryKeys.profile.all, "current"] as const, + }, + tracer: { + all: ["tracer"] as const, + readiness: (force = false) => + [...queryKeys.tracer.all, "readiness", { force }] as const, + analytics: (options?: { + from?: number; + to?: number; + includeBots?: boolean; + limit?: number; + }) => [...queryKeys.tracer.all, "analytics", options ?? {}] as const, + jobLinks: ( + jobId: string, + options?: { from?: number; to?: number; includeBots?: boolean }, + ) => [...queryKeys.tracer.all, "job-links", jobId, options ?? {}] as const, + }, + demo: { + all: ["demo"] as const, + info: () => [...queryKeys.demo.all, "info"] as const, + }, + jobs: { + all: ["jobs"] as const, + inProgressBoard: () => + [...queryKeys.jobs.all, "in-progress-board"] as const, + list: (options?: { statuses?: JobStatus[]; view?: "list" | "full" }) => + [...queryKeys.jobs.all, "list", options ?? {}] as const, + revision: (options?: { statuses?: JobStatus[] }) => + [...queryKeys.jobs.all, "revision", options ?? {}] as const, + detail: (id: string) => [...queryKeys.jobs.all, "detail", id] as const, + stageEvents: (id: string) => + [...queryKeys.jobs.all, "stage-events", id] as const, + tasks: (id: string) => [...queryKeys.jobs.all, "tasks", id] as const, + }, + pipeline: { + all: ["pipeline"] as const, + status: () => [...queryKeys.pipeline.all, "status"] as const, + }, + visaSponsors: { + all: ["visa-sponsors"] as const, + status: () => [...queryKeys.visaSponsors.all, "status"] as const, + search: (query: string, limit: number, minScore: number) => + [ + ...queryKeys.visaSponsors.all, + "search", + { query, limit, minScore }, + ] as const, + organization: (name: string) => + [...queryKeys.visaSponsors.all, "organization", name] as const, + }, + postApplication: { + all: ["post-application"] as const, + providerStatus: (provider: PostApplicationProvider, accountKey: string) => + [ + ...queryKeys.postApplication.all, + "provider-status", + { provider, accountKey }, + ] as const, + inbox: ( + provider: PostApplicationProvider, + accountKey: string, + limit: number, + ) => + [ + ...queryKeys.postApplication.all, + "inbox", + { provider, accountKey, limit }, + ] as const, + runs: ( + provider: PostApplicationProvider, + accountKey: string, + limit: number, + ) => + [ + ...queryKeys.postApplication.all, + "runs", + { provider, accountKey, limit }, + ] as const, + runMessages: ( + runId: string, + provider: PostApplicationProvider, + accountKey: string, + ) => + [ + ...queryKeys.postApplication.all, + "run-messages", + { runId, provider, accountKey }, + ] as const, + }, + backups: { + all: ["backups"] as const, + list: () => [...queryKeys.backups.all, "list"] as const, + }, +} as const; diff --git a/orchestrator/src/client/main.tsx b/orchestrator/src/client/main.tsx index 5c24bf3..ab6e72d 100644 --- a/orchestrator/src/client/main.tsx +++ b/orchestrator/src/client/main.tsx @@ -1,6 +1,8 @@ +import { QueryClientProvider } from "@tanstack/react-query"; import React from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; +import { queryClient } from "@/client/lib/queryClient"; import { App } from "./App"; import "../index.css"; @@ -9,8 +11,10 @@ if (!rootElement) throw new Error("Failed to find the root element"); ReactDOM.createRoot(rootElement).render( - - - + + + + + , ); diff --git a/orchestrator/src/client/pages/HomePage.tsx b/orchestrator/src/client/pages/HomePage.tsx index cc57493..3bc0693 100644 --- a/orchestrator/src/client/pages/HomePage.tsx +++ b/orchestrator/src/client/pages/HomePage.tsx @@ -7,10 +7,12 @@ import { } from "@client/components/charts"; import { PageHeader, PageMain } from "@client/components/layout"; import type { StageEvent } from "@shared/types.js"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChartColumn } from "lucide-react"; import type React from "react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; +import { queryKeys } from "@/client/lib/queryKeys"; type JobWithEvents = { id: string; @@ -24,11 +26,8 @@ const DURATION_OPTIONS = [7, 14, 30, 90] as const; const DEFAULT_DURATION = 30; export const HomePage: React.FC = () => { + const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); - const [jobsWithEvents, setJobsWithEvents] = useState([]); - const [appliedDates, setAppliedDates] = useState>([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); // Read initial duration from URL const initialDuration: DurationValue = (() => { @@ -42,70 +41,72 @@ export const HomePage: React.FC = () => { const [duration, setDuration] = useState(initialDuration); - useEffect(() => { - let isMounted = true; - setIsLoading(true); - - api - .getJobs({ + const overviewQuery = useQuery({ + queryKey: queryKeys.jobs.list({ + statuses: ["applied", "in_progress"], + view: "list", + }), + queryFn: async () => { + const response = await api.getJobs({ statuses: ["applied", "in_progress"], view: "list", - }) - .then(async (response) => { - if (!isMounted) return; - const appliedDates = response.jobs.map((job) => job.appliedAt); - const jobSummaries = response.jobs.map((job) => ({ - id: job.id, - datePosted: job.datePosted, - discoveredAt: job.discoveredAt, - appliedAt: job.appliedAt, - positiveResponse: false, - })); + }); + const appliedDates = response.jobs.map((job) => job.appliedAt); + const jobSummaries = response.jobs.map((job) => ({ + id: job.id, + datePosted: job.datePosted, + discoveredAt: job.discoveredAt, + appliedAt: job.appliedAt, + positiveResponse: false, + })); - const appliedJobs = jobSummaries.filter((job) => job.appliedAt); - const results = await Promise.allSettled( - appliedJobs.map((job) => api.getJobStageEvents(job.id)), - ); - const eventsMap = new Map(); + const appliedJobs = jobSummaries.filter((job) => job.appliedAt); + const results = await Promise.allSettled( + appliedJobs.map((job) => + queryClient.fetchQuery({ + queryKey: queryKeys.jobs.stageEvents(job.id), + queryFn: () => api.getJobStageEvents(job.id), + staleTime: 0, + }), + ), + ); + const eventsMap = new Map(); - results.forEach((result, index) => { - const jobId = appliedJobs[index]?.id; - if (!jobId) return; - if (result.status !== "fulfilled") { - eventsMap.set(jobId, []); - return; - } - eventsMap.set(jobId, result.value); - }); - - const resolvedJobsWithEvents: JobWithEvents[] = jobSummaries - .filter((job) => job.appliedAt) - .map((job) => ({ - ...job, - events: eventsMap.get(job.id) ?? [], - })); - - setJobsWithEvents(resolvedJobsWithEvents); - setAppliedDates(appliedDates); - setError(null); - }) - .catch((fetchError) => { - if (!isMounted) return; - const message = - fetchError instanceof Error - ? fetchError.message - : "Failed to load applications"; - setError(message); - }) - .finally(() => { - if (!isMounted) return; - setIsLoading(false); + results.forEach((result, index) => { + const jobId = appliedJobs[index]?.id; + if (!jobId) return; + if (result.status !== "fulfilled") { + eventsMap.set(jobId, []); + return; + } + eventsMap.set(jobId, result.value); }); - return () => { - isMounted = false; - }; - }, []); + const jobsWithEvents: JobWithEvents[] = jobSummaries + .filter((job) => job.appliedAt) + .map((job) => ({ + ...job, + events: eventsMap.get(job.id) ?? [], + })); + + return { jobsWithEvents, appliedDates }; + }, + }); + + const jobsWithEvents = useMemo( + () => overviewQuery.data?.jobsWithEvents ?? [], + [overviewQuery.data], + ); + const appliedDates = useMemo( + () => overviewQuery.data?.appliedDates ?? [], + [overviewQuery.data], + ); + const error = overviewQuery.error + ? overviewQuery.error instanceof Error + ? overviewQuery.error.message + : "Failed to load applications" + : null; + const isLoading = overviewQuery.isLoading; const handleDurationChange = useCallback( (newDuration: DurationValue) => { diff --git a/orchestrator/src/client/pages/InProgressBoardPage.test.tsx b/orchestrator/src/client/pages/InProgressBoardPage.test.tsx index 1ddeb2e..a04798e 100644 --- a/orchestrator/src/client/pages/InProgressBoardPage.test.tsx +++ b/orchestrator/src/client/pages/InProgressBoardPage.test.tsx @@ -1,11 +1,15 @@ import type { JobListItem, StageEvent } from "@shared/types"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { toast } from "sonner"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../api"; +import { renderWithQueryClient } from "../test/renderWithQueryClient"; import { InProgressBoardPage } from "./InProgressBoardPage"; +const render = (ui: Parameters[0]) => + renderWithQueryClient(ui); + vi.mock("../api", () => ({ getJobs: vi.fn(), getJobStageEvents: vi.fn(), diff --git a/orchestrator/src/client/pages/InProgressBoardPage.tsx b/orchestrator/src/client/pages/InProgressBoardPage.tsx index 5e8fca2..8dd9f6d 100644 --- a/orchestrator/src/client/pages/InProgressBoardPage.tsx +++ b/orchestrator/src/client/pages/InProgressBoardPage.tsx @@ -6,10 +6,13 @@ import { STAGE_LABELS, type StageEvent, } from "@shared/types.js"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDownAZ, Columns3, ExternalLink, Plus } from "lucide-react"; import React from "react"; import { Link, useNavigate } from "react-router-dom"; import { toast } from "sonner"; +import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast"; +import { queryKeys } from "@/client/lib/queryKeys"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -76,9 +79,9 @@ const resolveCurrentStage = ( }; export const InProgressBoardPage: React.FC = () => { + const queryClient = useQueryClient(); const navigate = useNavigate(); - const [cards, setCards] = React.useState([]); - const [isLoading, setIsLoading] = React.useState(true); + const [dragging, setDragging] = React.useState<{ jobId: string; fromStage: ApplicationStage; @@ -90,9 +93,9 @@ export const InProgressBoardPage: React.FC = () => { "updated" | "title" | "company" >("updated"); - const loadBoard = React.useCallback(async () => { - try { - setIsLoading(true); + const boardQuery = useQuery({ + queryKey: queryKeys.jobs.inProgressBoard(), + queryFn: async () => { const response = await api.getJobs({ statuses: ["in_progress"], view: "list", @@ -103,7 +106,7 @@ export const InProgressBoardPage: React.FC = () => { jobs.map((job) => api.getJobStageEvents(job.id)), ); - const nextCards = jobs.map((job, index) => { + return jobs.map((job, index) => { const result = eventResults[index]; const events = result?.status === "fulfilled" @@ -116,22 +119,31 @@ export const InProgressBoardPage: React.FC = () => { latestEventAt: resolved.latestEventAt, }; }); + }, + }); - setCards(nextCards); - } catch (error) { - const message = - error instanceof Error - ? error.message - : "Failed to load in-progress board"; - toast.error(message); - } finally { - setIsLoading(false); - } - }, []); + const transitionMutation = useMutation({ + mutationFn: ({ + jobId, + toStage, + }: { + jobId: string; + toStage: ApplicationStage; + }) => + api.transitionJobStage(jobId, { + toStage, + metadata: { + actor: "user", + eventType: "status_update", + eventLabel: `Moved to ${STAGE_LABELS[toStage]}`, + }, + }), + }); - React.useEffect(() => { - void loadBoard(); - }, [loadBoard]); + useQueryErrorToast(boardQuery.error, "Failed to load in-progress board"); + + const cards = boardQuery.data ?? []; + const isLoading = boardQuery.isPending; const lanes = React.useMemo(() => { const sortFn = @@ -170,31 +182,34 @@ export const InProgressBoardPage: React.FC = () => { } const { jobId } = dragging; - const previousCards = cards; + const previousCards = + queryClient.getQueryData( + queryKeys.jobs.inProgressBoard(), + ) ?? []; const nowEpoch = Math.floor(Date.now() / 1000); setMovingJobId(jobId); - setCards((current) => - current.map((card) => - card.job.id === jobId - ? { ...card, stage: toStage, latestEventAt: nowEpoch } - : card, - ), + queryClient.setQueryData( + queryKeys.jobs.inProgressBoard(), + (current) => + (current ?? []).map((card) => + card.job.id === jobId + ? { ...card, stage: toStage, latestEventAt: nowEpoch } + : card, + ), ); try { - await api.transitionJobStage(jobId, { - toStage, - metadata: { - actor: "user", - eventType: "status_update", - eventLabel: `Moved to ${STAGE_LABELS[toStage]}`, - }, - }); + await transitionMutation.mutateAsync({ jobId, toStage }); toast.success(`Moved to ${STAGE_LABELS[toStage]}`); - await loadBoard(); + await queryClient.invalidateQueries({ + queryKey: queryKeys.jobs.inProgressBoard(), + }); } catch (error) { - setCards(previousCards); + queryClient.setQueryData( + queryKeys.jobs.inProgressBoard(), + previousCards, + ); const message = error instanceof Error ? error.message : "Failed to move stage"; toast.error(message); @@ -204,7 +219,7 @@ export const InProgressBoardPage: React.FC = () => { setDropTargetStage(null); } }, - [cards, dragging, loadBoard], + [dragging, queryClient, transitionMutation], ); return ( diff --git a/orchestrator/src/client/pages/JobPage.tsx b/orchestrator/src/client/pages/JobPage.tsx index 23d3bed..2641259 100644 --- a/orchestrator/src/client/pages/JobPage.tsx +++ b/orchestrator/src/client/pages/JobPage.tsx @@ -6,6 +6,7 @@ import { STAGE_LABELS, type StageEvent, } from "@shared/types.js"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import confetti from "canvas-confetti"; import { ArrowLeft, @@ -26,6 +27,17 @@ import { import React from "react"; import { useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; +import { invalidateJobData } from "@/client/hooks/queries/invalidate"; +import { + useCheckSponsorMutation, + useGenerateJobPdfMutation, + useMarkAsAppliedMutation, + useRescoreJobMutation, + useSkipJobMutation, + useUpdateJobMutation, +} from "@/client/hooks/queries/useJobMutations"; +import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast"; +import { queryKeys } from "@/client/lib/queryKeys"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -55,10 +67,7 @@ import { JobTimeline } from "./job/Timeline"; export const JobPage: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const [job, setJob] = React.useState(null); - const [events, setEvents] = React.useState([]); - const [tasks, setTasks] = React.useState([]); - const [isLoading, setIsLoading] = React.useState(true); + const queryClient = useQueryClient(); const [isLogModalOpen, setIsLogModalOpen] = React.useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false); const [isEditDetailsOpen, setIsEditDetailsOpen] = React.useState(false); @@ -69,30 +78,58 @@ export const JobPage: React.FC = () => { ); const pendingEventRef = React.useRef(null); + const jobQuery = useQuery({ + queryKey: ["jobs", "detail", id ?? null] as const, + queryFn: () => (id ? api.getJob(id) : Promise.resolve(null)), + enabled: Boolean(id), + }); + const eventsQuery = useQuery({ + queryKey: ["jobs", "stage-events", id ?? null] as const, + queryFn: () => (id ? api.getJobStageEvents(id) : Promise.resolve([])), + enabled: Boolean(id), + }); + const tasksQuery = useQuery({ + queryKey: ["jobs", "tasks", id ?? null] as const, + queryFn: () => (id ? api.getJobTasks(id) : Promise.resolve([])), + enabled: Boolean(id), + }); + + useQueryErrorToast( + jobQuery.error, + "Failed to load job details. Please try again.", + ); + useQueryErrorToast( + eventsQuery.error, + "Failed to load job timeline. Please try again.", + ); + useQueryErrorToast( + tasksQuery.error, + "Failed to load job tasks. Please try again.", + ); + + const markAsAppliedMutation = useMarkAsAppliedMutation(); + const updateJobMutation = useUpdateJobMutation(); + const skipJobMutation = useSkipJobMutation(); + const rescoreJobMutation = useRescoreJobMutation(); + const generatePdfMutation = useGenerateJobPdfMutation(); + const checkSponsorMutation = useCheckSponsorMutation(); + + const job = jobQuery.data ?? null; + const events = mergeEvents(eventsQuery.data ?? [], pendingEventRef.current); + const tasks = tasksQuery.data ?? []; + const isLoading = + jobQuery.isLoading || eventsQuery.isLoading || tasksQuery.isLoading; + const loadData = React.useCallback(async () => { if (!id) return; - setIsLoading(true); - try { - const jobData = await api.getJob(id); - setJob(jobData); - - api - .getJobStageEvents(id) - .then((data) => setEvents(mergeEvents(data, pendingEventRef.current))) - .catch(() => toast.error("Failed to load stage events")); - - api - .getJobTasks(id) - .then((data) => setTasks(data)) - .catch(() => toast.error("Failed to load tasks")); - } finally { - setIsLoading(false); - } - }, [id]); - - React.useEffect(() => { - loadData(); - }, [loadData]); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.jobs.detail(id) }), + queryClient.invalidateQueries({ + queryKey: queryKeys.jobs.stageEvents(id), + }), + queryClient.invalidateQueries({ queryKey: queryKeys.jobs.tasks(id) }), + ]); + }, [id, queryClient]); const handleLogEvent = async ( values: LogEventFormValues, @@ -153,12 +190,7 @@ export const JobPage: React.FC = () => { pendingEventRef.current = newEvent; } - const [jobData, eventData] = await Promise.all([ - api.getJob(job.id), - api.getJobStageEvents(job.id), - ]); - setJob(jobData); - setEvents(eventData); + await invalidateJobData(queryClient, job.id); pendingEventRef.current = null; setEditingEvent(null); toast.success(eventId ? "Event updated" : "Event logged"); @@ -172,8 +204,9 @@ export const JobPage: React.FC = () => { }); } } catch (error) { - console.error("Failed to log event:", error); - toast.error("Failed to log event"); + const message = + error instanceof Error ? error.message : "Failed to log event"; + toast.error(message); } }; @@ -186,16 +219,12 @@ export const JobPage: React.FC = () => { if (!job || !eventToDelete) return; try { await api.deleteJobStageEvent(job.id, eventToDelete); - const [jobData, eventData] = await Promise.all([ - api.getJob(job.id), - api.getJobStageEvents(job.id), - ]); - setJob(jobData); - setEvents(eventData); + await invalidateJobData(queryClient, job.id); toast.success("Event deleted"); } catch (error) { - console.error("Failed to delete event:", error); - toast.error("Failed to delete event"); + const message = + error instanceof Error ? error.message : "Failed to delete event"; + toast.error(message); } finally { setIsDeleteModalOpen(false); setEventToDelete(null); @@ -228,7 +257,7 @@ export const JobPage: React.FC = () => { const handleMarkApplied = async () => { await runAction("mark-applied", async () => { if (!job) return; - await api.markAsApplied(job.id); + await markAsAppliedMutation.mutateAsync(job.id); toast.success("Marked as applied"); }); }; @@ -236,7 +265,10 @@ export const JobPage: React.FC = () => { const handleMoveToInProgress = async () => { await runAction("move-in-progress", async () => { if (!job) return; - await api.updateJob(job.id, { status: "in_progress" }); + await updateJobMutation.mutateAsync({ + id: job.id, + update: { status: "in_progress" }, + }); toast.success("Moved to in progress"); }); }; @@ -244,7 +276,7 @@ export const JobPage: React.FC = () => { const handleSkip = async () => { await runAction("skip", async () => { if (!job) return; - await api.skipJob(job.id); + await skipJobMutation.mutateAsync(job.id); toast.message("Job skipped"); }); }; @@ -252,7 +284,7 @@ export const JobPage: React.FC = () => { const handleRescore = async () => { await runAction("rescore", async () => { if (!job) return; - await api.rescoreJob(job.id); + await rescoreJobMutation.mutateAsync(job.id); toast.success("Match recalculated"); }); }; @@ -260,7 +292,7 @@ export const JobPage: React.FC = () => { const handleRegeneratePdf = async () => { await runAction("regenerate-pdf", async () => { if (!job) return; - await api.generateJobPdf(job.id); + await generatePdfMutation.mutateAsync(job.id); toast.success("Resume PDF generated"); }); }; @@ -268,7 +300,7 @@ export const JobPage: React.FC = () => { const handleCheckSponsor = async () => { await runAction("check-sponsor", async () => { if (!job) return; - await api.checkSponsor(job.id); + await checkSponsorMutation.mutateAsync(job.id); toast.success("Sponsor check completed"); }); }; diff --git a/orchestrator/src/client/pages/OrchestratorPage.test.tsx b/orchestrator/src/client/pages/OrchestratorPage.test.tsx index 13f6892..3317ffc 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.test.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.test.tsx @@ -1,12 +1,16 @@ import { createJob } from "@shared/testing/factories.js"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { toast } from "sonner"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../api"; +import { renderWithQueryClient } from "../test/renderWithQueryClient"; import { OrchestratorPage } from "./OrchestratorPage"; import type { FilterTab } from "./orchestrator/constants"; +const render = (ui: Parameters[0]) => + renderWithQueryClient(ui); + const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index 42c0a7c..364a04b 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -1,12 +1,16 @@ import { createAppSettings } from "@shared/testing/factories.js"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { toast } from "sonner"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../api"; import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness"; +import { renderWithQueryClient } from "../test/renderWithQueryClient"; import { SettingsPage } from "./SettingsPage"; +const render = (ui: Parameters[0]) => + renderWithQueryClient(ui); + vi.mock("../api", () => ({ getSettings: vi.fn(), updateSettings: vi.fn(), diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 30ccb63..a91ddd6 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -1,5 +1,6 @@ import * as api from "@client/api"; import { PageHeader } from "@client/components/layout"; +import { useUpdateSettingsMutation } from "@client/hooks/queries/useSettingsMutation"; import { useTracerReadiness } from "@client/hooks/useTracerReadiness"; import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection"; import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection"; @@ -23,16 +24,18 @@ import { } from "@shared/settings-schema.js"; import type { AppSettings, - BackupInfo, JobStatus, ResumeProjectCatalogItem, ResumeProjectsSettings, } from "@shared/types.js"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Settings } from "lucide-react"; import type React from "react"; import { useCallback, useEffect, useState } from "react"; import { FormProvider, type Resolver, useForm } from "react-hook-form"; import { toast } from "sonner"; +import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast"; +import { queryKeys } from "@/client/lib/queryKeys"; import { Accordion } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; @@ -293,9 +296,9 @@ const getDerivedSettings = (settings: AppSettings | null) => { }; export const SettingsPage: React.FC = () => { + const queryClient = useQueryClient(); const [settings, setSettings] = useState(null); const [isSaving, setIsSaving] = useState(false); - const [isLoading, setIsLoading] = useState(true); const [statusesToClear, setStatusesToClear] = useState([ "discovered", ]); @@ -309,9 +312,6 @@ export const SettingsPage: React.FC = () => { useState(false); // Backup state - const [backups, setBackups] = useState([]); - const [nextScheduled, setNextScheduled] = useState(null); - const [isLoadingBackups, setIsLoadingBackups] = useState(false); const [isCreatingBackup, setIsCreatingBackup] = useState(false); const [isDeletingBackup, setIsDeletingBackup] = useState(false); const { @@ -339,34 +339,32 @@ export const SettingsPage: React.FC = () => { formState: { isDirty, errors, isValid, dirtyFields }, } = methods; + const settingsQuery = useQuery({ + queryKey: queryKeys.settings.current(), + queryFn: api.getSettings, + }); + const backupsQuery = useQuery({ + queryKey: queryKeys.backups.list(), + queryFn: api.getBackups, + }); + const updateSettingsMutation = useUpdateSettingsMutation(); + const isLoading = settingsQuery.isLoading; + const backups = backupsQuery.data?.backups ?? []; + const nextScheduled = backupsQuery.data?.nextScheduled ?? null; + const isLoadingBackups = backupsQuery.isLoading; + useQueryErrorToast(backupsQuery.error, "Failed to load backups"); + const hasRxResumeAccess = Boolean( settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint, ); useEffect(() => { - let isMounted = true; - setIsLoading(true); - api - .getSettings() - .then((data) => { - if (!isMounted) return; - setSettings(data); - reset(mapSettingsToForm(data)); - }) - .catch((error) => { - const message = - error instanceof Error ? error.message : "Failed to load settings"; - toast.error(message); - }) - .finally(() => { - if (!isMounted) return; - setIsLoading(false); - }); + if (!settingsQuery.data) return; + setSettings(settingsQuery.data); + reset(mapSettingsToForm(settingsQuery.data)); + }, [settingsQuery.data, reset]); - return () => { - isMounted = false; - }; - }, [reset]); + useQueryErrorToast(settingsQuery.error, "Failed to load settings"); useEffect(() => { if (!settings) return; @@ -442,28 +440,12 @@ export const SettingsPage: React.FC = () => { scoring, } = derived; - // Backup functions - const loadBackups = useCallback(async () => { - setIsLoadingBackups(true); - try { - const response = await api.getBackups(); - setBackups(response.backups); - setNextScheduled(response.nextScheduled); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to load backups"; - toast.error(message); - } finally { - setIsLoadingBackups(false); - } - }, []); - const handleCreateBackup = async () => { setIsCreatingBackup(true); try { await api.createManualBackup(); toast.success("Backup created successfully"); - await loadBackups(); + await queryClient.invalidateQueries({ queryKey: queryKeys.backups.all }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to create backup"; @@ -484,7 +466,7 @@ export const SettingsPage: React.FC = () => { try { await api.deleteBackup(filename); toast.success("Backup deleted successfully"); - await loadBackups(); + await queryClient.invalidateQueries({ queryKey: queryKeys.backups.all }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to delete backup"; @@ -497,7 +479,9 @@ export const SettingsPage: React.FC = () => { const handleVerifyTracerReadiness = useCallback(async () => { try { const readiness = await refreshReadiness(true); - if (readiness.canEnable) { + if (!readiness) { + toast.error("Tracer links are unavailable. Verify your public URL."); + } else if (readiness.canEnable) { toast.success("Tracer links are ready"); } else { toast.error( @@ -514,13 +498,6 @@ export const SettingsPage: React.FC = () => { } }, [refreshReadiness]); - // Load backups when settings are loaded - useEffect(() => { - if (settings) { - loadBackups(); - } - }, [settings, loadBackups]); - const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects; const effectiveMaxProjectsTotal = effectiveProfileProjects.length; @@ -658,7 +635,7 @@ export const SettingsPage: React.FC = () => { // need to track it so that the save button is enabled when it changes delete payload.enableBasicAuth; - const updated = await api.updateSettings(payload); + const updated = await updateSettingsMutation.mutateAsync(payload); setSettings(updated); reset(mapSettingsToForm(updated)); toast.success("Settings saved"); @@ -758,7 +735,9 @@ export const SettingsPage: React.FC = () => { const handleReset = async () => { try { setIsSaving(true); - const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD); + const updated = await updateSettingsMutation.mutateAsync( + NULL_SETTINGS_PAYLOAD, + ); setSettings(updated); reset(mapSettingsToForm(updated)); toast.success("Reset to default"); diff --git a/orchestrator/src/client/pages/TracerLinksPage.test.tsx b/orchestrator/src/client/pages/TracerLinksPage.test.tsx new file mode 100644 index 0000000..2c265af --- /dev/null +++ b/orchestrator/src/client/pages/TracerLinksPage.test.tsx @@ -0,0 +1,159 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as api from "../api"; +import { renderWithQueryClient } from "../test/renderWithQueryClient"; +import { TracerLinksPage } from "./TracerLinksPage"; + +const render = (ui: Parameters[0]) => + renderWithQueryClient(ui); + +vi.mock("../api", () => ({ + getTracerAnalytics: vi.fn(), + getJobTracerLinks: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(api.getTracerAnalytics).mockResolvedValue({ + filters: { + jobId: null, + from: null, + to: null, + includeBots: false, + limit: 20, + }, + totals: { + clicks: 12, + uniqueOpens: 10, + botClicks: 3, + humanClicks: 9, + }, + timeSeries: [ + { + day: "2026-02-01", + clicks: 12, + uniqueOpens: 10, + botClicks: 3, + humanClicks: 9, + }, + ], + topJobs: [ + { + jobId: "job-1", + title: "Backend Engineer", + employer: "Acme", + clicks: 7, + uniqueOpens: 6, + botClicks: 2, + humanClicks: 5, + lastClickedAt: 1_700_000_000, + }, + ], + topLinks: [ + { + tracerLinkId: "tl-1", + token: "token-1", + jobId: "job-1", + title: "Backend Engineer", + employer: "Acme", + sourcePath: "resume.pdf", + sourceLabel: "Resume", + destinationUrl: "https://example.com/apply", + clicks: 7, + uniqueOpens: 6, + botClicks: 2, + humanClicks: 5, + lastClickedAt: 1_700_000_000, + }, + ], + }); + + vi.mocked(api.getJobTracerLinks).mockResolvedValue({ + job: { + id: "job-1", + title: "Backend Engineer", + employer: "Acme", + tracerLinksEnabled: true, + }, + totals: { + links: 1, + clicks: 7, + uniqueOpens: 6, + botClicks: 2, + humanClicks: 5, + }, + links: [ + { + tracerLinkId: "tl-1", + token: "token-1", + sourcePath: "resume.pdf", + sourceLabel: "Resume", + destinationUrl: "https://example.com/apply", + isActive: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + clicks: 7, + uniqueOpens: 6, + botClicks: 2, + humanClicks: 5, + lastClickedAt: 1_700_000_000, + }, + ], + }); +}); + +describe("TracerLinksPage", () => { + it("renders analytics cards and top job rows", async () => { + render( + + + , + ); + + expect(await screen.findByText("Backend Engineer")).toBeInTheDocument(); + expect(screen.getByText("12")).toBeInTheDocument(); + expect(screen.getByText("9")).toBeInTheDocument(); + }); + + it("loads job drilldown when selecting a top job", async () => { + render( + + + , + ); + + const row = await screen.findByRole("row", { name: /Backend Engineer/i }); + fireEvent.click(row); + + await waitFor(() => { + expect(api.getJobTracerLinks).toHaveBeenCalledWith( + "job-1", + expect.objectContaining({ includeBots: false }), + ); + }); + + expect( + await screen.findByText(/Job Links: Backend Engineer/), + ).toBeInTheDocument(); + }); + + it("refetches analytics when include bots filter changes", async () => { + render( + + + , + ); + + fireEvent.click(await screen.findByRole("button", { name: "Filters" })); + const includeBotsToggle = await screen.findByText("Include likely bots"); + fireEvent.click(includeBotsToggle); + + await waitFor(() => { + expect(api.getTracerAnalytics).toHaveBeenCalledWith( + expect.objectContaining({ includeBots: true, limit: 20 }), + ); + }); + }); +}); diff --git a/orchestrator/src/client/pages/TracerLinksPage.tsx b/orchestrator/src/client/pages/TracerLinksPage.tsx index 534437a..ef4041f 100644 --- a/orchestrator/src/client/pages/TracerLinksPage.tsx +++ b/orchestrator/src/client/pages/TracerLinksPage.tsx @@ -2,15 +2,16 @@ import * as api from "@client/api"; import { PageHeader, PageMain, SectionCard } from "@client/components/layout"; import type { JobTracerLinkAnalyticsItem, - JobTracerLinksResponse, TracerAnalyticsResponse, TracerAnalyticsTopJob, } from "@shared/types.js"; +import { useQuery } from "@tanstack/react-query"; import { BarChart3, Copy, ExternalLink, Loader2 } from "lucide-react"; import type React from "react"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { toast } from "sonner"; +import { queryKeys } from "@/client/lib/queryKeys"; import { Accordion, AccordionContent, @@ -113,19 +114,14 @@ function formatRelativeTime(value: number | null): string { } export const TracerLinksPage: React.FC = () => { - const [analytics, setAnalytics] = useState( - null, - ); - const [jobDrilldown, setJobDrilldown] = - useState(null); + const [selectedDrilldownJobId, setSelectedDrilldownJobId] = useState< + string | null + >(null); const [fromDate, setFromDate] = useState(""); const [toDate, setToDate] = useState(""); const [includeBots, setIncludeBots] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [isDrilldownLoading, setIsDrilldownLoading] = useState(false); const [isDrilldownOpen, setIsDrilldownOpen] = useState(false); const [drilldownMode, setDrilldownMode] = useState<"human" | "all">("human"); - const [error, setError] = useState(null); const query = useMemo( () => ({ @@ -137,62 +133,36 @@ export const TracerLinksPage: React.FC = () => { [fromDate, toDate, includeBots], ); - const loadJobDrilldown = async (targetJobId: string) => { - if (!targetJobId) { - setError("Enter a Job ID to load link drilldown."); - setJobDrilldown(null); - return; - } + const analyticsQuery = useQuery({ + queryKey: queryKeys.tracer.analytics(query), + queryFn: () => api.getTracerAnalytics(query), + }); + const analytics = analyticsQuery.data ?? null; + const isLoading = analyticsQuery.isPending; - try { - setIsDrilldownLoading(true); - setError(null); - const response = await api.getJobTracerLinks(targetJobId, { + const jobDrilldownQuery = useQuery({ + queryKey: queryKeys.tracer.jobLinks(selectedDrilldownJobId ?? "", { + from: query.from, + to: query.to, + includeBots, + }), + queryFn: () => + api.getJobTracerLinks(selectedDrilldownJobId ?? "", { from: query.from, to: query.to, includeBots, - }); - setJobDrilldown(response); - } catch (fetchError) { - const message = - fetchError instanceof Error - ? fetchError.message - : "Failed to load job tracer links."; - setError(message); - setJobDrilldown(null); - } finally { - setIsDrilldownLoading(false); - } - }; - - useEffect(() => { - let isMounted = true; - setIsLoading(true); - setError(null); - - api - .getTracerAnalytics(query) - .then((response) => { - if (!isMounted) return; - setAnalytics(response); - }) - .catch((fetchError) => { - if (!isMounted) return; - const message = - fetchError instanceof Error - ? fetchError.message - : "Failed to load tracer analytics."; - setError(message); - }) - .finally(() => { - if (!isMounted) return; - setIsLoading(false); - }); - - return () => { - isMounted = false; - }; - }, [query]); + }), + enabled: Boolean(isDrilldownOpen && selectedDrilldownJobId), + }); + const jobDrilldown = jobDrilldownQuery.data ?? null; + const isDrilldownLoading = + jobDrilldownQuery.isPending || jobDrilldownQuery.isFetching; + const error = + analyticsQuery.error instanceof Error + ? analyticsQuery.error.message + : jobDrilldownQuery.error instanceof Error + ? jobDrilldownQuery.error.message + : null; const chartData = analytics?.timeSeries ?? []; const totalViews = analytics?.totals.clicks ?? 0; @@ -271,8 +241,8 @@ export const TracerLinksPage: React.FC = () => { drilldownMode === "human" ? row.humanClicks : row.clicks; const handleSelectTopJob = (job: TracerAnalyticsTopJob) => { + setSelectedDrilldownJobId(job.jobId); setIsDrilldownOpen(true); - void loadJobDrilldown(job.jobId); }; return ( diff --git a/orchestrator/src/client/pages/TrackingInboxPage.test.tsx b/orchestrator/src/client/pages/TrackingInboxPage.test.tsx index 5419b45..cd04e07 100644 --- a/orchestrator/src/client/pages/TrackingInboxPage.test.tsx +++ b/orchestrator/src/client/pages/TrackingInboxPage.test.tsx @@ -1,9 +1,13 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../api"; +import { renderWithQueryClient } from "../test/renderWithQueryClient"; import { TrackingInboxPage } from "./TrackingInboxPage"; +const render = (ui: Parameters[0]) => + renderWithQueryClient(ui); + vi.mock("../api", () => ({ postApplicationProviderStatus: vi.fn(), getPostApplicationInbox: vi.fn(), diff --git a/orchestrator/src/client/pages/TrackingInboxPage.tsx b/orchestrator/src/client/pages/TrackingInboxPage.tsx index 700f813..f490841 100644 --- a/orchestrator/src/client/pages/TrackingInboxPage.tsx +++ b/orchestrator/src/client/pages/TrackingInboxPage.tsx @@ -5,6 +5,7 @@ import type { PostApplicationSyncRun, } from "@shared/types"; import { POST_APPLICATION_PROVIDERS } from "@shared/types"; +import { useQuery } from "@tanstack/react-query"; import { CheckCircle, Inbox, @@ -18,6 +19,8 @@ import { import type React from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast"; +import { queryKeys } from "@/client/lib/queryKeys"; import { AlertDialog, AlertDialogAction, @@ -57,6 +60,8 @@ const PROVIDER_OPTIONS: PostApplicationProvider[] = [ ]; const GMAIL_OAUTH_RESULT_TYPE = "gmail-oauth-result"; const GMAIL_OAUTH_TIMEOUT_MS = 3 * 60 * 1000; +const EMPTY_INBOX_ITEMS: PostApplicationInboxItem[] = []; +const EMPTY_SYNC_RUNS: PostApplicationSyncRun[] = []; type GmailOauthResultMessage = { type: string; @@ -76,81 +81,106 @@ export const TrackingInboxPage: React.FC = () => { const [maxMessages, setMaxMessages] = useState("100"); const [searchDays, setSearchDays] = useState("90"); - const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); const [isActionLoading, setIsActionLoading] = useState(false); const [activeAction, setActiveAction] = useState< "connect" | "sync" | "disconnect" | null >(null); - const [status, setStatus] = useState< - | Awaited>["status"] - | null - >(null); - const [inbox, setInbox] = useState([]); - const [runs, setRuns] = useState([]); const [isRunModalOpen, setIsRunModalOpen] = useState(false); - const [isRunMessagesLoading, setIsRunMessagesLoading] = useState(false); const [selectedRun, setSelectedRun] = useState( null, ); - const [selectedRunItems, setSelectedRunItems] = useState< - PostApplicationInboxItem[] - >([]); const [appliedJobByMessageId, setAppliedJobByMessageId] = useState< Record >({}); - const [appliedJobs, setAppliedJobs] = useState([]); - const [isAppliedJobsLoading, setIsAppliedJobsLoading] = useState(false); - const [hasAttemptedAppliedJobsLoad, setHasAttemptedAppliedJobsLoad] = - useState(false); + const statusQuery = useQuery({ + queryKey: queryKeys.postApplication.providerStatus(provider, accountKey), + queryFn: () => api.postApplicationProviderStatus({ provider, accountKey }), + enabled: Boolean(provider && accountKey), + }); + const inboxQuery = useQuery({ + queryKey: queryKeys.postApplication.inbox(provider, accountKey, 100), + queryFn: () => + api.getPostApplicationInbox({ provider, accountKey, limit: 100 }), + enabled: Boolean(provider && accountKey), + }); + const runsQuery = useQuery({ + queryKey: queryKeys.postApplication.runs(provider, accountKey, 20), + queryFn: () => + api.getPostApplicationRuns({ provider, accountKey, limit: 20 }), + enabled: Boolean(provider && accountKey), + }); + + const status = statusQuery.data?.status ?? null; + const inbox = inboxQuery.data?.items ?? EMPTY_INBOX_ITEMS; + const runs = runsQuery.data?.runs ?? EMPTY_SYNC_RUNS; + + const runMessagesQuery = useQuery({ + queryKey: queryKeys.postApplication.runMessages( + selectedRun?.id ?? "", + provider, + accountKey, + ), + queryFn: () => + api.getPostApplicationRunMessages({ + runId: selectedRun?.id ?? "", + provider, + accountKey, + }), + enabled: Boolean( + isRunModalOpen && selectedRun?.id && provider && accountKey, + ), + }); + const selectedRunItems = runMessagesQuery.data?.items ?? EMPTY_INBOX_ITEMS; + const isRunMessagesLoading = + runMessagesQuery.isPending || runMessagesQuery.isFetching; + + const hasReviewItems = useMemo( + () => inbox.length > 0 || selectedRunItems.length > 0, + [inbox.length, selectedRunItems.length], + ); + + const appliedJobsQuery = useQuery({ + queryKey: queryKeys.jobs.list({ + statuses: ["applied", "in_progress"], + view: "list", + }), + queryFn: () => + api.getJobs({ + statuses: ["applied", "in_progress"], + view: "list", + }), + enabled: hasReviewItems, + }); + const appliedJobs = useMemo( + () => + (appliedJobsQuery.data?.jobs ?? []).filter( + (job) => job.status === "applied" || job.status === "in_progress", + ), + [appliedJobsQuery.data?.jobs], + ); + const isAppliedJobsLoading = + appliedJobsQuery.isPending || appliedJobsQuery.isFetching; const [bulkActionDialog, setBulkActionDialog] = useState<{ isOpen: boolean; action: "approve" | "deny" | null; itemCount: number; }>({ isOpen: false, action: null, itemCount: 0 }); - - const loadAppliedJobs = useCallback(async () => { - if (hasAttemptedAppliedJobsLoad || isAppliedJobsLoading) return; - setHasAttemptedAppliedJobsLoad(true); - setIsAppliedJobsLoading(true); - try { - const response = await api.getJobs({ - statuses: ["applied", "in_progress"], - view: "list", - }); - setAppliedJobs( - response.jobs.filter( - (job) => job.status === "applied" || job.status === "in_progress", - ), - ); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to load jobs"; - toast.error(message); - } finally { - setIsAppliedJobsLoading(false); - } - }, [hasAttemptedAppliedJobsLoad, isAppliedJobsLoading]); - - const loadAll = useCallback(async () => { - const [statusRes, inboxRes, runsRes] = await Promise.all([ - api.postApplicationProviderStatus({ provider, accountKey }), - api.getPostApplicationInbox({ provider, accountKey, limit: 100 }), - api.getPostApplicationRuns({ provider, accountKey, limit: 20 }), - ]); - - setStatus(statusRes.status); - setInbox(inboxRes.items); - setRuns(runsRes.runs); - }, [provider, accountKey]); + const isLoading = + statusQuery.isPending || inboxQuery.isPending || runsQuery.isPending; const refresh = useCallback(async () => { setIsRefreshing(true); try { - await loadAll(); + await Promise.all([ + statusQuery.refetch(), + inboxQuery.refetch(), + runsQuery.refetch(), + hasReviewItems ? appliedJobsQuery.refetch() : Promise.resolve(), + ]); } catch (error) { const message = error instanceof Error @@ -159,36 +189,19 @@ export const TrackingInboxPage: React.FC = () => { toast.error(message); } finally { setIsRefreshing(false); - setIsLoading(false); } - }, [loadAll]); - - useEffect(() => { - setIsLoading(true); - void refresh(); - }, [refresh]); + }, [appliedJobsQuery, hasReviewItems, inboxQuery, runsQuery, statusQuery]); useEffect(() => { if (!provider || !accountKey) return; - setAppliedJobs([]); setAppliedJobByMessageId({}); - setHasAttemptedAppliedJobsLoad(false); }, [provider, accountKey]); - const hasReviewItems = useMemo( - () => inbox.length > 0 || selectedRunItems.length > 0, - [inbox.length, selectedRunItems.length], - ); - - useEffect(() => { - if (!hasReviewItems) return; - void loadAppliedJobs(); - }, [hasReviewItems, loadAppliedJobs]); - useEffect(() => { const defaultAppliedJobId = appliedJobs[0]?.id ?? ""; setAppliedJobByMessageId((previous) => { const next = { ...previous }; + let didChange = false; for (const item of [...inbox, ...selectedRunItems]) { const selectedJobId = next[item.message.id]; const hasValidSelection = appliedJobs.some( @@ -199,12 +212,16 @@ export const TrackingInboxPage: React.FC = () => { const hasValidMatchedJob = appliedJobs.some( (appliedJob) => appliedJob.id === matchedJobId, ); - next[item.message.id] = hasValidMatchedJob + const nextJobId = hasValidMatchedJob ? matchedJobId : defaultAppliedJobId; + if (next[item.message.id] !== nextJobId) { + next[item.message.id] = nextJobId; + didChange = true; + } } } - return next; + return didChange ? next : previous; }); }, [appliedJobs, inbox, selectedRunItems]); @@ -487,32 +504,24 @@ export const TrackingInboxPage: React.FC = () => { [inbox], ); - const handleOpenRunMessages = useCallback( - async (run: PostApplicationSyncRun) => { - setSelectedRun(run); - setSelectedRunItems([]); - setIsRunModalOpen(true); - setIsRunMessagesLoading(true); + const handleOpenRunMessages = useCallback((run: PostApplicationSyncRun) => { + setSelectedRun(run); + setIsRunModalOpen(true); + }, []); - try { - const response = await api.getPostApplicationRunMessages({ - runId: run.id, - provider, - accountKey, - }); - setSelectedRun(response.run); - setSelectedRunItems(response.items); - } catch (error) { - const message = - error instanceof Error - ? error.message - : "Failed to load messages for selected sync run"; - toast.error(message); - } finally { - setIsRunMessagesLoading(false); - } - }, - [accountKey, provider], + useQueryErrorToast( + statusQuery.error, + "Failed to load provider connection status", + ); + useQueryErrorToast(inboxQuery.error, "Failed to load inbox"); + useQueryErrorToast(runsQuery.error, "Failed to load sync runs"); + useQueryErrorToast( + appliedJobsQuery.error, + "Failed to load jobs for inbox matching", + ); + useQueryErrorToast( + runMessagesQuery.error, + "Failed to load messages for selected sync run", ); const pendingCount = inbox.length; @@ -789,7 +798,6 @@ export const TrackingInboxPage: React.FC = () => { onOpenChange={(open) => { setIsRunModalOpen(open); if (!open) { - setSelectedRunItems([]); setSelectedRun(null); } }} diff --git a/orchestrator/src/client/pages/VisaSponsorsPage.tsx b/orchestrator/src/client/pages/VisaSponsorsPage.tsx index 5a4bb09..a97af51 100644 --- a/orchestrator/src/client/pages/VisaSponsorsPage.tsx +++ b/orchestrator/src/client/pages/VisaSponsorsPage.tsx @@ -8,6 +8,7 @@ import type { VisaSponsorSearchResult, VisaSponsorStatusResponse, } from "@shared/types.js"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, Building2, @@ -23,8 +24,10 @@ import { X, } from "lucide-react"; import type React from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast"; +import { queryKeys } from "@/client/lib/queryKeys"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"; @@ -56,18 +59,13 @@ const getScoreTokens = (score: number) => { }; export const VisaSponsorsPage: React.FC = () => { + const queryClient = useQueryClient(); // State - const [status, setStatus] = useState(null); const [searchQuery, setSearchQuery] = useState(""); - const [results, setResults] = useState([]); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); const [selectedOrg, setSelectedOrg] = useState(null); - const [orgDetails, setOrgDetails] = useState([]); // Loading states - const [isLoadingStatus, setIsLoadingStatus] = useState(true); - const [isSearching, setIsSearching] = useState(false); - const [isUpdating, setIsUpdating] = useState(false); - const [isLoadingDetails, setIsLoadingDetails] = useState(false); const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false); const [isDesktop, setIsDesktop] = useState(() => typeof window !== "undefined" @@ -75,80 +73,56 @@ export const VisaSponsorsPage: React.FC = () => { : false, ); - // Fetch organization details - const fetchOrgDetails = useCallback(async (orgName: string) => { - setIsLoadingDetails(true); - setSelectedOrg(orgName); - try { - const details = await api.getVisaSponsorOrganization(orgName); - setOrgDetails(details); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to fetch details"; - toast.error(message); - setOrgDetails([]); - } finally { - setIsLoadingDetails(false); - } - }, []); + const statusQuery = useQuery({ + queryKey: queryKeys.visaSponsors.status(), + queryFn: api.getVisaSponsorStatus, + }); + const status = statusQuery.data ?? null; + useQueryErrorToast(statusQuery.error, "Failed to fetch status"); - const fetchStatus = useCallback(async () => { - setIsLoadingStatus(true); - try { - const data = await api.getVisaSponsorStatus(); - setStatus(data); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to fetch status"; - toast.error(message); - } finally { - setIsLoadingStatus(false); - } - }, []); - - // Fetch status on mount - useEffect(() => { - fetchStatus(); - }, [fetchStatus]); - - // Search with debounce - const handleSearch = useCallback(async (query: string) => { - if (!query.trim()) { - setResults([]); - return; - } - - setIsSearching(true); - try { - const response = await api.searchVisaSponsors({ - query: query.trim(), - limit: 100, - minScore: 20, - }); - setResults(response.results); - } catch (err) { - const message = err instanceof Error ? err.message : "Search failed"; - toast.error(message); - setResults([]); - } finally { - setIsSearching(false); - } - }, []); - - // Debounced search effect useEffect(() => { const timer = setTimeout(() => { - handleSearch(searchQuery); + setDebouncedSearchQuery(searchQuery); }, 300); - return () => clearTimeout(timer); - }, [searchQuery, handleSearch]); + }, [searchQuery]); + + const searchQueryResult = useQuery({ + queryKey: queryKeys.visaSponsors.search( + debouncedSearchQuery.trim(), + 100, + 20, + ), + queryFn: () => + api.searchVisaSponsors({ + query: debouncedSearchQuery.trim(), + limit: 100, + minScore: 20, + }), + enabled: Boolean(debouncedSearchQuery.trim()), + }); + useQueryErrorToast(searchQueryResult.error, "Search failed"); + + const orgDetailsQuery = useQuery({ + queryKey: queryKeys.visaSponsors.organization(selectedOrg ?? ""), + queryFn: () => + selectedOrg + ? api.getVisaSponsorOrganization(selectedOrg) + : Promise.resolve([]), + enabled: Boolean(selectedOrg), + }); + const orgDetails = orgDetailsQuery.data ?? []; + useQueryErrorToast(orgDetailsQuery.error, "Failed to fetch details"); + + const results = useMemo(() => { + if (!debouncedSearchQuery.trim()) return []; + return searchQueryResult.data?.results ?? []; + }, [debouncedSearchQuery, searchQueryResult.data]); // Auto-select first result useEffect(() => { if (results.length === 0) { setSelectedOrg(null); - setOrgDetails([]); return; } if ( @@ -157,9 +131,8 @@ export const VisaSponsorsPage: React.FC = () => { ) { const firstOrg = results[0].sponsor.organisationName; setSelectedOrg(firstOrg); - fetchOrgDetails(firstOrg); } - }, [results, fetchOrgDetails, selectedOrg]); + }, [results, selectedOrg]); useEffect(() => { if (!selectedOrg) { @@ -187,25 +160,33 @@ export const VisaSponsorsPage: React.FC = () => { }, [isDesktop, isDetailDrawerOpen]); // Trigger manual update - const handleUpdate = async () => { - setIsUpdating(true); - try { - const result = await api.updateVisaSponsorList(); - setStatus(result.status); - toast.success(result.message); - if (searchQuery.trim()) { - handleSearch(searchQuery); + const updateListMutation = useMutation({ + mutationFn: api.updateVisaSponsorList, + onSuccess: async (result) => { + queryClient.setQueryData(queryKeys.visaSponsors.status(), result.status); + if (debouncedSearchQuery.trim()) { + await queryClient.invalidateQueries({ + queryKey: queryKeys.visaSponsors.search( + debouncedSearchQuery.trim(), + 100, + 20, + ), + }); } - } catch (err) { - const message = err instanceof Error ? err.message : "Update failed"; + toast.success(result.message); + }, + onError: (error) => { + const message = error instanceof Error ? error.message : "Update failed"; toast.error(message); - } finally { - setIsUpdating(false); - } + }, + }); + + const handleUpdate = async () => { + await updateListMutation.mutateAsync(); }; const handleSelectOrg = (orgName: string) => { - fetchOrgDetails(orgName); + setSelectedOrg(orgName); if (!isDesktop) { setIsDetailDrawerOpen(true); } @@ -217,7 +198,10 @@ export const VisaSponsorsPage: React.FC = () => { [results, selectedOrg], ); - const isUpdateInProgress = isUpdating || status?.isUpdating; + const isUpdateInProgress = updateListMutation.isPending || status?.isUpdating; + const isLoadingStatus = statusQuery.isLoading; + const isSearching = searchQueryResult.isFetching; + const isLoadingDetails = orgDetailsQuery.isLoading; const detailPanelContent = !selectedOrg ? (
@@ -412,9 +396,9 @@ export const VisaSponsorsPage: React.FC = () => {