try and consolidate duplicate helpers

This commit is contained in:
DaKheera47 2026-01-20 07:58:49 +00:00
parent 48ed0933a2
commit d506966d4c
20 changed files with 141 additions and 168 deletions

View File

@ -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';

View File

@ -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 = [

View File

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

View File

@ -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;

View File

@ -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(">")) {

View File

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

View File

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

View File

@ -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"

View File

@ -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;

View File

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

View File

@ -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(),

View File

@ -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;

View File

@ -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 {

View File

@ -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;

View File

@ -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 }> = {

View File

@ -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,

View File

@ -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

View File

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

View File

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

View File

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