Migration to tanstack query (#199)

* commit at some point in the middle, WIP

* formatting

* ci passing

* comments

* handle no jobid case

* better error handling

* comments

* Update orchestrator/src/client/hooks/queries/useJobMutations.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update orchestrator/src/client/hooks/queries/useSettingsMutation.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* better types

* formatter

* tracking inbox page

* in progress page

* tracer links page

* invalidate harder

* ensure tracer links docs show

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Shaheer Sarfaraz 2026-02-19 23:04:47 +00:00 committed by GitHub
parent 8b71bef5cf
commit 3640abef2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 1194 additions and 780 deletions

View File

@ -145,7 +145,7 @@ Fix:
## Related pages ## Related pages
- [Settings](/docs/features/settings) - [Settings](/docs/next/features/settings)
- [Reactive Resume](/docs/features/reactive-resume) - [Reactive Resume](/docs/next/features/reactive-resume)
- [Find Jobs and Apply Workflow](/docs/workflows/find-jobs-and-apply-workflow) - [Find Jobs and Apply Workflow](/docs/next/workflows/find-jobs-and-apply-workflow)
- [Post-Application Tracking](/docs/features/post-application-tracking) - [Post-Application Tracking](/docs/next/features/post-application-tracking)

View File

@ -35,6 +35,7 @@ const sidebars: SidebarsConfig = {
"features/ghostwriter", "features/ghostwriter",
"features/post-application-tracking", "features/post-application-tracking",
"features/visa-sponsors", "features/visa-sponsors",
"features/tracer-links",
], ],
}, },
{ {

View File

@ -32,7 +32,8 @@
"features/in-progress-board", "features/in-progress-board",
"features/ghostwriter", "features/ghostwriter",
"features/post-application-tracking", "features/post-application-tracking",
"features/visa-sponsors" "features/visa-sponsors",
"features/tracer-links"
] ]
}, },
{ {

View File

@ -44,6 +44,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.21",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"better-sqlite3": "^11.6.0", "better-sqlite3": "^11.6.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",

View File

@ -1,12 +1,16 @@
import { createJob } from "@shared/testing/factories.js"; import { createJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.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 type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api"; import * as api from "../api";
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness"; import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
import { renderWithQueryClient } from "../test/renderWithQueryClient";
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer"; import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui);
vi.mock("@/components/ui/sheet", () => ({ vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ open, children }: { open: boolean; children: React.ReactNode }) => Sheet: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div>{children}</div> : null, open ? <div>{children}</div> : null,
@ -166,7 +170,11 @@ describe("JobDetailsEditDrawer", () => {
); );
await waitFor(() => expect(api.getTracerReadiness).toHaveBeenCalled()); 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 })); fireEvent.click(screen.getByRole("button", { name: /save details/i }));
await waitFor(() => await waitFor(() =>

View File

@ -1,10 +1,14 @@
import * as api from "@client/api"; import * as api from "@client/api";
import { useSettings } from "@client/hooks/useSettings"; 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 type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithQueryClient } from "../test/renderWithQueryClient";
import { OnboardingGate } from "./OnboardingGate"; import { OnboardingGate } from "./OnboardingGate";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui);
vi.mock("@client/api", () => ({ vi.mock("@client/api", () => ({
getDemoInfo: vi.fn(), getDemoInfo: vi.fn(),
validateLlm: vi.fn(), validateLlm: vi.fn(),

View File

@ -1,13 +1,17 @@
import { createJob } from "@shared/testing/factories.js"; import { createJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.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 type React from "react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api"; import * as api from "../api";
import { renderWithQueryClient } from "../test/renderWithQueryClient";
import { ReadyPanel } from "./ReadyPanel"; import { ReadyPanel } from "./ReadyPanel";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui);
vi.mock("@/components/ui/dropdown-menu", () => { vi.mock("@/components/ui/dropdown-menu", () => {
return { return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => ( DropdownMenu: ({ children }: { children: React.ReactNode }) => (

View File

@ -1,12 +1,16 @@
import { createJob as createBaseJob } from "@shared/testing/factories.js"; import { createJob as createBaseJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.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 { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api"; import * as api from "../api";
import { useProfile } from "../hooks/useProfile"; import { useProfile } from "../hooks/useProfile";
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness"; import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
import { renderWithQueryClient } from "../test/renderWithQueryClient";
import { TailoringEditor } from "./TailoringEditor"; import { TailoringEditor } from "./TailoringEditor";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui);
vi.mock("../api", () => ({ vi.mock("../api", () => ({
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]), getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
updateJob: vi.fn().mockResolvedValue({}), updateJob: vi.fn().mockResolvedValue({}),

View File

@ -1,13 +1,17 @@
import { createJob } from "@shared/testing/factories.js"; import { createJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.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 type React from "react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../../api"; import * as api from "../../api";
import { renderWithQueryClient } from "../../test/renderWithQueryClient";
import { DiscoveredPanel } from "./DiscoveredPanel"; import { DiscoveredPanel } from "./DiscoveredPanel";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui);
vi.mock("@/components/ui/dropdown-menu", () => { vi.mock("@/components/ui/dropdown-menu", () => {
return { return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => ( DropdownMenu: ({ children }: { children: React.ReactNode }) => (

View File

@ -1,12 +1,16 @@
import { createJob as createBaseJob } from "@shared/testing/factories.js"; import { createJob as createBaseJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.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 { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../../api"; import * as api from "../../api";
import { useProfile } from "../../hooks/useProfile"; import { useProfile } from "../../hooks/useProfile";
import { _resetTracerReadinessCache } from "../../hooks/useTracerReadiness"; import { _resetTracerReadinessCache } from "../../hooks/useTracerReadiness";
import { renderWithQueryClient } from "../../test/renderWithQueryClient";
import { TailorMode } from "./TailorMode"; import { TailorMode } from "./TailorMode";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui);
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]), getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
updateJob: vi.fn(), updateJob: vi.fn(),

View File

@ -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(),
});
});
});

View File

@ -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<void> {
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<void> {
await queryClient.invalidateQueries({ queryKey: queryKeys.settings.all });
await queryClient.invalidateQueries({ queryKey: queryKeys.tracer.all });
}

View File

@ -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<Job> }) =>
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<Job>(
queryKeys.jobs.detail(id),
);
queryClient.setQueryData<Job>(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<Job>(
queryKeys.jobs.detail(id),
);
queryClient.setQueryData<Job>(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);
},
});
}

View File

@ -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);
},
});
}

View File

@ -1,30 +1,18 @@
import * as api from "@client/api"; import * as api from "@client/api";
import type { DemoInfoResponse } from "@shared/types"; 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() { export function useDemoInfo() {
const [demoInfo, setDemoInfo] = useState<DemoInfoResponse | null>(null); const { data } = useQuery<DemoInfoResponse | null>({
queryKey: queryKeys.demo.info(),
useEffect(() => { queryFn: async () => {
let isCancelled = false; try {
return await api.getDemoInfo();
void api } catch {
.getDemoInfo() return null;
.then((info) => { }
if (!isCancelled) { },
setDemoInfo(info); });
} return data ?? null;
})
.catch(() => {
if (!isCancelled) {
setDemoInfo(null);
}
});
return () => {
isCancelled = true;
};
}, []);
return demoInfo;
} }

View File

@ -1,98 +1,35 @@
import type { ResumeProfile } from "@shared/types"; 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"; 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. * Hook to get the full profile data from base.json.
* Caches the result to avoid re-fetching. * Caches the result to avoid re-fetching.
*/ */
export function useProfile() { export function useProfile() {
const [profile, setProfile] = useState<ResumeProfile | null>(profileCache); const {
const [error, setError] = useState<Error | null>(profileError); data: profile = null,
error,
useEffect(() => { isLoading,
if (profileCache) { isFetching,
setProfile(profileCache); refetch,
} } = useQuery<ResumeProfile | null>({
if (profileError) { queryKey: queryKeys.profile.current(),
setError(profileError); queryFn: api.getProfile,
} });
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 refreshProfile = async () => { const refreshProfile = async () => {
isFetching = true; const result = await refetch();
profileError = null; if (result.error) throw result.error;
subscribers.forEach((sub) => { return result.data ?? null;
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;
}
}; };
return { return {
profile, profile,
error, error: error ?? null,
isLoading: !profile && isFetching && !error, isLoading: isLoading || (!!isFetching && !profile && !error),
personName: profile?.basics?.name || "Resume", personName: profile?.basics?.name || "Resume",
refreshProfile, refreshProfile,
}; };
@ -100,8 +37,5 @@ export function useProfile() {
/** @internal For testing only */ /** @internal For testing only */
export function _resetProfileCache() { export function _resetProfileCache() {
profileCache = null; appQueryClient.removeQueries({ queryKey: queryKeys.profile.all });
profileError = null;
isFetching = false;
subscribers.clear();
} }

View File

@ -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<string | null>(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]);
}

View File

@ -1,7 +1,8 @@
import { act, renderHook } from "@testing-library/react"; import { act } from "@testing-library/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api"; import * as api from "../api";
import { renderHookWithQueryClient } from "../test/renderWithQueryClient";
import { useRescoreJob } from "./useRescoreJob"; import { useRescoreJob } from "./useRescoreJob";
vi.mock("../api", () => ({ vi.mock("../api", () => ({
@ -24,7 +25,9 @@ describe("useRescoreJob", () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined); const onJobUpdated = vi.fn().mockResolvedValue(undefined);
vi.mocked(api.rescoreJob).mockResolvedValue({} as any); vi.mocked(api.rescoreJob).mockResolvedValue({} as any);
const { result } = renderHook(() => useRescoreJob(onJobUpdated)); const { result } = renderHookWithQueryClient(() =>
useRescoreJob(onJobUpdated),
);
await act(async () => { await act(async () => {
await result.current.rescoreJob("job-1"); await result.current.rescoreJob("job-1");

View File

@ -1,10 +1,10 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useRescoreJobMutation } from "@/client/hooks/queries/useJobMutations";
import * as api from "../api";
export function useRescoreJob(onJobUpdated: () => void | Promise<void>) { export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
const [isRescoring, setIsRescoring] = useState(false); const [isRescoring, setIsRescoring] = useState(false);
const rescoreMutation = useRescoreJobMutation();
const rescoreJob = useCallback( const rescoreJob = useCallback(
async (jobId?: string | null) => { async (jobId?: string | null) => {
@ -12,7 +12,7 @@ export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
try { try {
setIsRescoring(true); setIsRescoring(true);
await api.rescoreJob(jobId); await rescoreMutation.mutateAsync(jobId);
toast.success("Match recalculated"); toast.success("Match recalculated");
await onJobUpdated(); await onJobUpdated();
} catch (error) { } catch (error) {
@ -25,7 +25,7 @@ export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
setIsRescoring(false); setIsRescoring(false);
} }
}, },
[onJobUpdated], [onJobUpdated, rescoreMutation],
); );
return { isRescoring, rescoreJob }; return { isRescoring, rescoreJob };

View File

@ -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 { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api"; import * as api from "../api";
import { renderHookWithQueryClient } from "../test/renderWithQueryClient";
import { _resetSettingsCache, useSettings } from "./useSettings"; import { _resetSettingsCache, useSettings } from "./useSettings";
vi.mock("../api", () => ({ vi.mock("../api", () => ({
@ -17,7 +18,7 @@ describe("useSettings", () => {
const mockSettings = { showSponsorInfo: false }; const mockSettings = { showSponsorInfo: false };
vi.mocked(api.getSettings).mockResolvedValue(mockSettings as any); vi.mocked(api.getSettings).mockResolvedValue(mockSettings as any);
const { result } = renderHook(() => useSettings()); const { result } = renderHookWithQueryClient(() => useSettings());
// Should start in loading state // Should start in loading state
expect(result.current.settings).toBeNull(); expect(result.current.settings).toBeNull();
@ -33,7 +34,7 @@ describe("useSettings", () => {
it("uses default values when settings are null", async () => { it("uses default values when settings are null", async () => {
vi.mocked(api.getSettings).mockResolvedValue(null as any); vi.mocked(api.getSettings).mockResolvedValue(null as any);
const { result } = renderHook(() => useSettings()); const { result } = renderHookWithQueryClient(() => useSettings());
await waitFor(() => { await waitFor(() => {
// settings is null, so showSponsorInfo should default to true // 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(initialSettings as any);
vi.mocked(api.getSettings).mockResolvedValueOnce(updatedSettings as any); vi.mocked(api.getSettings).mockResolvedValueOnce(updatedSettings as any);
const { result } = renderHook(() => useSettings()); const { result } = renderHookWithQueryClient(() => useSettings());
await waitFor(() => { await waitFor(() => {
expect(result.current.settings).toEqual(initialSettings); expect(result.current.settings).toEqual(initialSettings);
@ -71,7 +72,7 @@ describe("useSettings", () => {
const mockError = new Error("Failed to fetch"); const mockError = new Error("Failed to fetch");
vi.mocked(api.getSettings).mockRejectedValue(mockError); vi.mocked(api.getSettings).mockRejectedValue(mockError);
const { result } = renderHook(() => useSettings()); const { result } = renderHookWithQueryClient(() => useSettings());
await waitFor(() => { await waitFor(() => {
expect(result.current.error).toEqual(mockError); expect(result.current.error).toEqual(mockError);

View File

@ -1,94 +1,31 @@
import type { AppSettings } from "@shared/types"; 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"; 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() { export function useSettings() {
const [settings, setSettings] = useState<AppSettings | null>(settingsCache); const {
const [error, setError] = useState<Error | null>(settingsError); data: settings = null,
error,
useEffect(() => { isLoading,
if (settingsCache) { isFetching,
setSettings(settingsCache); refetch,
} } = useQuery<AppSettings | null>({
if (settingsError) { queryKey: queryKeys.settings.current(),
setError(settingsError); queryFn: api.getSettings,
} });
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 refreshSettings = async () => { const refreshSettings = async () => {
isFetching = true; const result = await refetch();
settingsError = null; if (result.error) throw result.error;
subscribers.forEach((sub) => { return result.data ?? null;
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;
}
}; };
return { return {
settings, settings,
error, error: error ?? null,
isLoading: !settings && isFetching && !error, isLoading: isLoading || (!!isFetching && !settings && !error),
showSponsorInfo: settings?.showSponsorInfo ?? true, showSponsorInfo: settings?.showSponsorInfo ?? true,
refreshSettings, refreshSettings,
}; };
@ -96,8 +33,5 @@ export function useSettings() {
/** @internal For testing only */ /** @internal For testing only */
export function _resetSettingsCache() { export function _resetSettingsCache() {
settingsCache = null; appQueryClient.removeQueries({ queryKey: queryKeys.settings.all });
settingsError = null;
isFetching = false;
subscribers.clear();
} }

View File

@ -1,101 +1,46 @@
import type { TracerReadinessResponse } from "@shared/types"; 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"; 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<TracerReadinessResponse> {
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() { export function useTracerReadiness() {
const [readiness, setReadiness] = useState<TracerReadinessResponse | null>( const queryClient = useQueryClient();
readinessCache, const {
); data: readiness = null,
const [error, setError] = useState<Error | null>(readinessError); error,
const [loading, setLoading] = useState<boolean>( isLoading,
!readinessCache && isFetching, isFetching,
); refetch,
} = useQuery<TracerReadinessResponse | null>({
useEffect(() => { queryKey: queryKeys.tracer.readiness(false),
if (readinessCache) setReadiness(readinessCache); queryFn: () => api.getTracerReadiness({ force: false }),
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 refreshReadiness = async (force = true) => { 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 { return {
readiness, readiness,
error, error: error ?? null,
isLoading: loading && !readiness, isLoading: isLoading && !readiness,
isChecking: loading, isChecking: isFetching,
refreshReadiness, refreshReadiness,
}; };
} }
/** @internal For testing only */ /** @internal For testing only */
export function _resetTracerReadinessCache() { export function _resetTracerReadinessCache() {
readinessCache = null; appQueryClient.removeQueries({ queryKey: queryKeys.tracer.all });
readinessError = null;
isFetching = false;
subscribers.clear();
} }

View File

@ -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();

View File

@ -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;

View File

@ -1,6 +1,8 @@
import { QueryClientProvider } from "@tanstack/react-query";
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { queryClient } from "@/client/lib/queryClient";
import { App } from "./App"; import { App } from "./App";
import "../index.css"; import "../index.css";
@ -9,8 +11,10 @@ if (!rootElement) throw new Error("Failed to find the root element");
ReactDOM.createRoot(rootElement).render( ReactDOM.createRoot(rootElement).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <QueryClientProvider client={queryClient}>
<App /> <BrowserRouter>
</BrowserRouter> <App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@ -7,10 +7,12 @@ import {
} from "@client/components/charts"; } from "@client/components/charts";
import { PageHeader, PageMain } from "@client/components/layout"; import { PageHeader, PageMain } from "@client/components/layout";
import type { StageEvent } from "@shared/types.js"; import type { StageEvent } from "@shared/types.js";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChartColumn } from "lucide-react"; import { ChartColumn } from "lucide-react";
import type React from "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 { useSearchParams } from "react-router-dom";
import { queryKeys } from "@/client/lib/queryKeys";
type JobWithEvents = { type JobWithEvents = {
id: string; id: string;
@ -24,11 +26,8 @@ const DURATION_OPTIONS = [7, 14, 30, 90] as const;
const DEFAULT_DURATION = 30; const DEFAULT_DURATION = 30;
export const HomePage: React.FC = () => { export const HomePage: React.FC = () => {
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [jobsWithEvents, setJobsWithEvents] = useState<JobWithEvents[]>([]);
const [appliedDates, setAppliedDates] = useState<Array<string | null>>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Read initial duration from URL // Read initial duration from URL
const initialDuration: DurationValue = (() => { const initialDuration: DurationValue = (() => {
@ -42,70 +41,72 @@ export const HomePage: React.FC = () => {
const [duration, setDuration] = useState<DurationValue>(initialDuration); const [duration, setDuration] = useState<DurationValue>(initialDuration);
useEffect(() => { const overviewQuery = useQuery({
let isMounted = true; queryKey: queryKeys.jobs.list({
setIsLoading(true); statuses: ["applied", "in_progress"],
view: "list",
api }),
.getJobs({ queryFn: async () => {
const response = await api.getJobs({
statuses: ["applied", "in_progress"], statuses: ["applied", "in_progress"],
view: "list", view: "list",
}) });
.then(async (response) => { const appliedDates = response.jobs.map((job) => job.appliedAt);
if (!isMounted) return; const jobSummaries = response.jobs.map((job) => ({
const appliedDates = response.jobs.map((job) => job.appliedAt); id: job.id,
const jobSummaries = response.jobs.map((job) => ({ datePosted: job.datePosted,
id: job.id, discoveredAt: job.discoveredAt,
datePosted: job.datePosted, appliedAt: job.appliedAt,
discoveredAt: job.discoveredAt, positiveResponse: false,
appliedAt: job.appliedAt, }));
positiveResponse: false,
}));
const appliedJobs = jobSummaries.filter((job) => job.appliedAt); const appliedJobs = jobSummaries.filter((job) => job.appliedAt);
const results = await Promise.allSettled( const results = await Promise.allSettled(
appliedJobs.map((job) => api.getJobStageEvents(job.id)), appliedJobs.map((job) =>
); queryClient.fetchQuery({
const eventsMap = new Map<string, StageEvent[]>(); queryKey: queryKeys.jobs.stageEvents(job.id),
queryFn: () => api.getJobStageEvents(job.id),
staleTime: 0,
}),
),
);
const eventsMap = new Map<string, StageEvent[]>();
results.forEach((result, index) => { results.forEach((result, index) => {
const jobId = appliedJobs[index]?.id; const jobId = appliedJobs[index]?.id;
if (!jobId) return; if (!jobId) return;
if (result.status !== "fulfilled") { if (result.status !== "fulfilled") {
eventsMap.set(jobId, []); eventsMap.set(jobId, []);
return; return;
} }
eventsMap.set(jobId, result.value); 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);
}); });
return () => { const jobsWithEvents: JobWithEvents[] = jobSummaries
isMounted = false; .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( const handleDurationChange = useCallback(
(newDuration: DurationValue) => { (newDuration: DurationValue) => {

View File

@ -1,11 +1,15 @@
import type { JobListItem, StageEvent } from "@shared/types"; 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 { MemoryRouter } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api"; import * as api from "../api";
import { renderWithQueryClient } from "../test/renderWithQueryClient";
import { InProgressBoardPage } from "./InProgressBoardPage"; import { InProgressBoardPage } from "./InProgressBoardPage";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui);
vi.mock("../api", () => ({ vi.mock("../api", () => ({
getJobs: vi.fn(), getJobs: vi.fn(),
getJobStageEvents: vi.fn(), getJobStageEvents: vi.fn(),

View File

@ -6,10 +6,13 @@ import {
STAGE_LABELS, STAGE_LABELS,
type StageEvent, type StageEvent,
} from "@shared/types.js"; } from "@shared/types.js";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDownAZ, Columns3, ExternalLink, Plus } from "lucide-react"; import { ArrowDownAZ, Columns3, ExternalLink, Plus } from "lucide-react";
import React from "react"; import React from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
import { queryKeys } from "@/client/lib/queryKeys";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -76,9 +79,9 @@ const resolveCurrentStage = (
}; };
export const InProgressBoardPage: React.FC = () => { export const InProgressBoardPage: React.FC = () => {
const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const [cards, setCards] = React.useState<BoardCard[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [dragging, setDragging] = React.useState<{ const [dragging, setDragging] = React.useState<{
jobId: string; jobId: string;
fromStage: ApplicationStage; fromStage: ApplicationStage;
@ -90,9 +93,9 @@ export const InProgressBoardPage: React.FC = () => {
"updated" | "title" | "company" "updated" | "title" | "company"
>("updated"); >("updated");
const loadBoard = React.useCallback(async () => { const boardQuery = useQuery({
try { queryKey: queryKeys.jobs.inProgressBoard(),
setIsLoading(true); queryFn: async () => {
const response = await api.getJobs({ const response = await api.getJobs({
statuses: ["in_progress"], statuses: ["in_progress"],
view: "list", view: "list",
@ -103,7 +106,7 @@ export const InProgressBoardPage: React.FC = () => {
jobs.map((job) => api.getJobStageEvents(job.id)), jobs.map((job) => api.getJobStageEvents(job.id)),
); );
const nextCards = jobs.map((job, index) => { return jobs.map((job, index) => {
const result = eventResults[index]; const result = eventResults[index];
const events = const events =
result?.status === "fulfilled" result?.status === "fulfilled"
@ -116,22 +119,31 @@ export const InProgressBoardPage: React.FC = () => {
latestEventAt: resolved.latestEventAt, latestEventAt: resolved.latestEventAt,
}; };
}); });
},
});
setCards(nextCards); const transitionMutation = useMutation({
} catch (error) { mutationFn: ({
const message = jobId,
error instanceof Error toStage,
? error.message }: {
: "Failed to load in-progress board"; jobId: string;
toast.error(message); toStage: ApplicationStage;
} finally { }) =>
setIsLoading(false); api.transitionJobStage(jobId, {
} toStage,
}, []); metadata: {
actor: "user",
eventType: "status_update",
eventLabel: `Moved to ${STAGE_LABELS[toStage]}`,
},
}),
});
React.useEffect(() => { useQueryErrorToast(boardQuery.error, "Failed to load in-progress board");
void loadBoard();
}, [loadBoard]); const cards = boardQuery.data ?? [];
const isLoading = boardQuery.isPending;
const lanes = React.useMemo(() => { const lanes = React.useMemo(() => {
const sortFn = const sortFn =
@ -170,31 +182,34 @@ export const InProgressBoardPage: React.FC = () => {
} }
const { jobId } = dragging; const { jobId } = dragging;
const previousCards = cards; const previousCards =
queryClient.getQueryData<BoardCard[]>(
queryKeys.jobs.inProgressBoard(),
) ?? [];
const nowEpoch = Math.floor(Date.now() / 1000); const nowEpoch = Math.floor(Date.now() / 1000);
setMovingJobId(jobId); setMovingJobId(jobId);
setCards((current) => queryClient.setQueryData<BoardCard[]>(
current.map((card) => queryKeys.jobs.inProgressBoard(),
card.job.id === jobId (current) =>
? { ...card, stage: toStage, latestEventAt: nowEpoch } (current ?? []).map((card) =>
: card, card.job.id === jobId
), ? { ...card, stage: toStage, latestEventAt: nowEpoch }
: card,
),
); );
try { try {
await api.transitionJobStage(jobId, { await transitionMutation.mutateAsync({ jobId, toStage });
toStage,
metadata: {
actor: "user",
eventType: "status_update",
eventLabel: `Moved to ${STAGE_LABELS[toStage]}`,
},
});
toast.success(`Moved to ${STAGE_LABELS[toStage]}`); toast.success(`Moved to ${STAGE_LABELS[toStage]}`);
await loadBoard(); await queryClient.invalidateQueries({
queryKey: queryKeys.jobs.inProgressBoard(),
});
} catch (error) { } catch (error) {
setCards(previousCards); queryClient.setQueryData(
queryKeys.jobs.inProgressBoard(),
previousCards,
);
const message = const message =
error instanceof Error ? error.message : "Failed to move stage"; error instanceof Error ? error.message : "Failed to move stage";
toast.error(message); toast.error(message);
@ -204,7 +219,7 @@ export const InProgressBoardPage: React.FC = () => {
setDropTargetStage(null); setDropTargetStage(null);
} }
}, },
[cards, dragging, loadBoard], [dragging, queryClient, transitionMutation],
); );
return ( return (

View File

@ -6,6 +6,7 @@ import {
STAGE_LABELS, STAGE_LABELS,
type StageEvent, type StageEvent,
} from "@shared/types.js"; } from "@shared/types.js";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import { import {
ArrowLeft, ArrowLeft,
@ -26,6 +27,17 @@ import {
import React from "react"; import React from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner"; 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -55,10 +67,7 @@ import { JobTimeline } from "./job/Timeline";
export const JobPage: React.FC = () => { export const JobPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [job, setJob] = React.useState<Job | null>(null); const queryClient = useQueryClient();
const [events, setEvents] = React.useState<StageEvent[]>([]);
const [tasks, setTasks] = React.useState<ApplicationTask[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [isLogModalOpen, setIsLogModalOpen] = React.useState(false); const [isLogModalOpen, setIsLogModalOpen] = React.useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false);
const [isEditDetailsOpen, setIsEditDetailsOpen] = React.useState(false); const [isEditDetailsOpen, setIsEditDetailsOpen] = React.useState(false);
@ -69,30 +78,58 @@ export const JobPage: React.FC = () => {
); );
const pendingEventRef = React.useRef<StageEvent | null>(null); const pendingEventRef = React.useRef<StageEvent | null>(null);
const jobQuery = useQuery<Job | null>({
queryKey: ["jobs", "detail", id ?? null] as const,
queryFn: () => (id ? api.getJob(id) : Promise.resolve(null)),
enabled: Boolean(id),
});
const eventsQuery = useQuery<StageEvent[]>({
queryKey: ["jobs", "stage-events", id ?? null] as const,
queryFn: () => (id ? api.getJobStageEvents(id) : Promise.resolve([])),
enabled: Boolean(id),
});
const tasksQuery = useQuery<ApplicationTask[]>({
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 () => { const loadData = React.useCallback(async () => {
if (!id) return; if (!id) return;
setIsLoading(true); await Promise.all([
try { queryClient.invalidateQueries({ queryKey: queryKeys.jobs.detail(id) }),
const jobData = await api.getJob(id); queryClient.invalidateQueries({
setJob(jobData); queryKey: queryKeys.jobs.stageEvents(id),
}),
api queryClient.invalidateQueries({ queryKey: queryKeys.jobs.tasks(id) }),
.getJobStageEvents(id) ]);
.then((data) => setEvents(mergeEvents(data, pendingEventRef.current))) }, [id, queryClient]);
.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]);
const handleLogEvent = async ( const handleLogEvent = async (
values: LogEventFormValues, values: LogEventFormValues,
@ -153,12 +190,7 @@ export const JobPage: React.FC = () => {
pendingEventRef.current = newEvent; pendingEventRef.current = newEvent;
} }
const [jobData, eventData] = await Promise.all([ await invalidateJobData(queryClient, job.id);
api.getJob(job.id),
api.getJobStageEvents(job.id),
]);
setJob(jobData);
setEvents(eventData);
pendingEventRef.current = null; pendingEventRef.current = null;
setEditingEvent(null); setEditingEvent(null);
toast.success(eventId ? "Event updated" : "Event logged"); toast.success(eventId ? "Event updated" : "Event logged");
@ -172,8 +204,9 @@ export const JobPage: React.FC = () => {
}); });
} }
} catch (error) { } catch (error) {
console.error("Failed to log event:", error); const message =
toast.error("Failed to log event"); 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; if (!job || !eventToDelete) return;
try { try {
await api.deleteJobStageEvent(job.id, eventToDelete); await api.deleteJobStageEvent(job.id, eventToDelete);
const [jobData, eventData] = await Promise.all([ await invalidateJobData(queryClient, job.id);
api.getJob(job.id),
api.getJobStageEvents(job.id),
]);
setJob(jobData);
setEvents(eventData);
toast.success("Event deleted"); toast.success("Event deleted");
} catch (error) { } catch (error) {
console.error("Failed to delete event:", error); const message =
toast.error("Failed to delete event"); error instanceof Error ? error.message : "Failed to delete event";
toast.error(message);
} finally { } finally {
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
setEventToDelete(null); setEventToDelete(null);
@ -228,7 +257,7 @@ export const JobPage: React.FC = () => {
const handleMarkApplied = async () => { const handleMarkApplied = async () => {
await runAction("mark-applied", async () => { await runAction("mark-applied", async () => {
if (!job) return; if (!job) return;
await api.markAsApplied(job.id); await markAsAppliedMutation.mutateAsync(job.id);
toast.success("Marked as applied"); toast.success("Marked as applied");
}); });
}; };
@ -236,7 +265,10 @@ export const JobPage: React.FC = () => {
const handleMoveToInProgress = async () => { const handleMoveToInProgress = async () => {
await runAction("move-in-progress", async () => { await runAction("move-in-progress", async () => {
if (!job) return; 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"); toast.success("Moved to in progress");
}); });
}; };
@ -244,7 +276,7 @@ export const JobPage: React.FC = () => {
const handleSkip = async () => { const handleSkip = async () => {
await runAction("skip", async () => { await runAction("skip", async () => {
if (!job) return; if (!job) return;
await api.skipJob(job.id); await skipJobMutation.mutateAsync(job.id);
toast.message("Job skipped"); toast.message("Job skipped");
}); });
}; };
@ -252,7 +284,7 @@ export const JobPage: React.FC = () => {
const handleRescore = async () => { const handleRescore = async () => {
await runAction("rescore", async () => { await runAction("rescore", async () => {
if (!job) return; if (!job) return;
await api.rescoreJob(job.id); await rescoreJobMutation.mutateAsync(job.id);
toast.success("Match recalculated"); toast.success("Match recalculated");
}); });
}; };
@ -260,7 +292,7 @@ export const JobPage: React.FC = () => {
const handleRegeneratePdf = async () => { const handleRegeneratePdf = async () => {
await runAction("regenerate-pdf", async () => { await runAction("regenerate-pdf", async () => {
if (!job) return; if (!job) return;
await api.generateJobPdf(job.id); await generatePdfMutation.mutateAsync(job.id);
toast.success("Resume PDF generated"); toast.success("Resume PDF generated");
}); });
}; };
@ -268,7 +300,7 @@ export const JobPage: React.FC = () => {
const handleCheckSponsor = async () => { const handleCheckSponsor = async () => {
await runAction("check-sponsor", async () => { await runAction("check-sponsor", async () => {
if (!job) return; if (!job) return;
await api.checkSponsor(job.id); await checkSponsorMutation.mutateAsync(job.id);
toast.success("Sponsor check completed"); toast.success("Sponsor check completed");
}); });
}; };

View File

@ -1,12 +1,16 @@
import { createJob } from "@shared/testing/factories.js"; 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 { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api"; import * as api from "../api";
import { renderWithQueryClient } from "../test/renderWithQueryClient";
import { OrchestratorPage } from "./OrchestratorPage"; import { OrchestratorPage } from "./OrchestratorPage";
import type { FilterTab } from "./orchestrator/constants"; import type { FilterTab } from "./orchestrator/constants";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui);
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true, configurable: true,

View File

@ -1,12 +1,16 @@
import { createAppSettings } from "@shared/testing/factories.js"; 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 { MemoryRouter } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api"; import * as api from "../api";
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness"; import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
import { renderWithQueryClient } from "../test/renderWithQueryClient";
import { SettingsPage } from "./SettingsPage"; import { SettingsPage } from "./SettingsPage";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui);
vi.mock("../api", () => ({ vi.mock("../api", () => ({
getSettings: vi.fn(), getSettings: vi.fn(),
updateSettings: vi.fn(), updateSettings: vi.fn(),

View File

@ -1,5 +1,6 @@
import * as api from "@client/api"; import * as api from "@client/api";
import { PageHeader } from "@client/components/layout"; import { PageHeader } from "@client/components/layout";
import { useUpdateSettingsMutation } from "@client/hooks/queries/useSettingsMutation";
import { useTracerReadiness } from "@client/hooks/useTracerReadiness"; import { useTracerReadiness } from "@client/hooks/useTracerReadiness";
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection"; import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection"; import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection";
@ -23,16 +24,18 @@ import {
} from "@shared/settings-schema.js"; } from "@shared/settings-schema.js";
import type { import type {
AppSettings, AppSettings,
BackupInfo,
JobStatus, JobStatus,
ResumeProjectCatalogItem, ResumeProjectCatalogItem,
ResumeProjectsSettings, ResumeProjectsSettings,
} from "@shared/types.js"; } from "@shared/types.js";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Settings } from "lucide-react"; import { Settings } from "lucide-react";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { FormProvider, type Resolver, useForm } from "react-hook-form"; import { FormProvider, type Resolver, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
import { queryKeys } from "@/client/lib/queryKeys";
import { Accordion } from "@/components/ui/accordion"; import { Accordion } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -293,9 +296,9 @@ const getDerivedSettings = (settings: AppSettings | null) => {
}; };
export const SettingsPage: React.FC = () => { export const SettingsPage: React.FC = () => {
const queryClient = useQueryClient();
const [settings, setSettings] = useState<AppSettings | null>(null); const [settings, setSettings] = useState<AppSettings | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>([ const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>([
"discovered", "discovered",
]); ]);
@ -309,9 +312,6 @@ export const SettingsPage: React.FC = () => {
useState(false); useState(false);
// Backup state // Backup state
const [backups, setBackups] = useState<BackupInfo[]>([]);
const [nextScheduled, setNextScheduled] = useState<string | null>(null);
const [isLoadingBackups, setIsLoadingBackups] = useState(false);
const [isCreatingBackup, setIsCreatingBackup] = useState(false); const [isCreatingBackup, setIsCreatingBackup] = useState(false);
const [isDeletingBackup, setIsDeletingBackup] = useState(false); const [isDeletingBackup, setIsDeletingBackup] = useState(false);
const { const {
@ -339,34 +339,32 @@ export const SettingsPage: React.FC = () => {
formState: { isDirty, errors, isValid, dirtyFields }, formState: { isDirty, errors, isValid, dirtyFields },
} = methods; } = 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( const hasRxResumeAccess = Boolean(
settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint, settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint,
); );
useEffect(() => { useEffect(() => {
let isMounted = true; if (!settingsQuery.data) return;
setIsLoading(true); setSettings(settingsQuery.data);
api reset(mapSettingsToForm(settingsQuery.data));
.getSettings() }, [settingsQuery.data, reset]);
.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);
});
return () => { useQueryErrorToast(settingsQuery.error, "Failed to load settings");
isMounted = false;
};
}, [reset]);
useEffect(() => { useEffect(() => {
if (!settings) return; if (!settings) return;
@ -442,28 +440,12 @@ export const SettingsPage: React.FC = () => {
scoring, scoring,
} = derived; } = 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 () => { const handleCreateBackup = async () => {
setIsCreatingBackup(true); setIsCreatingBackup(true);
try { try {
await api.createManualBackup(); await api.createManualBackup();
toast.success("Backup created successfully"); toast.success("Backup created successfully");
await loadBackups(); await queryClient.invalidateQueries({ queryKey: queryKeys.backups.all });
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : "Failed to create backup"; error instanceof Error ? error.message : "Failed to create backup";
@ -484,7 +466,7 @@ export const SettingsPage: React.FC = () => {
try { try {
await api.deleteBackup(filename); await api.deleteBackup(filename);
toast.success("Backup deleted successfully"); toast.success("Backup deleted successfully");
await loadBackups(); await queryClient.invalidateQueries({ queryKey: queryKeys.backups.all });
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : "Failed to delete backup"; error instanceof Error ? error.message : "Failed to delete backup";
@ -497,7 +479,9 @@ export const SettingsPage: React.FC = () => {
const handleVerifyTracerReadiness = useCallback(async () => { const handleVerifyTracerReadiness = useCallback(async () => {
try { try {
const readiness = await refreshReadiness(true); 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"); toast.success("Tracer links are ready");
} else { } else {
toast.error( toast.error(
@ -514,13 +498,6 @@ export const SettingsPage: React.FC = () => {
} }
}, [refreshReadiness]); }, [refreshReadiness]);
// Load backups when settings are loaded
useEffect(() => {
if (settings) {
loadBackups();
}
}, [settings, loadBackups]);
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects; const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects;
const effectiveMaxProjectsTotal = effectiveProfileProjects.length; 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 // need to track it so that the save button is enabled when it changes
delete payload.enableBasicAuth; delete payload.enableBasicAuth;
const updated = await api.updateSettings(payload); const updated = await updateSettingsMutation.mutateAsync(payload);
setSettings(updated); setSettings(updated);
reset(mapSettingsToForm(updated)); reset(mapSettingsToForm(updated));
toast.success("Settings saved"); toast.success("Settings saved");
@ -758,7 +735,9 @@ export const SettingsPage: React.FC = () => {
const handleReset = async () => { const handleReset = async () => {
try { try {
setIsSaving(true); setIsSaving(true);
const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD); const updated = await updateSettingsMutation.mutateAsync(
NULL_SETTINGS_PAYLOAD,
);
setSettings(updated); setSettings(updated);
reset(mapSettingsToForm(updated)); reset(mapSettingsToForm(updated));
toast.success("Reset to default"); toast.success("Reset to default");

View File

@ -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<typeof renderWithQueryClient>[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(
<MemoryRouter>
<TracerLinksPage />
</MemoryRouter>,
);
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(
<MemoryRouter>
<TracerLinksPage />
</MemoryRouter>,
);
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(
<MemoryRouter>
<TracerLinksPage />
</MemoryRouter>,
);
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 }),
);
});
});
});

View File

@ -2,15 +2,16 @@ import * as api from "@client/api";
import { PageHeader, PageMain, SectionCard } from "@client/components/layout"; import { PageHeader, PageMain, SectionCard } from "@client/components/layout";
import type { import type {
JobTracerLinkAnalyticsItem, JobTracerLinkAnalyticsItem,
JobTracerLinksResponse,
TracerAnalyticsResponse, TracerAnalyticsResponse,
TracerAnalyticsTopJob, TracerAnalyticsTopJob,
} from "@shared/types.js"; } from "@shared/types.js";
import { useQuery } from "@tanstack/react-query";
import { BarChart3, Copy, ExternalLink, Loader2 } from "lucide-react"; import { BarChart3, Copy, ExternalLink, Loader2 } from "lucide-react";
import type React from "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 { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { toast } from "sonner"; import { toast } from "sonner";
import { queryKeys } from "@/client/lib/queryKeys";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@ -113,19 +114,14 @@ function formatRelativeTime(value: number | null): string {
} }
export const TracerLinksPage: React.FC = () => { export const TracerLinksPage: React.FC = () => {
const [analytics, setAnalytics] = useState<TracerAnalyticsResponse | null>( const [selectedDrilldownJobId, setSelectedDrilldownJobId] = useState<
null, string | null
); >(null);
const [jobDrilldown, setJobDrilldown] =
useState<JobTracerLinksResponse | null>(null);
const [fromDate, setFromDate] = useState(""); const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState(""); const [toDate, setToDate] = useState("");
const [includeBots, setIncludeBots] = useState(false); const [includeBots, setIncludeBots] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isDrilldownLoading, setIsDrilldownLoading] = useState(false);
const [isDrilldownOpen, setIsDrilldownOpen] = useState(false); const [isDrilldownOpen, setIsDrilldownOpen] = useState(false);
const [drilldownMode, setDrilldownMode] = useState<"human" | "all">("human"); const [drilldownMode, setDrilldownMode] = useState<"human" | "all">("human");
const [error, setError] = useState<string | null>(null);
const query = useMemo( const query = useMemo(
() => ({ () => ({
@ -137,62 +133,36 @@ export const TracerLinksPage: React.FC = () => {
[fromDate, toDate, includeBots], [fromDate, toDate, includeBots],
); );
const loadJobDrilldown = async (targetJobId: string) => { const analyticsQuery = useQuery<TracerAnalyticsResponse>({
if (!targetJobId) { queryKey: queryKeys.tracer.analytics(query),
setError("Enter a Job ID to load link drilldown."); queryFn: () => api.getTracerAnalytics(query),
setJobDrilldown(null); });
return; const analytics = analyticsQuery.data ?? null;
} const isLoading = analyticsQuery.isPending;
try { const jobDrilldownQuery = useQuery({
setIsDrilldownLoading(true); queryKey: queryKeys.tracer.jobLinks(selectedDrilldownJobId ?? "", {
setError(null); from: query.from,
const response = await api.getJobTracerLinks(targetJobId, { to: query.to,
includeBots,
}),
queryFn: () =>
api.getJobTracerLinks(selectedDrilldownJobId ?? "", {
from: query.from, from: query.from,
to: query.to, to: query.to,
includeBots, includeBots,
}); }),
setJobDrilldown(response); enabled: Boolean(isDrilldownOpen && selectedDrilldownJobId),
} catch (fetchError) { });
const message = const jobDrilldown = jobDrilldownQuery.data ?? null;
fetchError instanceof Error const isDrilldownLoading =
? fetchError.message jobDrilldownQuery.isPending || jobDrilldownQuery.isFetching;
: "Failed to load job tracer links."; const error =
setError(message); analyticsQuery.error instanceof Error
setJobDrilldown(null); ? analyticsQuery.error.message
} finally { : jobDrilldownQuery.error instanceof Error
setIsDrilldownLoading(false); ? jobDrilldownQuery.error.message
} : null;
};
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]);
const chartData = analytics?.timeSeries ?? []; const chartData = analytics?.timeSeries ?? [];
const totalViews = analytics?.totals.clicks ?? 0; const totalViews = analytics?.totals.clicks ?? 0;
@ -271,8 +241,8 @@ export const TracerLinksPage: React.FC = () => {
drilldownMode === "human" ? row.humanClicks : row.clicks; drilldownMode === "human" ? row.humanClicks : row.clicks;
const handleSelectTopJob = (job: TracerAnalyticsTopJob) => { const handleSelectTopJob = (job: TracerAnalyticsTopJob) => {
setSelectedDrilldownJobId(job.jobId);
setIsDrilldownOpen(true); setIsDrilldownOpen(true);
void loadJobDrilldown(job.jobId);
}; };
return ( return (

View File

@ -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 { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api"; import * as api from "../api";
import { renderWithQueryClient } from "../test/renderWithQueryClient";
import { TrackingInboxPage } from "./TrackingInboxPage"; import { TrackingInboxPage } from "./TrackingInboxPage";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui);
vi.mock("../api", () => ({ vi.mock("../api", () => ({
postApplicationProviderStatus: vi.fn(), postApplicationProviderStatus: vi.fn(),
getPostApplicationInbox: vi.fn(), getPostApplicationInbox: vi.fn(),

View File

@ -5,6 +5,7 @@ import type {
PostApplicationSyncRun, PostApplicationSyncRun,
} from "@shared/types"; } from "@shared/types";
import { POST_APPLICATION_PROVIDERS } from "@shared/types"; import { POST_APPLICATION_PROVIDERS } from "@shared/types";
import { useQuery } from "@tanstack/react-query";
import { import {
CheckCircle, CheckCircle,
Inbox, Inbox,
@ -18,6 +19,8 @@ import {
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
import { queryKeys } from "@/client/lib/queryKeys";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -57,6 +60,8 @@ const PROVIDER_OPTIONS: PostApplicationProvider[] = [
]; ];
const GMAIL_OAUTH_RESULT_TYPE = "gmail-oauth-result"; const GMAIL_OAUTH_RESULT_TYPE = "gmail-oauth-result";
const GMAIL_OAUTH_TIMEOUT_MS = 3 * 60 * 1000; const GMAIL_OAUTH_TIMEOUT_MS = 3 * 60 * 1000;
const EMPTY_INBOX_ITEMS: PostApplicationInboxItem[] = [];
const EMPTY_SYNC_RUNS: PostApplicationSyncRun[] = [];
type GmailOauthResultMessage = { type GmailOauthResultMessage = {
type: string; type: string;
@ -76,81 +81,106 @@ export const TrackingInboxPage: React.FC = () => {
const [maxMessages, setMaxMessages] = useState("100"); const [maxMessages, setMaxMessages] = useState("100");
const [searchDays, setSearchDays] = useState("90"); const [searchDays, setSearchDays] = useState("90");
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [isActionLoading, setIsActionLoading] = useState(false); const [isActionLoading, setIsActionLoading] = useState(false);
const [activeAction, setActiveAction] = useState< const [activeAction, setActiveAction] = useState<
"connect" | "sync" | "disconnect" | null "connect" | "sync" | "disconnect" | null
>(null); >(null);
const [status, setStatus] = useState<
| Awaited<ReturnType<typeof api.postApplicationProviderStatus>>["status"]
| null
>(null);
const [inbox, setInbox] = useState<PostApplicationInboxItem[]>([]);
const [runs, setRuns] = useState<PostApplicationSyncRun[]>([]);
const [isRunModalOpen, setIsRunModalOpen] = useState(false); const [isRunModalOpen, setIsRunModalOpen] = useState(false);
const [isRunMessagesLoading, setIsRunMessagesLoading] = useState(false);
const [selectedRun, setSelectedRun] = useState<PostApplicationSyncRun | null>( const [selectedRun, setSelectedRun] = useState<PostApplicationSyncRun | null>(
null, null,
); );
const [selectedRunItems, setSelectedRunItems] = useState<
PostApplicationInboxItem[]
>([]);
const [appliedJobByMessageId, setAppliedJobByMessageId] = useState< const [appliedJobByMessageId, setAppliedJobByMessageId] = useState<
Record<string, string> Record<string, string>
>({}); >({});
const [appliedJobs, setAppliedJobs] = useState<JobListItem[]>([]); const statusQuery = useQuery({
const [isAppliedJobsLoading, setIsAppliedJobsLoading] = useState(false); queryKey: queryKeys.postApplication.providerStatus(provider, accountKey),
const [hasAttemptedAppliedJobsLoad, setHasAttemptedAppliedJobsLoad] = queryFn: () => api.postApplicationProviderStatus({ provider, accountKey }),
useState(false); 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<JobListItem[]>(
() =>
(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<{ const [bulkActionDialog, setBulkActionDialog] = useState<{
isOpen: boolean; isOpen: boolean;
action: "approve" | "deny" | null; action: "approve" | "deny" | null;
itemCount: number; itemCount: number;
}>({ isOpen: false, action: null, itemCount: 0 }); }>({ isOpen: false, action: null, itemCount: 0 });
const isLoading =
const loadAppliedJobs = useCallback(async () => { statusQuery.isPending || inboxQuery.isPending || runsQuery.isPending;
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 refresh = useCallback(async () => { const refresh = useCallback(async () => {
setIsRefreshing(true); setIsRefreshing(true);
try { try {
await loadAll(); await Promise.all([
statusQuery.refetch(),
inboxQuery.refetch(),
runsQuery.refetch(),
hasReviewItems ? appliedJobsQuery.refetch() : Promise.resolve(),
]);
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error error instanceof Error
@ -159,36 +189,19 @@ export const TrackingInboxPage: React.FC = () => {
toast.error(message); toast.error(message);
} finally { } finally {
setIsRefreshing(false); setIsRefreshing(false);
setIsLoading(false);
} }
}, [loadAll]); }, [appliedJobsQuery, hasReviewItems, inboxQuery, runsQuery, statusQuery]);
useEffect(() => {
setIsLoading(true);
void refresh();
}, [refresh]);
useEffect(() => { useEffect(() => {
if (!provider || !accountKey) return; if (!provider || !accountKey) return;
setAppliedJobs([]);
setAppliedJobByMessageId({}); setAppliedJobByMessageId({});
setHasAttemptedAppliedJobsLoad(false);
}, [provider, accountKey]); }, [provider, accountKey]);
const hasReviewItems = useMemo(
() => inbox.length > 0 || selectedRunItems.length > 0,
[inbox.length, selectedRunItems.length],
);
useEffect(() => {
if (!hasReviewItems) return;
void loadAppliedJobs();
}, [hasReviewItems, loadAppliedJobs]);
useEffect(() => { useEffect(() => {
const defaultAppliedJobId = appliedJobs[0]?.id ?? ""; const defaultAppliedJobId = appliedJobs[0]?.id ?? "";
setAppliedJobByMessageId((previous) => { setAppliedJobByMessageId((previous) => {
const next = { ...previous }; const next = { ...previous };
let didChange = false;
for (const item of [...inbox, ...selectedRunItems]) { for (const item of [...inbox, ...selectedRunItems]) {
const selectedJobId = next[item.message.id]; const selectedJobId = next[item.message.id];
const hasValidSelection = appliedJobs.some( const hasValidSelection = appliedJobs.some(
@ -199,12 +212,16 @@ export const TrackingInboxPage: React.FC = () => {
const hasValidMatchedJob = appliedJobs.some( const hasValidMatchedJob = appliedJobs.some(
(appliedJob) => appliedJob.id === matchedJobId, (appliedJob) => appliedJob.id === matchedJobId,
); );
next[item.message.id] = hasValidMatchedJob const nextJobId = hasValidMatchedJob
? matchedJobId ? matchedJobId
: defaultAppliedJobId; : defaultAppliedJobId;
if (next[item.message.id] !== nextJobId) {
next[item.message.id] = nextJobId;
didChange = true;
}
} }
} }
return next; return didChange ? next : previous;
}); });
}, [appliedJobs, inbox, selectedRunItems]); }, [appliedJobs, inbox, selectedRunItems]);
@ -487,32 +504,24 @@ export const TrackingInboxPage: React.FC = () => {
[inbox], [inbox],
); );
const handleOpenRunMessages = useCallback( const handleOpenRunMessages = useCallback((run: PostApplicationSyncRun) => {
async (run: PostApplicationSyncRun) => { setSelectedRun(run);
setSelectedRun(run); setIsRunModalOpen(true);
setSelectedRunItems([]); }, []);
setIsRunModalOpen(true);
setIsRunMessagesLoading(true);
try { useQueryErrorToast(
const response = await api.getPostApplicationRunMessages({ statusQuery.error,
runId: run.id, "Failed to load provider connection status",
provider, );
accountKey, useQueryErrorToast(inboxQuery.error, "Failed to load inbox");
}); useQueryErrorToast(runsQuery.error, "Failed to load sync runs");
setSelectedRun(response.run); useQueryErrorToast(
setSelectedRunItems(response.items); appliedJobsQuery.error,
} catch (error) { "Failed to load jobs for inbox matching",
const message = );
error instanceof Error useQueryErrorToast(
? error.message runMessagesQuery.error,
: "Failed to load messages for selected sync run"; "Failed to load messages for selected sync run",
toast.error(message);
} finally {
setIsRunMessagesLoading(false);
}
},
[accountKey, provider],
); );
const pendingCount = inbox.length; const pendingCount = inbox.length;
@ -789,7 +798,6 @@ export const TrackingInboxPage: React.FC = () => {
onOpenChange={(open) => { onOpenChange={(open) => {
setIsRunModalOpen(open); setIsRunModalOpen(open);
if (!open) { if (!open) {
setSelectedRunItems([]);
setSelectedRun(null); setSelectedRun(null);
} }
}} }}

View File

@ -8,6 +8,7 @@ import type {
VisaSponsorSearchResult, VisaSponsorSearchResult,
VisaSponsorStatusResponse, VisaSponsorStatusResponse,
} from "@shared/types.js"; } from "@shared/types.js";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertCircle, AlertCircle,
Building2, Building2,
@ -23,8 +24,10 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
import { queryKeys } from "@/client/lib/queryKeys";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"; import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
@ -56,18 +59,13 @@ const getScoreTokens = (score: number) => {
}; };
export const VisaSponsorsPage: React.FC = () => { export const VisaSponsorsPage: React.FC = () => {
const queryClient = useQueryClient();
// State // State
const [status, setStatus] = useState<VisaSponsorStatusResponse | null>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [results, setResults] = useState<VisaSponsorSearchResult[]>([]); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const [selectedOrg, setSelectedOrg] = useState<string | null>(null); const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
const [orgDetails, setOrgDetails] = useState<VisaSponsor[]>([]);
// Loading states // 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 [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
const [isDesktop, setIsDesktop] = useState(() => const [isDesktop, setIsDesktop] = useState(() =>
typeof window !== "undefined" typeof window !== "undefined"
@ -75,80 +73,56 @@ export const VisaSponsorsPage: React.FC = () => {
: false, : false,
); );
// Fetch organization details const statusQuery = useQuery<VisaSponsorStatusResponse>({
const fetchOrgDetails = useCallback(async (orgName: string) => { queryKey: queryKeys.visaSponsors.status(),
setIsLoadingDetails(true); queryFn: api.getVisaSponsorStatus,
setSelectedOrg(orgName); });
try { const status = statusQuery.data ?? null;
const details = await api.getVisaSponsorOrganization(orgName); useQueryErrorToast(statusQuery.error, "Failed to fetch status");
setOrgDetails(details);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to fetch details";
toast.error(message);
setOrgDetails([]);
} finally {
setIsLoadingDetails(false);
}
}, []);
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(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
handleSearch(searchQuery); setDebouncedSearchQuery(searchQuery);
}, 300); }, 300);
return () => clearTimeout(timer); 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<VisaSponsor[]>({
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<VisaSponsorSearchResult[]>(() => {
if (!debouncedSearchQuery.trim()) return [];
return searchQueryResult.data?.results ?? [];
}, [debouncedSearchQuery, searchQueryResult.data]);
// Auto-select first result // Auto-select first result
useEffect(() => { useEffect(() => {
if (results.length === 0) { if (results.length === 0) {
setSelectedOrg(null); setSelectedOrg(null);
setOrgDetails([]);
return; return;
} }
if ( if (
@ -157,9 +131,8 @@ export const VisaSponsorsPage: React.FC = () => {
) { ) {
const firstOrg = results[0].sponsor.organisationName; const firstOrg = results[0].sponsor.organisationName;
setSelectedOrg(firstOrg); setSelectedOrg(firstOrg);
fetchOrgDetails(firstOrg);
} }
}, [results, fetchOrgDetails, selectedOrg]); }, [results, selectedOrg]);
useEffect(() => { useEffect(() => {
if (!selectedOrg) { if (!selectedOrg) {
@ -187,25 +160,33 @@ export const VisaSponsorsPage: React.FC = () => {
}, [isDesktop, isDetailDrawerOpen]); }, [isDesktop, isDetailDrawerOpen]);
// Trigger manual update // Trigger manual update
const handleUpdate = async () => { const updateListMutation = useMutation({
setIsUpdating(true); mutationFn: api.updateVisaSponsorList,
try { onSuccess: async (result) => {
const result = await api.updateVisaSponsorList(); queryClient.setQueryData(queryKeys.visaSponsors.status(), result.status);
setStatus(result.status); if (debouncedSearchQuery.trim()) {
toast.success(result.message); await queryClient.invalidateQueries({
if (searchQuery.trim()) { queryKey: queryKeys.visaSponsors.search(
handleSearch(searchQuery); debouncedSearchQuery.trim(),
100,
20,
),
});
} }
} catch (err) { toast.success(result.message);
const message = err instanceof Error ? err.message : "Update failed"; },
onError: (error) => {
const message = error instanceof Error ? error.message : "Update failed";
toast.error(message); toast.error(message);
} finally { },
setIsUpdating(false); });
}
const handleUpdate = async () => {
await updateListMutation.mutateAsync();
}; };
const handleSelectOrg = (orgName: string) => { const handleSelectOrg = (orgName: string) => {
fetchOrgDetails(orgName); setSelectedOrg(orgName);
if (!isDesktop) { if (!isDesktop) {
setIsDetailDrawerOpen(true); setIsDetailDrawerOpen(true);
} }
@ -217,7 +198,10 @@ export const VisaSponsorsPage: React.FC = () => {
[results, selectedOrg], [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 ? ( const detailPanelContent = !selectedOrg ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center"> <div className="flex h-full flex-col items-center justify-center gap-2 text-center">
@ -412,9 +396,9 @@ export const VisaSponsorsPage: React.FC = () => {
<Button <Button
size="sm" size="sm"
onClick={handleUpdate} onClick={handleUpdate}
disabled={isUpdating} disabled={isUpdateInProgress}
> >
{isUpdating ? ( {isUpdateInProgress ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Downloading... Downloading...

View File

@ -1,17 +1,15 @@
import { createJob } from "@shared/testing/factories.js"; import { createJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.js"; import type { Job } from "@shared/types.js";
import { import { act, fireEvent, screen, waitFor } from "@testing-library/react";
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type React from "react"; import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../../api"; import * as api from "../../api";
import { renderWithQueryClient } from "../../test/renderWithQueryClient";
import { JobDetailPanel } from "./JobDetailPanel"; import { JobDetailPanel } from "./JobDetailPanel";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui);
vi.mock("@/components/ui/dropdown-menu", () => { vi.mock("@/components/ui/dropdown-menu", () => {
return { return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => ( DropdownMenu: ({ children }: { children: React.ReactNode }) => (

View File

@ -1,8 +1,12 @@
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../../api"; import * as api from "../../api";
import { renderHookWithQueryClient } from "../../test/renderWithQueryClient";
import { useOrchestratorData } from "./useOrchestratorData"; import { useOrchestratorData } from "./useOrchestratorData";
const renderHook = (callback: () => ReturnType<typeof useOrchestratorData>) =>
renderHookWithQueryClient(callback);
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
getJobs: vi.fn(), getJobs: vi.fn(),
getJobsRevision: vi.fn(), getJobsRevision: vi.fn(),

View File

@ -1,6 +1,8 @@
import type { Job, JobListItem, JobStatus } from "@shared/types"; import type { Job, JobListItem, JobStatus } from "@shared/types";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { queryKeys } from "@/client/lib/queryKeys";
import * as api from "../../api"; import * as api from "../../api";
import { subscribeToEventSource } from "../../lib/sse"; import { subscribeToEventSource } from "../../lib/sse";
@ -79,6 +81,7 @@ const buildTerminalSignature = ({
}; };
export const useOrchestratorData = (selectedJobId: string | null) => { export const useOrchestratorData = (selectedJobId: string | null) => {
const queryClient = useQueryClient();
const [jobListItems, setJobListItems] = useState<JobListItem[]>([]); const [jobListItems, setJobListItems] = useState<JobListItem[]>([]);
const [selectedJob, setSelectedJob] = useState<Job | null>(null); const [selectedJob, setSelectedJob] = useState<Job | null>(null);
const [stats, setStats] = useState<Record<JobStatus, number>>(initialStats); const [stats, setStats] = useState<Record<JobStatus, number>>(initialStats);
@ -166,7 +169,11 @@ export const useOrchestratorData = (selectedJobId: string | null) => {
async (jobId: string) => { async (jobId: string) => {
const seq = ++selectedJobRequestSeqRef.current; const seq = ++selectedJobRequestSeqRef.current;
try { try {
const fullJob = await api.getJob(jobId); const fullJob = await queryClient.fetchQuery({
queryKey: queryKeys.jobs.detail(jobId),
queryFn: () => api.getJob(jobId),
staleTime: 0,
});
selectedJobCacheRef.current.set(jobId, fullJob); selectedJobCacheRef.current.set(jobId, fullJob);
if ( if (
selectedJobId === jobId && selectedJobId === jobId &&
@ -182,7 +189,7 @@ export const useOrchestratorData = (selectedJobId: string | null) => {
toast.error(message); toast.error(message);
} }
}, },
[selectedJobId], [queryClient, selectedJobId],
); );
const loadJobs = useCallback(async () => { const loadJobs = useCallback(async () => {
@ -191,6 +198,7 @@ export const useOrchestratorData = (selectedJobId: string | null) => {
try { try {
setIsLoading(true); setIsLoading(true);
const data = await api.getJobs({ view: "list" }); const data = await api.getJobs({ view: "list" });
queryClient.setQueryData(queryKeys.jobs.list({ view: "list" }), data);
if (seq >= latestAppliedSeqRef.current) { if (seq >= latestAppliedSeqRef.current) {
latestAppliedSeqRef.current = seq; latestAppliedSeqRef.current = seq;
setJobListItems(data.jobs); setJobListItems(data.jobs);
@ -210,11 +218,15 @@ export const useOrchestratorData = (selectedJobId: string | null) => {
setIsLoading(false); setIsLoading(false);
} }
} }
}, []); }, [queryClient]);
const checkPipelineStatus = useCallback(async () => { const checkPipelineStatus = useCallback(async () => {
try { try {
const status = await api.getPipelineStatus(); const status = await queryClient.fetchQuery({
queryKey: queryKeys.pipeline.status(),
queryFn: () => api.getPipelineStatus(),
staleTime: 0,
});
const terminalStatus = status.lastRun?.status; const terminalStatus = status.lastRun?.status;
if (status.isRunning) { if (status.isRunning) {
@ -247,24 +259,31 @@ export const useOrchestratorData = (selectedJobId: string | null) => {
} catch { } catch {
// Ignore errors // Ignore errors
} }
}, [observePipelineState]); }, [observePipelineState, queryClient]);
const checkForJobChanges = useCallback(async () => { const checkForJobChanges = useCallback(async () => {
if (isRefreshPaused || !isDocumentVisible()) return; if (isRefreshPaused || !isDocumentVisible()) return;
try { try {
const revision = await api.getJobsRevision(); const revision = await queryClient.fetchQuery({
queryKey: queryKeys.jobs.revision(),
queryFn: () => api.getJobsRevision(),
staleTime: 0,
});
const previousRevision = lastRevisionRef.current; const previousRevision = lastRevisionRef.current;
if (previousRevision === null) { if (previousRevision === null) {
lastRevisionRef.current = revision.revision; lastRevisionRef.current = revision.revision;
return; return;
} }
if (revision.revision !== previousRevision) { if (revision.revision !== previousRevision) {
await queryClient.invalidateQueries({
queryKey: queryKeys.jobs.all,
});
await loadJobs(); await loadJobs();
} }
} catch { } catch {
// Ignore errors // Ignore errors
} }
}, [isRefreshPaused, loadJobs]); }, [isRefreshPaused, loadJobs, queryClient]);
useEffect(() => { useEffect(() => {
void loadJobs(); void loadJobs();

View File

@ -0,0 +1,45 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type RenderOptions, render, renderHook } from "@testing-library/react";
import type { ReactElement, ReactNode } from "react";
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
});
}
export function renderWithQueryClient(
ui: ReactElement,
options?: Omit<RenderOptions, "wrapper">,
) {
const queryClient = createTestQueryClient();
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return {
queryClient,
...render(ui, { wrapper: Wrapper, ...options }),
};
}
export function renderHookWithQueryClient<TResult>(callback: () => TResult) {
const queryClient = createTestQueryClient();
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const rendered = renderHook(callback, { wrapper: Wrapper });
return {
queryClient,
...rendered,
};
}

27
package-lock.json generated
View File

@ -7913,6 +7913,32 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tanstack/query-core": {
"version": "5.90.20",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.21",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.20"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tokenizer/inflate": { "node_modules/@tokenizer/inflate": {
"version": "0.2.7", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
@ -24629,6 +24655,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.21",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"better-sqlite3": "^11.6.0", "better-sqlite3": "^11.6.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",