diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 0b7c38c..9b3cae5 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -21,7 +21,7 @@ import type { VisaSponsorStatusResponse, VisaSponsor, } from '../../shared/types'; -import { trackEvent } from '../lib/analytics'; +import { trackEvent } from "@/lib/analytics"; const API_BASE = '/api'; diff --git a/orchestrator/src/client/components/Header.tsx b/orchestrator/src/client/components/Header.tsx index ec014e7..feb70db 100644 --- a/orchestrator/src/client/components/Header.tsx +++ b/orchestrator/src/client/components/Header.tsx @@ -33,6 +33,7 @@ import { SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; +import { sourceLabel } from "@/lib/utils"; import type { JobSource } from "../../shared/types"; interface HeaderProps { @@ -55,14 +56,6 @@ export const Header: React.FC = ({ const location = useLocation(); const [sheetOpen, setSheetOpen] = React.useState(false); - const sourceLabel: Record = { - gradcracker: "Gradcracker", - indeed: "Indeed", - linkedin: "LinkedIn", - ukvisajobs: "UK Visa Jobs", - manual: "Manual", - }; - const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"]; const navLinks = [ diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index 654114b..7097766 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -39,8 +39,7 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { cn } from "@/lib/utils"; -import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy"; +import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils"; import * as api from "../api"; import { FitAssessment } from "."; import type { Job, ResumeProjectCatalogItem } from "../../shared/types"; diff --git a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx index 1aaec68..6678fcf 100644 --- a/orchestrator/src/client/components/discovered-panel/DecideMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/DecideMode.tsx @@ -6,10 +6,10 @@ import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { FitAssessment } from "../FitAssessment"; -import { formatDate } from "../../lib/dateUtils"; +import { formatDate, sourceLabel } from "@/lib/utils"; import type { Job } from "../../../shared/types"; import { CollapsibleSection } from "./CollapsibleSection"; -import { getPlainDescription, sourceLabel } from "./helpers"; +import { getPlainDescription } from "./helpers"; interface DecideModeProps { job: Job; diff --git a/orchestrator/src/client/components/discovered-panel/helpers.ts b/orchestrator/src/client/components/discovered-panel/helpers.ts index 80e1d4c..f3d8342 100644 --- a/orchestrator/src/client/components/discovered-panel/helpers.ts +++ b/orchestrator/src/client/components/discovered-panel/helpers.ts @@ -1,19 +1,6 @@ +import { stripHtml } from "@/lib/utils"; import type { Job } from "../../../shared/types"; -export const stripHtml = (value: string) => - value - .replace(/<[^>]*>/g, " ") - .replace(/\s+/g, " ") - .trim(); - -export const sourceLabel: Record = { - gradcracker: "Gradcracker", - indeed: "Indeed", - linkedin: "LinkedIn", - ukvisajobs: "UK Visa Jobs", - manual: "Manual", -}; - export const getPlainDescription = (jobDescription?: string | null) => { if (!jobDescription) return "No description available."; if (jobDescription.includes("<") && jobDescription.includes(">")) { diff --git a/orchestrator/src/client/lib/dateUtils.ts b/orchestrator/src/client/lib/dateUtils.ts deleted file mode 100644 index 4154e22..0000000 --- a/orchestrator/src/client/lib/dateUtils.ts +++ /dev/null @@ -1,36 +0,0 @@ -export const formatDate = (dateStr?: string | null) => { - if (!dateStr) return null; - try { - const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T"); - const parsed = new Date(normalized); - if (Number.isNaN(parsed.getTime())) return dateStr; - return parsed.toLocaleDateString("en-GB", { - day: "numeric", - month: "short", - year: "numeric", - }); - } catch { - return dateStr; - } -}; - -export const formatDateTime = (dateStr?: string | null) => { - if (!dateStr) return null; - try { - const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T"); - const parsed = new Date(normalized); - if (Number.isNaN(parsed.getTime())) return dateStr; - const date = parsed.toLocaleDateString("en-GB", { - day: "numeric", - month: "short", - year: "numeric", - }); - const time = parsed.toLocaleTimeString("en-GB", { - hour: "2-digit", - minute: "2-digit", - }); - return `${date} ${time}`; - } catch { - return dateStr; - } -}; diff --git a/orchestrator/src/client/lib/jobCopy.ts b/orchestrator/src/client/lib/jobCopy.ts deleted file mode 100644 index da8e07e..0000000 --- a/orchestrator/src/client/lib/jobCopy.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Job } from "@shared/types"; - -export const formatJobForWebhook = (job: Job) => { - return JSON.stringify( - { - event: "job.completed", - sentAt: new Date().toISOString(), - job, - }, - null, - 2, - ); -}; - -export async function copyTextToClipboard(text: string) { - if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - return; - } - - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.setAttribute("readonly", ""); - textarea.style.position = "fixed"; - textarea.style.top = "0"; - textarea.style.left = "0"; - textarea.style.opacity = "0"; - - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - - const ok = document.execCommand("copy"); - document.body.removeChild(textarea); - - if (!ok) { - throw new Error("Copy failed"); - } -} diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index dc983f2..6859f06 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -11,7 +11,8 @@ import { Accordion } from "@/components/ui/accordion" import { Button } from "@/components/ui/button" import type { AppSettings, JobStatus, ResumeProjectsSettings } from "../../shared/types" import * as api from "../api" -import { arraysEqual, resumeProjectsEqual } from "./settings/utils" +import { arraysEqual } from "@/lib/utils" +import { resumeProjectsEqual } from "./settings/utils" import { DangerZoneSection } from "./settings/components/DangerZoneSection" import { GradcrackerSection } from "./settings/components/GradcrackerSection" import { JobCompleteWebhookSection } from "./settings/components/JobCompleteWebhookSection" diff --git a/orchestrator/src/client/pages/UkVisaJobsPage.tsx b/orchestrator/src/client/pages/UkVisaJobsPage.tsx index 5bb8667..07ee550 100644 --- a/orchestrator/src/client/pages/UkVisaJobsPage.tsx +++ b/orchestrator/src/client/pages/UkVisaJobsPage.tsx @@ -37,13 +37,10 @@ import { SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; -import { cn } from "@/lib/utils"; -import { formatDate, formatDateTime } from "../lib/dateUtils"; +import { cn, formatDate, formatDateTime, stripHtml } from "@/lib/utils"; import * as api from "../api"; import type { CreateJobInput } from "../../shared/types"; -const stripHtml = (value: string) => value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); - const clampText = (value: string, max = 160) => (value.length > max ? `${value.slice(0, max).trim()}...` : value); const jobKey = (job: CreateJobInput) => job.sourceJobId || job.jobUrl; diff --git a/orchestrator/src/client/pages/VisaSponsorsPage.tsx b/orchestrator/src/client/pages/VisaSponsorsPage.tsx index 00c1a10..a3b2f61 100644 --- a/orchestrator/src/client/pages/VisaSponsorsPage.tsx +++ b/orchestrator/src/client/pages/VisaSponsorsPage.tsx @@ -24,7 +24,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"; -import { cn } from "@/lib/utils"; +import { cn, formatDateTime } from "@/lib/utils"; import { PageHeader, StatusIndicator, @@ -43,26 +43,6 @@ import type { VisaSponsorStatusResponse, } from "../../shared/types"; -const formatDateTime = (dateStr?: string | null) => { - if (!dateStr) return "Never"; - try { - const parsed = new Date(dateStr); - if (Number.isNaN(parsed.getTime())) return dateStr; - const date = parsed.toLocaleDateString("en-GB", { - day: "numeric", - month: "short", - year: "numeric", - }); - const time = parsed.toLocaleTimeString("en-GB", { - hour: "2-digit", - minute: "2-digit", - }); - return `${date} ${time}`; - } catch { - return dateStr; - } -}; - const getScoreTokens = (score: number) => { if (score >= 90) return { badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200" }; @@ -329,7 +309,7 @@ export const VisaSponsorsPage: React.FC = () => { - {formatDateTime(status.lastUpdated)} + {formatDateTime(status.lastUpdated) || "Never"} )} diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx index 9670b20..3bc3f53 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx @@ -48,10 +48,14 @@ vi.mock("../../components/TailoringEditor", () => ({ TailoringEditor: () =>
, })); -vi.mock("@client/lib/jobCopy", () => ({ - copyTextToClipboard: vi.fn().mockResolvedValue(undefined), - formatJobForWebhook: vi.fn(() => "payload"), -})); +vi.mock("@/lib/utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + copyTextToClipboard: vi.fn().mockResolvedValue(undefined), + formatJobForWebhook: vi.fn(() => "payload"), + }; +}); vi.mock("../../api", () => ({ updateJob: vi.fn(), diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx index 7e472c0..63c5ee6 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx @@ -29,18 +29,15 @@ import { } from "@/components/ui/dropdown-menu"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; -import { cn } from "@/lib/utils"; -import { copyTextToClipboard, formatJobForWebhook } from "@client/lib/jobCopy"; +import { cn, copyTextToClipboard, formatDate, formatJobForWebhook, sourceLabel, safeFilenamePart, stripHtml } from "@/lib/utils"; import { DiscoveredPanel } from "../../components"; import { ReadyPanel } from "../../components/ReadyPanel"; import { TailoringEditor } from "../../components/TailoringEditor"; -import { formatDate } from "../../lib/dateUtils"; import * as api from "../../api"; import type { Job, JobStatus } from "../../../shared/types"; -import { defaultStatusToken, sourceLabel, statusTokens } from "./constants"; +import { defaultStatusToken, statusTokens } from "./constants"; import type { FilterTab } from "./constants"; -import { safeFilenamePart, stripHtml } from "./utils"; interface JobDetailPanelProps { activeTab: FilterTab; diff --git a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx index 544f9f0..3303e10 100644 --- a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx +++ b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx @@ -15,8 +15,9 @@ import { import { Input } from "@/components/ui/input"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { sourceLabel } from "@/lib/utils"; import type { JobSource } from "../../../shared/types"; -import { defaultSortDirection, sortLabels, sourceLabel, tabs } from "./constants"; +import { defaultSortDirection, sortLabels, tabs } from "./constants"; import type { FilterTab, JobSort } from "./constants"; interface OrchestratorFiltersProps { diff --git a/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.tsx b/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.tsx index 6ae86c6..68f339c 100644 --- a/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.tsx +++ b/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.tsx @@ -31,8 +31,9 @@ import { SheetTrigger, } from "@/components/ui/sheet"; +import { sourceLabel } from "@/lib/utils"; import type { JobSource } from "../../../shared/types"; -import { orderedSources, sourceLabel } from "./constants"; +import { orderedSources } from "./constants"; interface OrchestratorHeaderProps { navOpen: boolean; diff --git a/orchestrator/src/client/pages/orchestrator/constants.ts b/orchestrator/src/client/pages/orchestrator/constants.ts index 7e29691..0e05337 100644 --- a/orchestrator/src/client/pages/orchestrator/constants.ts +++ b/orchestrator/src/client/pages/orchestrator/constants.ts @@ -3,14 +3,6 @@ import type { JobSource, JobStatus } from "../../../shared/types"; export const DEFAULT_PIPELINE_SOURCES: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"]; export const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources"; -export const sourceLabel: Record = { - gradcracker: "Gradcracker", - indeed: "Indeed", - linkedin: "LinkedIn", - ukvisajobs: "UK Visa Jobs", - manual: "Manual", -}; - export const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"]; export const statusTokens: Record = { diff --git a/orchestrator/src/client/pages/orchestrator/utils.ts b/orchestrator/src/client/pages/orchestrator/utils.ts index 5ec3553..6102120 100644 --- a/orchestrator/src/client/pages/orchestrator/utils.ts +++ b/orchestrator/src/client/pages/orchestrator/utils.ts @@ -1,3 +1,4 @@ +import { safeFilenamePart, stripHtml } from "@/lib/utils"; import type { Job } from "../../../shared/types"; import type { FilterTab, JobSort } from "./constants"; @@ -71,10 +72,6 @@ export const jobMatchesQuery = (job: Job, query: string) => { return haystack.includes(normalized); }; -export const stripHtml = (value: string) => value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); - -export const safeFilenamePart = (value: string) => value.replace(/[^a-z0-9]/gi, "_"); - export const getJobCounts = (jobs: Job[]): Record => { const byTab: Record = { ready: 0, diff --git a/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.tsx b/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.tsx index 1ef1cc5..1ff3194 100644 --- a/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.tsx @@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types" -import { clampInt } from "../utils" +import { clampInt } from "@/lib/utils" type ResumeProjectsSectionProps = { resumeProjectsDraft: ResumeProjectsSettings | null diff --git a/orchestrator/src/client/pages/settings/utils.ts b/orchestrator/src/client/pages/settings/utils.ts index e68a84a..181d985 100644 --- a/orchestrator/src/client/pages/settings/utils.ts +++ b/orchestrator/src/client/pages/settings/utils.ts @@ -2,16 +2,9 @@ * Settings page helpers. */ +import { arraysEqual } from "@/lib/utils" import type { ResumeProjectsSettings } from "@shared/types" -export function arraysEqual(a: string[], b: string[]) { - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false - } - return true -} - export function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjectsSettings) { return ( a.maxProjects === b.maxProjects && @@ -19,9 +12,3 @@ export function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjects arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds) ) } - -export function clampInt(value: number, min: number, max: number) { - const int = Math.floor(value) - if (Number.isNaN(int)) return min - return Math.min(max, Math.max(min, int)) -} diff --git a/orchestrator/src/client/lib/analytics.ts b/orchestrator/src/lib/analytics.ts similarity index 86% rename from orchestrator/src/client/lib/analytics.ts rename to orchestrator/src/lib/analytics.ts index 4779eda..92d9fff 100644 --- a/orchestrator/src/client/lib/analytics.ts +++ b/orchestrator/src/lib/analytics.ts @@ -9,6 +9,6 @@ declare global { } export function trackEvent(event: string, data?: Record) { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; window.umami?.track(event, data); } diff --git a/orchestrator/src/lib/utils.ts b/orchestrator/src/lib/utils.ts index bd0c391..1d64e8c 100644 --- a/orchestrator/src/lib/utils.ts +++ b/orchestrator/src/lib/utils.ts @@ -1,6 +1,118 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" +import type { Job } from "@shared/types" +// --- CSS --- export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +// --- Dates --- +export const formatDate = (dateStr?: string | null) => { + if (!dateStr) return null; + try { + const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T"); + const parsed = new Date(normalized); + if (Number.isNaN(parsed.getTime())) return dateStr; + return parsed.toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); + } catch { + return dateStr; + } +}; + +export const formatDateTime = (dateStr?: string | null) => { + if (!dateStr) return null; + try { + const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T"); + const parsed = new Date(normalized); + if (Number.isNaN(parsed.getTime())) return dateStr; + const date = parsed.toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); + const time = parsed.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + }); + return `${date} ${time}`; + } catch { + return dateStr; + } +}; + +// --- DOM & Clipboard --- +export async function copyTextToClipboard(text: string) { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "0"; + textarea.style.opacity = "0"; + + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + const ok = document.execCommand("copy"); + document.body.removeChild(textarea); + + if (!ok) { + throw new Error("Copy failed"); + } +} + +// --- Text Processing --- +export const stripHtml = (value: string) => + value + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim(); + +export const safeFilenamePart = (value: string) => value.replace(/[^a-z0-9]/gi, "_"); + +// --- Comparisons & Math --- +export function arraysEqual(a: string[], b: string[]) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +export function clampInt(value: number, min: number, max: number) { + const int = Math.floor(value); + if (Number.isNaN(int)) return min; + return Math.min(max, Math.max(min, int)); +} + +// --- Job Specific Helpers --- +export const formatJobForWebhook = (job: Job) => { + return JSON.stringify( + { + event: "job.completed", + sentAt: new Date().toISOString(), + job, + }, + null, + 2, + ); +}; + +export const sourceLabel: Record = { + gradcracker: "Gradcracker", + indeed: "Indeed", + linkedin: "LinkedIn", + ukvisajobs: "UK Visa Jobs", + manual: "Manual", +};