Shaheer Sarfaraz 6e771ce728
Timeline introduced (#38)
* 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>
2026-01-27 23:49:11 +00:00

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