try and consolidate duplicate helpers
This commit is contained in:
parent
48ed0933a2
commit
d506966d4c
@ -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';
|
||||
|
||||
|
||||
@ -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<HeaderProps> = ({
|
||||
const location = useLocation();
|
||||
const [sheetOpen, setSheetOpen] = React.useState(false);
|
||||
|
||||
const sourceLabel: Record<JobSource, string> = {
|
||||
gradcracker: "Gradcracker",
|
||||
indeed: "Indeed",
|
||||
linkedin: "LinkedIn",
|
||||
ukvisajobs: "UK Visa Jobs",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
|
||||
|
||||
const navLinks = [
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<Job["source"], string> = {
|
||||
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(">")) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 = () => {
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{formatDateTime(status.lastUpdated)}
|
||||
{formatDateTime(status.lastUpdated) || "Never"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -48,10 +48,14 @@ vi.mock("../../components/TailoringEditor", () => ({
|
||||
TailoringEditor: () => <div data-testid="tailoring-editor" />,
|
||||
}));
|
||||
|
||||
vi.mock("@client/lib/jobCopy", () => ({
|
||||
copyTextToClipboard: vi.fn().mockResolvedValue(undefined),
|
||||
formatJobForWebhook: vi.fn(() => "payload"),
|
||||
}));
|
||||
vi.mock("@/lib/utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/utils")>();
|
||||
return {
|
||||
...actual,
|
||||
copyTextToClipboard: vi.fn().mockResolvedValue(undefined),
|
||||
formatJobForWebhook: vi.fn(() => "payload"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
updateJob: vi.fn(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<JobSource, string> = {
|
||||
gradcracker: "Gradcracker",
|
||||
indeed: "Indeed",
|
||||
linkedin: "LinkedIn",
|
||||
ukvisajobs: "UK Visa Jobs",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
export const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
|
||||
|
||||
export const statusTokens: Record<JobStatus, { label: string; badge: string; dot: string }> = {
|
||||
|
||||
@ -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<FilterTab, number> => {
|
||||
const byTab: Record<FilterTab, number> = {
|
||||
ready: 0,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -9,6 +9,6 @@ declare global {
|
||||
}
|
||||
|
||||
export function trackEvent(event: string, data?: Record<string, unknown>) {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (typeof window === "undefined") return;
|
||||
window.umami?.track(event, data);
|
||||
}
|
||||
@ -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<Job["source"], string> = {
|
||||
gradcracker: "Gradcracker",
|
||||
indeed: "Indeed",
|
||||
linkedin: "LinkedIn",
|
||||
ukvisajobs: "UK Visa Jobs",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user