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
- [Settings](/docs/features/settings)
- [Reactive Resume](/docs/features/reactive-resume)
- [Find Jobs and Apply Workflow](/docs/workflows/find-jobs-and-apply-workflow)
- [Post-Application Tracking](/docs/features/post-application-tracking)
- [Settings](/docs/next/features/settings)
- [Reactive Resume](/docs/next/features/reactive-resume)
- [Find Jobs and Apply Workflow](/docs/next/workflows/find-jobs-and-apply-workflow)
- [Post-Application Tracking](/docs/next/features/post-application-tracking)

View File

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

View File

@ -32,7 +32,8 @@
"features/in-progress-board",
"features/ghostwriter",
"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-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.21",
"@types/canvas-confetti": "^1.9.0",
"better-sqlite3": "^11.6.0",
"canvas-confetti": "^1.9.4",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,16 @@
import { createJob as createBaseJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../../api";
import { useProfile } from "../../hooks/useProfile";
import { _resetTracerReadinessCache } from "../../hooks/useTracerReadiness";
import { renderWithQueryClient } from "../../test/renderWithQueryClient";
import { TailorMode } from "./TailorMode";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui);
vi.mock("../../api", () => ({
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
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 type { DemoInfoResponse } from "@shared/types";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/client/lib/queryKeys";
export function useDemoInfo() {
const [demoInfo, setDemoInfo] = useState<DemoInfoResponse | null>(null);
useEffect(() => {
let isCancelled = false;
void api
.getDemoInfo()
.then((info) => {
if (!isCancelled) {
setDemoInfo(info);
}
})
.catch(() => {
if (!isCancelled) {
setDemoInfo(null);
}
});
return () => {
isCancelled = true;
};
}, []);
return demoInfo;
const { data } = useQuery<DemoInfoResponse | null>({
queryKey: queryKeys.demo.info(),
queryFn: async () => {
try {
return await api.getDemoInfo();
} catch {
return null;
}
},
});
return data ?? null;
}

View File

@ -1,98 +1,35 @@
import type { ResumeProfile } from "@shared/types";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { queryClient as appQueryClient } from "@/client/lib/queryClient";
import { queryKeys } from "@/client/lib/queryKeys";
import * as api from "../api";
let profileCache: ResumeProfile | null = null;
let profileError: Error | null = null;
const subscribers: Set<
(profile: ResumeProfile | null, error: Error | null) => void
> = new Set();
let isFetching = false;
/**
* Hook to get the full profile data from base.json.
* Caches the result to avoid re-fetching.
*/
export function useProfile() {
const [profile, setProfile] = useState<ResumeProfile | null>(profileCache);
const [error, setError] = useState<Error | null>(profileError);
useEffect(() => {
if (profileCache) {
setProfile(profileCache);
}
if (profileError) {
setError(profileError);
}
const handleUpdate = (
newProfile: ResumeProfile | null,
newError: Error | null,
) => {
setProfile(newProfile);
setError(newError);
};
subscribers.add(handleUpdate);
if (!profileCache && !isFetching) {
isFetching = true;
profileError = null;
api
.getProfile()
.then((data) => {
profileCache = data;
profileError = null;
subscribers.forEach((sub) => {
sub(data, null);
});
})
.catch((err) => {
profileError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach((sub) => {
sub(profileCache, profileError);
});
})
.finally(() => {
isFetching = false;
});
}
return () => {
subscribers.delete(handleUpdate);
};
}, []);
const {
data: profile = null,
error,
isLoading,
isFetching,
refetch,
} = useQuery<ResumeProfile | null>({
queryKey: queryKeys.profile.current(),
queryFn: api.getProfile,
});
const refreshProfile = async () => {
isFetching = true;
profileError = null;
subscribers.forEach((sub) => {
sub(profileCache, null);
});
try {
const data = await api.getProfile();
profileCache = data;
profileError = null;
subscribers.forEach((sub) => {
sub(data, null);
});
return data;
} catch (err) {
profileError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach((sub) => {
sub(profileCache, profileError);
});
throw profileError;
} finally {
isFetching = false;
}
const result = await refetch();
if (result.error) throw result.error;
return result.data ?? null;
};
return {
profile,
error,
isLoading: !profile && isFetching && !error,
error: error ?? null,
isLoading: isLoading || (!!isFetching && !profile && !error),
personName: profile?.basics?.name || "Resume",
refreshProfile,
};
@ -100,8 +37,5 @@ export function useProfile() {
/** @internal For testing only */
export function _resetProfileCache() {
profileCache = null;
profileError = null;
isFetching = false;
subscribers.clear();
appQueryClient.removeQueries({ queryKey: queryKeys.profile.all });
}

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

View File

@ -1,10 +1,10 @@
import { useCallback, useState } from "react";
import { toast } from "sonner";
import * as api from "../api";
import { useRescoreJobMutation } from "@/client/hooks/queries/useJobMutations";
export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
const [isRescoring, setIsRescoring] = useState(false);
const rescoreMutation = useRescoreJobMutation();
const rescoreJob = useCallback(
async (jobId?: string | null) => {
@ -12,7 +12,7 @@ export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
try {
setIsRescoring(true);
await api.rescoreJob(jobId);
await rescoreMutation.mutateAsync(jobId);
toast.success("Match recalculated");
await onJobUpdated();
} catch (error) {
@ -25,7 +25,7 @@ export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
setIsRescoring(false);
}
},
[onJobUpdated],
[onJobUpdated, rescoreMutation],
);
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 * as api from "../api";
import { renderHookWithQueryClient } from "../test/renderWithQueryClient";
import { _resetSettingsCache, useSettings } from "./useSettings";
vi.mock("../api", () => ({
@ -17,7 +18,7 @@ describe("useSettings", () => {
const mockSettings = { showSponsorInfo: false };
vi.mocked(api.getSettings).mockResolvedValue(mockSettings as any);
const { result } = renderHook(() => useSettings());
const { result } = renderHookWithQueryClient(() => useSettings());
// Should start in loading state
expect(result.current.settings).toBeNull();
@ -33,7 +34,7 @@ describe("useSettings", () => {
it("uses default values when settings are null", async () => {
vi.mocked(api.getSettings).mockResolvedValue(null as any);
const { result } = renderHook(() => useSettings());
const { result } = renderHookWithQueryClient(() => useSettings());
await waitFor(() => {
// settings is null, so showSponsorInfo should default to true
@ -48,7 +49,7 @@ describe("useSettings", () => {
vi.mocked(api.getSettings).mockResolvedValueOnce(initialSettings as any);
vi.mocked(api.getSettings).mockResolvedValueOnce(updatedSettings as any);
const { result } = renderHook(() => useSettings());
const { result } = renderHookWithQueryClient(() => useSettings());
await waitFor(() => {
expect(result.current.settings).toEqual(initialSettings);
@ -71,7 +72,7 @@ describe("useSettings", () => {
const mockError = new Error("Failed to fetch");
vi.mocked(api.getSettings).mockRejectedValue(mockError);
const { result } = renderHook(() => useSettings());
const { result } = renderHookWithQueryClient(() => useSettings());
await waitFor(() => {
expect(result.current.error).toEqual(mockError);

View File

@ -1,94 +1,31 @@
import type { AppSettings } from "@shared/types";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { queryClient as appQueryClient } from "@/client/lib/queryClient";
import { queryKeys } from "@/client/lib/queryKeys";
import * as api from "../api";
let settingsCache: AppSettings | null = null;
let settingsError: Error | null = null;
const subscribers: Set<
(settings: AppSettings | null, error: Error | null) => void
> = new Set();
let isFetching = false;
export function useSettings() {
const [settings, setSettings] = useState<AppSettings | null>(settingsCache);
const [error, setError] = useState<Error | null>(settingsError);
useEffect(() => {
if (settingsCache) {
setSettings(settingsCache);
}
if (settingsError) {
setError(settingsError);
}
const handleUpdate = (
newSettings: AppSettings | null,
newError: Error | null,
) => {
setSettings(newSettings);
setError(newError);
};
subscribers.add(handleUpdate);
if (!settingsCache && !isFetching) {
isFetching = true;
settingsError = null;
api
.getSettings()
.then((data) => {
settingsCache = data;
settingsError = null;
subscribers.forEach((sub) => {
sub(data, null);
});
})
.catch((err) => {
settingsError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach((sub) => {
sub(settingsCache, settingsError);
});
})
.finally(() => {
isFetching = false;
});
}
return () => {
subscribers.delete(handleUpdate);
};
}, []);
const {
data: settings = null,
error,
isLoading,
isFetching,
refetch,
} = useQuery<AppSettings | null>({
queryKey: queryKeys.settings.current(),
queryFn: api.getSettings,
});
const refreshSettings = async () => {
isFetching = true;
settingsError = null;
subscribers.forEach((sub) => {
sub(settingsCache, null);
});
try {
const data = await api.getSettings();
settingsCache = data;
settingsError = null;
subscribers.forEach((sub) => {
sub(data, null);
});
return data;
} catch (err) {
settingsError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach((sub) => {
sub(settingsCache, settingsError);
});
throw settingsError;
} finally {
isFetching = false;
}
const result = await refetch();
if (result.error) throw result.error;
return result.data ?? null;
};
return {
settings,
error,
isLoading: !settings && isFetching && !error,
error: error ?? null,
isLoading: isLoading || (!!isFetching && !settings && !error),
showSponsorInfo: settings?.showSponsorInfo ?? true,
refreshSettings,
};
@ -96,8 +33,5 @@ export function useSettings() {
/** @internal For testing only */
export function _resetSettingsCache() {
settingsCache = null;
settingsError = null;
isFetching = false;
subscribers.clear();
appQueryClient.removeQueries({ queryKey: queryKeys.settings.all });
}

View File

@ -1,101 +1,46 @@
import type { TracerReadinessResponse } from "@shared/types";
import { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { queryClient as appQueryClient } from "@/client/lib/queryClient";
import { queryKeys } from "@/client/lib/queryKeys";
import * as api from "../api";
let readinessCache: TracerReadinessResponse | null = null;
let readinessError: Error | null = null;
let isFetching = false;
const subscribers: Set<
(
readiness: TracerReadinessResponse | null,
error: Error | null,
loading: boolean,
) => void
> = new Set();
function notifySubscribers(
readiness: TracerReadinessResponse | null,
error: Error | null,
loading: boolean,
) {
for (const subscriber of subscribers) {
subscriber(readiness, error, loading);
}
}
async function runReadinessFetch(
force: boolean,
): Promise<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() {
const [readiness, setReadiness] = useState<TracerReadinessResponse | null>(
readinessCache,
);
const [error, setError] = useState<Error | null>(readinessError);
const [loading, setLoading] = useState<boolean>(
!readinessCache && isFetching,
);
useEffect(() => {
if (readinessCache) setReadiness(readinessCache);
if (readinessError) setError(readinessError);
const handleUpdate = (
nextReadiness: TracerReadinessResponse | null,
nextError: Error | null,
nextLoading: boolean,
) => {
setReadiness(nextReadiness);
setError(nextError);
setLoading(nextLoading);
};
subscribers.add(handleUpdate);
if (!readinessCache && !isFetching) {
void runReadinessFetch(false);
}
return () => {
subscribers.delete(handleUpdate);
};
}, []);
const queryClient = useQueryClient();
const {
data: readiness = null,
error,
isLoading,
isFetching,
refetch,
} = useQuery<TracerReadinessResponse | null>({
queryKey: queryKeys.tracer.readiness(false),
queryFn: () => api.getTracerReadiness({ force: false }),
});
const refreshReadiness = async (force = true) => {
return await runReadinessFetch(force);
if (!force) {
const result = await refetch();
if (result.error) throw result.error;
return result.data ?? null;
}
const data = await api.getTracerReadiness({ force: true });
await queryClient.invalidateQueries({
queryKey: queryKeys.tracer.readiness(false),
});
return data;
};
return {
readiness,
error,
isLoading: loading && !readiness,
isChecking: loading,
error: error ?? null,
isLoading: isLoading && !readiness,
isChecking: isFetching,
refreshReadiness,
};
}
/** @internal For testing only */
export function _resetTracerReadinessCache() {
readinessCache = null;
readinessError = null;
isFetching = false;
subscribers.clear();
appQueryClient.removeQueries({ queryKey: queryKeys.tracer.all });
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -1,17 +1,15 @@
import { createJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.js";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../../api";
import { renderWithQueryClient } from "../../test/renderWithQueryClient";
import { JobDetailPanel } from "./JobDetailPanel";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui);
vi.mock("@/components/ui/dropdown-menu", () => {
return {
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 * as api from "../../api";
import { renderHookWithQueryClient } from "../../test/renderWithQueryClient";
import { useOrchestratorData } from "./useOrchestratorData";
const renderHook = (callback: () => ReturnType<typeof useOrchestratorData>) =>
renderHookWithQueryClient(callback);
vi.mock("../../api", () => ({
getJobs: vi.fn(),
getJobsRevision: vi.fn(),

View File

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