diff --git a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx
index 405cb73..c919a43 100644
--- a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx
+++ b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx
@@ -1,9 +1,13 @@
import { createAppSettings } from "@shared/testing/factories.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import { AutomaticRunTab } from "./AutomaticRunTab";
+const { getDetectedCountryKeyMock } = vi.hoisted(() => ({
+ getDetectedCountryKeyMock: vi.fn((): string | null => null),
+}));
+
vi.mock("@/components/ui/tooltip", () => ({
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
<>{children}>
@@ -17,7 +21,37 @@ vi.mock("@/components/ui/tooltip", () => ({
),
}));
+vi.mock("@/lib/user-location", () => ({
+ getDetectedCountryKey: getDetectedCountryKeyMock,
+}));
+
describe("AutomaticRunTab", () => {
+ beforeEach(() => {
+ getDetectedCountryKeyMock.mockReset();
+ getDetectedCountryKeyMock.mockReturnValue(null);
+ });
+
+ it("uses detected country when location settings are still defaults", () => {
+ getDetectedCountryKeyMock.mockReturnValueOnce("united states");
+
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole("combobox", { name: "United States" }),
+ ).toBeInTheDocument();
+ });
+
it("loads persisted country from settings", () => {
render(
{
default: ["backend engineer"],
override: null,
},
- jobspyCountryIndeed: { value: "us", default: "us", override: null },
+ jobspyCountryIndeed: {
+ value: "us",
+ default: "united kingdom",
+ override: "us",
+ },
searchCities: { value: "", default: "", override: null },
})}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
@@ -57,8 +95,8 @@ describe("AutomaticRunTab", () => {
},
jobspyCountryIndeed: {
value: "usa/ca",
- default: "usa/ca",
- override: null,
+ default: "united kingdom",
+ override: "usa/ca",
},
searchCities: { value: "", default: "", override: null },
})}
@@ -90,8 +128,8 @@ describe("AutomaticRunTab", () => {
},
jobspyCountryIndeed: {
value: "united states",
- default: "united states",
- override: null,
+ default: "united kingdom",
+ override: "united states",
},
searchCities: { value: "", default: "", override: null },
})}
@@ -124,8 +162,8 @@ describe("AutomaticRunTab", () => {
},
jobspyCountryIndeed: {
value: "united states",
- default: "united states",
- override: null,
+ default: "united kingdom",
+ override: "united states",
},
searchCities: { value: "", default: "", override: null },
})}
@@ -159,8 +197,8 @@ describe("AutomaticRunTab", () => {
},
jobspyCountryIndeed: {
value: "japan",
- default: "japan",
- override: null,
+ default: "united kingdom",
+ override: "japan",
},
searchCities: { value: "", default: "", override: null },
})}
@@ -199,12 +237,12 @@ describe("AutomaticRunTab", () => {
jobspyCountryIndeed: {
value: "united kingdom",
default: "united kingdom",
- override: null,
+ override: "united kingdom",
},
searchCities: {
value: "United Kingdom",
default: "United Kingdom",
- override: null,
+ override: "United Kingdom",
},
})}
enabledSources={["linkedin", "glassdoor"]}
@@ -240,7 +278,7 @@ describe("AutomaticRunTab", () => {
jobspyCountryIndeed: {
value: "united kingdom",
default: "united kingdom",
- override: null,
+ override: "united kingdom",
},
searchCities: { value: "", default: "", override: null },
})}
@@ -278,12 +316,12 @@ describe("AutomaticRunTab", () => {
jobspyCountryIndeed: {
value: "united kingdom",
default: "united kingdom",
- override: null,
+ override: "united kingdom",
},
searchCities: {
value: "London|Manchester",
default: "London|Manchester",
- override: null,
+ override: "London|Manchester",
},
})}
enabledSources={["linkedin", "glassdoor"]}
diff --git a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx
index bbe62b2..6dd82a1 100644
--- a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx
+++ b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx
@@ -27,6 +27,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import { getDetectedCountryKey } from "@/lib/user-location";
import { sourceLabel } from "@/lib/utils";
import {
AUTOMATIC_PRESETS,
@@ -185,10 +186,22 @@ export const AutomaticRunTab: React.FC = ({
settings?.gradcrackerMaxJobsPerTerm?.value ??
settings?.ukvisajobsMaxJobs?.value ??
DEFAULT_VALUES.runBudget;
+ const hasExplicitLocationOverride = Boolean(
+ settings?.jobspyCountryIndeed?.override ||
+ settings?.searchCities?.override,
+ );
+ const defaultLocationCountry = !hasExplicitLocationOverride
+ ? getDetectedCountryKey()
+ : null;
const rememberedCountry = normalizeUiCountryKey(
- settings?.jobspyCountryIndeed?.value ??
- settings?.searchCities?.value ??
- DEFAULT_VALUES.country,
+ hasExplicitLocationOverride
+ ? (settings?.jobspyCountryIndeed?.value ??
+ settings?.searchCities?.value ??
+ DEFAULT_VALUES.country)
+ : (defaultLocationCountry ??
+ settings?.jobspyCountryIndeed?.value ??
+ settings?.searchCities?.value ??
+ DEFAULT_VALUES.country),
);
const rememberedCountryKey = rememberedCountry || DEFAULT_VALUES.country;
const rememberedLocations = parseCityLocationsSetting(
diff --git a/orchestrator/src/lib/user-location.test.ts b/orchestrator/src/lib/user-location.test.ts
new file mode 100644
index 0000000..8644b93
--- /dev/null
+++ b/orchestrator/src/lib/user-location.test.ts
@@ -0,0 +1,59 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { detectUserCountryKey, getDetectedCountryKey } from "./user-location";
+
+describe("user-location", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ vi.restoreAllMocks();
+ });
+
+ it("detects a supported country from browser locale region", () => {
+ expect(
+ detectUserCountryKey({
+ languages: ["en-US", "en"],
+ language: "en",
+ timeZone: "Europe/London",
+ }),
+ ).toBe("united states");
+ });
+
+ it("falls back to timezone when locale has no region", () => {
+ expect(
+ detectUserCountryKey({
+ languages: ["en"],
+ language: "en",
+ timeZone: "America/New_York",
+ }),
+ ).toBe("united states");
+ });
+
+ it("returns cached country without re-detecting", () => {
+ localStorage.setItem(
+ "jobops.user-country-cache.v1",
+ JSON.stringify({
+ country: "united kingdom",
+ detectedAt: Date.now(),
+ }),
+ );
+
+ const result = getDetectedCountryKey();
+
+ expect(result).toBe("united kingdom");
+ });
+
+ it("caches detected country from browser signals", () => {
+ Object.defineProperty(window.navigator, "languages", {
+ configurable: true,
+ value: ["en-US"],
+ });
+ Object.defineProperty(window.navigator, "language", {
+ configurable: true,
+ value: "en-US",
+ });
+ const result = getDetectedCountryKey();
+ const cached = localStorage.getItem("jobops.user-country-cache.v1");
+
+ expect(result).toBe("united states");
+ expect(cached).toContain("united states");
+ });
+});
diff --git a/orchestrator/src/lib/user-location.ts b/orchestrator/src/lib/user-location.ts
new file mode 100644
index 0000000..5321f52
--- /dev/null
+++ b/orchestrator/src/lib/user-location.ts
@@ -0,0 +1,206 @@
+import {
+ normalizeCountryKey,
+ SUPPORTED_COUNTRY_KEYS,
+} from "@shared/location-support.js";
+
+const STORAGE_KEY = "jobops.user-country-cache.v1";
+const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
+
+interface CachedUserCountry {
+ country: string;
+ detectedAt: number;
+}
+
+const REGION_TO_COUNTRY_KEY: Record = {
+ ar: "argentina",
+ at: "austria",
+ au: "australia",
+ be: "belgium",
+ br: "brazil",
+ ca: "canada",
+ ch: "switzerland",
+ cl: "chile",
+ co: "colombia",
+ cz: "czechia",
+ de: "germany",
+ dk: "denmark",
+ eg: "egypt",
+ es: "spain",
+ fi: "finland",
+ fr: "france",
+ gb: "united kingdom",
+ gr: "greece",
+ hk: "hong kong",
+ hu: "hungary",
+ ie: "ireland",
+ in: "india",
+ it: "italy",
+ jp: "japan",
+ mx: "mexico",
+ my: "malaysia",
+ nl: "netherlands",
+ no: "norway",
+ nz: "new zealand",
+ pl: "poland",
+ pt: "portugal",
+ ro: "romania",
+ se: "sweden",
+ sg: "singapore",
+ tr: "turkey",
+ ua: "ukraine",
+ us: "united states",
+ vn: "vietnam",
+ za: "south africa",
+ uk: "united kingdom",
+};
+
+const TIMEZONE_TO_REGION: Array<[prefix: string, region: string]> = [
+ ["Europe/London", "gb"],
+ ["Europe/Dublin", "ie"],
+ ["Europe/Paris", "fr"],
+ ["Europe/Berlin", "de"],
+ ["Europe/Madrid", "es"],
+ ["Europe/Rome", "it"],
+ ["Europe/Amsterdam", "nl"],
+ ["Europe/Warsaw", "pl"],
+ ["Europe/Stockholm", "se"],
+ ["Europe/Zurich", "ch"],
+ ["Europe/Vienna", "at"],
+ ["America/New_York", "us"],
+ ["America/Detroit", "us"],
+ ["America/Chicago", "us"],
+ ["America/Denver", "us"],
+ ["America/Los_Angeles", "us"],
+ ["America/Phoenix", "us"],
+ ["America/Anchorage", "us"],
+ ["Pacific/Honolulu", "us"],
+ ["America/Toronto", "ca"],
+ ["America/Vancouver", "ca"],
+ ["America/Montreal", "ca"],
+ ["America/Edmonton", "ca"],
+ ["America/Winnipeg", "ca"],
+ ["Australia/", "au"],
+ ["Pacific/Auckland", "nz"],
+ ["Asia/Tokyo", "jp"],
+ ["Asia/Singapore", "sg"],
+ ["Asia/Hong_Kong", "hk"],
+ ["Asia/Kolkata", "in"],
+ ["Europe/Istanbul", "tr"],
+];
+
+function canUseStorage(): boolean {
+ return (
+ typeof localStorage !== "undefined" &&
+ typeof localStorage.getItem === "function" &&
+ typeof localStorage.setItem === "function"
+ );
+}
+
+function normalizeSupportedCountry(
+ value: string | null | undefined,
+): string | null {
+ const normalized = normalizeCountryKey(value);
+ if (!normalized) return null;
+ return SUPPORTED_COUNTRY_KEYS.includes(normalized) ? normalized : null;
+}
+
+function countryFromRegionCode(
+ regionCode: string | null | undefined,
+): string | null {
+ if (!regionCode) return null;
+ return normalizeSupportedCountry(
+ REGION_TO_COUNTRY_KEY[regionCode.toLowerCase()],
+ );
+}
+
+function extractRegionCodeFromLocaleTag(localeTag: string): string | null {
+ const parts = localeTag.replace(/_/g, "-").split("-");
+ for (let index = parts.length - 1; index >= 1; index -= 1) {
+ const part = parts[index];
+ if (/^[a-z]{2}$/i.test(part)) return part;
+ }
+ return null;
+}
+
+function countryFromTimezone(
+ timeZone: string | null | undefined,
+): string | null {
+ if (!timeZone) return null;
+ const matched = TIMEZONE_TO_REGION.find(([prefix]) =>
+ timeZone.startsWith(prefix),
+ );
+ if (!matched) return null;
+ return countryFromRegionCode(matched[1]);
+}
+
+export function detectUserCountryKey(input?: {
+ languages?: readonly string[] | null;
+ language?: string | null;
+ timeZone?: string | null;
+}): string | null {
+ const localeCandidates = [
+ ...(input?.languages ?? []),
+ input?.language ?? null,
+ ].filter(
+ (value): value is string =>
+ typeof value === "string" && value.trim().length > 0,
+ );
+
+ for (const locale of localeCandidates) {
+ const regionCode = extractRegionCodeFromLocaleTag(locale);
+ const country = countryFromRegionCode(regionCode);
+ if (country) return country;
+ }
+
+ return countryFromTimezone(input?.timeZone);
+}
+
+function readCachedUserCountry(now: number): string | null {
+ if (!canUseStorage()) return null;
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as Partial;
+ if (
+ typeof parsed.country !== "string" ||
+ typeof parsed.detectedAt !== "number"
+ ) {
+ return null;
+ }
+ if (now - parsed.detectedAt > CACHE_TTL_MS) return null;
+ return normalizeSupportedCountry(parsed.country);
+ } catch {
+ return null;
+ }
+}
+
+function writeCachedUserCountry(country: string, now: number): void {
+ if (!canUseStorage()) return;
+ try {
+ const payload: CachedUserCountry = { country, detectedAt: now };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
+ } catch {
+ // Ignore storage failures
+ }
+}
+
+export function getDetectedCountryKey(): string | null {
+ const now = Date.now();
+ const cached = readCachedUserCountry(now);
+ if (cached) return cached;
+
+ const detected = detectUserCountryKey({
+ languages:
+ typeof navigator !== "undefined" && Array.isArray(navigator.languages)
+ ? navigator.languages
+ : null,
+ language:
+ typeof navigator !== "undefined" && typeof navigator.language === "string"
+ ? navigator.language
+ : null,
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ });
+
+ if (detected) writeCachedUserCountry(detected, now);
+ return detected;
+}