Set automatic extractor defaults from user location (#227)

* Set automatic extractor defaults from user location

* Rename detected country helper
This commit is contained in:
Shaheer Sarfaraz 2026-02-22 14:41:06 +00:00 committed by GitHub
parent 39ef177953
commit 16acdf2b5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 334 additions and 18 deletions

View File

@ -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(
<AutomaticRunTab
open
settings={createAppSettings()}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin"]}
onToggleSource={vi.fn()}
onSetPipelineSources={vi.fn()}
isPipelineRunning={false}
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
/>,
);
expect(
screen.getByRole("combobox", { name: "United States" }),
).toBeInTheDocument();
});
it("loads persisted country from settings", () => {
render(
<AutomaticRunTab
@ -28,7 +62,11 @@ describe("AutomaticRunTab", () => {
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"]}

View File

@ -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<AutomaticRunTabProps> = ({
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(
hasExplicitLocationOverride
? (settings?.jobspyCountryIndeed?.value ??
settings?.searchCities?.value ??
DEFAULT_VALUES.country)
: (defaultLocationCountry ??
settings?.jobspyCountryIndeed?.value ??
settings?.searchCities?.value ??
DEFAULT_VALUES.country,
DEFAULT_VALUES.country),
);
const rememberedCountryKey = rememberedCountry || DEFAULT_VALUES.country;
const rememberedLocations = parseCityLocationsSetting(

View File

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

View File

@ -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<string, string> = {
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<CachedUserCountry>;
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;
}