Set automatic extractor defaults from user location (#227)
* Set automatic extractor defaults from user location * Rename detected country helper
This commit is contained in:
parent
39ef177953
commit
16acdf2b5e
@ -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"]}
|
||||
|
||||
@ -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(
|
||||
|
||||
59
orchestrator/src/lib/user-location.test.ts
Normal file
59
orchestrator/src/lib/user-location.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
206
orchestrator/src/lib/user-location.ts
Normal file
206
orchestrator/src/lib/user-location.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user