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; +}