* initial implementation * onboarding doesn't pop until invalid values are present * link to job page * proactive inputs working slightly * onboarding gate reinstated * better proactive buttons * fully manual tracking for now. * edit and delete timeline events * status showing correctly * tests update * tests * Update orchestrator/src/server/services/applicationTracking.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/server/services/applicationTracking.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/server/services/applicationTracking.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/client/pages/job/Timeline.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/client/pages/JobPage.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * add tests for application tracking routes and remove unused actionId from client API * remove unnecessary await from synchronous transitionStage calls and improve test isolation * relax externalUrl validation to allow non-URL metadata * add toast notifications for data loading and event logging in JobPage * comments * fix: resolve type error in sponsor-matching.test.ts * fix ci * tests fix for github * lint * github comments * build fix * dedupe * format * types fix * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * formatting * title and group id are discrete fields * backfill * hide view button on page * show relevant dropdown options * confetti! * remove redundant * confirm delete is a custom element now * formatting * fix styling --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
144 lines
3.5 KiB
TypeScript
144 lines
3.5 KiB
TypeScript
import type { Job } from "@shared/types";
|
|
import { type ClassValue, clsx } from "clsx";
|
|
import { twMerge } from "tailwind-merge";
|
|
|
|
// --- 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 formatTimestamp = (value?: number | null) => {
|
|
if (!value) return "No due date";
|
|
return new Date(value * 1000).toLocaleDateString("en-GB", {
|
|
day: "numeric",
|
|
month: "short",
|
|
year: "numeric",
|
|
});
|
|
};
|
|
|
|
export const formatTimestampWithTime = (value?: number | null) => {
|
|
if (!value) return "No date";
|
|
const date = new Date(value * 1000);
|
|
const dateLabel = formatTimestamp(value);
|
|
const timeLabel = date.toLocaleTimeString("en-GB", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
return `${dateLabel} ${timeLabel}`;
|
|
};
|
|
|
|
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",
|
|
};
|