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 { createAppSettings } from "@shared/testing/factories.js";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import type React from "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";
|
import { AutomaticRunTab } from "./AutomaticRunTab";
|
||||||
|
|
||||||
|
const { getDetectedCountryKeyMock } = vi.hoisted(() => ({
|
||||||
|
getDetectedCountryKeyMock: vi.fn((): string | null => null),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/ui/tooltip", () => ({
|
vi.mock("@/components/ui/tooltip", () => ({
|
||||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
|
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
<>{children}</>
|
<>{children}</>
|
||||||
@ -17,7 +21,37 @@ vi.mock("@/components/ui/tooltip", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/user-location", () => ({
|
||||||
|
getDetectedCountryKey: getDetectedCountryKeyMock,
|
||||||
|
}));
|
||||||
|
|
||||||
describe("AutomaticRunTab", () => {
|
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", () => {
|
it("loads persisted country from settings", () => {
|
||||||
render(
|
render(
|
||||||
<AutomaticRunTab
|
<AutomaticRunTab
|
||||||
@ -28,7 +62,11 @@ describe("AutomaticRunTab", () => {
|
|||||||
default: ["backend engineer"],
|
default: ["backend engineer"],
|
||||||
override: null,
|
override: null,
|
||||||
},
|
},
|
||||||
jobspyCountryIndeed: { value: "us", default: "us", override: null },
|
jobspyCountryIndeed: {
|
||||||
|
value: "us",
|
||||||
|
default: "united kingdom",
|
||||||
|
override: "us",
|
||||||
|
},
|
||||||
searchCities: { value: "", default: "", override: null },
|
searchCities: { value: "", default: "", override: null },
|
||||||
})}
|
})}
|
||||||
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||||
@ -57,8 +95,8 @@ describe("AutomaticRunTab", () => {
|
|||||||
},
|
},
|
||||||
jobspyCountryIndeed: {
|
jobspyCountryIndeed: {
|
||||||
value: "usa/ca",
|
value: "usa/ca",
|
||||||
default: "usa/ca",
|
default: "united kingdom",
|
||||||
override: null,
|
override: "usa/ca",
|
||||||
},
|
},
|
||||||
searchCities: { value: "", default: "", override: null },
|
searchCities: { value: "", default: "", override: null },
|
||||||
})}
|
})}
|
||||||
@ -90,8 +128,8 @@ describe("AutomaticRunTab", () => {
|
|||||||
},
|
},
|
||||||
jobspyCountryIndeed: {
|
jobspyCountryIndeed: {
|
||||||
value: "united states",
|
value: "united states",
|
||||||
default: "united states",
|
default: "united kingdom",
|
||||||
override: null,
|
override: "united states",
|
||||||
},
|
},
|
||||||
searchCities: { value: "", default: "", override: null },
|
searchCities: { value: "", default: "", override: null },
|
||||||
})}
|
})}
|
||||||
@ -124,8 +162,8 @@ describe("AutomaticRunTab", () => {
|
|||||||
},
|
},
|
||||||
jobspyCountryIndeed: {
|
jobspyCountryIndeed: {
|
||||||
value: "united states",
|
value: "united states",
|
||||||
default: "united states",
|
default: "united kingdom",
|
||||||
override: null,
|
override: "united states",
|
||||||
},
|
},
|
||||||
searchCities: { value: "", default: "", override: null },
|
searchCities: { value: "", default: "", override: null },
|
||||||
})}
|
})}
|
||||||
@ -159,8 +197,8 @@ describe("AutomaticRunTab", () => {
|
|||||||
},
|
},
|
||||||
jobspyCountryIndeed: {
|
jobspyCountryIndeed: {
|
||||||
value: "japan",
|
value: "japan",
|
||||||
default: "japan",
|
default: "united kingdom",
|
||||||
override: null,
|
override: "japan",
|
||||||
},
|
},
|
||||||
searchCities: { value: "", default: "", override: null },
|
searchCities: { value: "", default: "", override: null },
|
||||||
})}
|
})}
|
||||||
@ -199,12 +237,12 @@ describe("AutomaticRunTab", () => {
|
|||||||
jobspyCountryIndeed: {
|
jobspyCountryIndeed: {
|
||||||
value: "united kingdom",
|
value: "united kingdom",
|
||||||
default: "united kingdom",
|
default: "united kingdom",
|
||||||
override: null,
|
override: "united kingdom",
|
||||||
},
|
},
|
||||||
searchCities: {
|
searchCities: {
|
||||||
value: "United Kingdom",
|
value: "United Kingdom",
|
||||||
default: "United Kingdom",
|
default: "United Kingdom",
|
||||||
override: null,
|
override: "United Kingdom",
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
enabledSources={["linkedin", "glassdoor"]}
|
enabledSources={["linkedin", "glassdoor"]}
|
||||||
@ -240,7 +278,7 @@ describe("AutomaticRunTab", () => {
|
|||||||
jobspyCountryIndeed: {
|
jobspyCountryIndeed: {
|
||||||
value: "united kingdom",
|
value: "united kingdom",
|
||||||
default: "united kingdom",
|
default: "united kingdom",
|
||||||
override: null,
|
override: "united kingdom",
|
||||||
},
|
},
|
||||||
searchCities: { value: "", default: "", override: null },
|
searchCities: { value: "", default: "", override: null },
|
||||||
})}
|
})}
|
||||||
@ -278,12 +316,12 @@ describe("AutomaticRunTab", () => {
|
|||||||
jobspyCountryIndeed: {
|
jobspyCountryIndeed: {
|
||||||
value: "united kingdom",
|
value: "united kingdom",
|
||||||
default: "united kingdom",
|
default: "united kingdom",
|
||||||
override: null,
|
override: "united kingdom",
|
||||||
},
|
},
|
||||||
searchCities: {
|
searchCities: {
|
||||||
value: "London|Manchester",
|
value: "London|Manchester",
|
||||||
default: "London|Manchester",
|
default: "London|Manchester",
|
||||||
override: null,
|
override: "London|Manchester",
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
enabledSources={["linkedin", "glassdoor"]}
|
enabledSources={["linkedin", "glassdoor"]}
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { getDetectedCountryKey } from "@/lib/user-location";
|
||||||
import { sourceLabel } from "@/lib/utils";
|
import { sourceLabel } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
AUTOMATIC_PRESETS,
|
AUTOMATIC_PRESETS,
|
||||||
@ -185,10 +186,22 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
settings?.gradcrackerMaxJobsPerTerm?.value ??
|
settings?.gradcrackerMaxJobsPerTerm?.value ??
|
||||||
settings?.ukvisajobsMaxJobs?.value ??
|
settings?.ukvisajobsMaxJobs?.value ??
|
||||||
DEFAULT_VALUES.runBudget;
|
DEFAULT_VALUES.runBudget;
|
||||||
|
const hasExplicitLocationOverride = Boolean(
|
||||||
|
settings?.jobspyCountryIndeed?.override ||
|
||||||
|
settings?.searchCities?.override,
|
||||||
|
);
|
||||||
|
const defaultLocationCountry = !hasExplicitLocationOverride
|
||||||
|
? getDetectedCountryKey()
|
||||||
|
: null;
|
||||||
const rememberedCountry = normalizeUiCountryKey(
|
const rememberedCountry = normalizeUiCountryKey(
|
||||||
settings?.jobspyCountryIndeed?.value ??
|
hasExplicitLocationOverride
|
||||||
settings?.searchCities?.value ??
|
? (settings?.jobspyCountryIndeed?.value ??
|
||||||
DEFAULT_VALUES.country,
|
settings?.searchCities?.value ??
|
||||||
|
DEFAULT_VALUES.country)
|
||||||
|
: (defaultLocationCountry ??
|
||||||
|
settings?.jobspyCountryIndeed?.value ??
|
||||||
|
settings?.searchCities?.value ??
|
||||||
|
DEFAULT_VALUES.country),
|
||||||
);
|
);
|
||||||
const rememberedCountryKey = rememberedCountry || DEFAULT_VALUES.country;
|
const rememberedCountryKey = rememberedCountry || DEFAULT_VALUES.country;
|
||||||
const rememberedLocations = parseCityLocationsSetting(
|
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