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>
This commit is contained in:
Shaheer Sarfaraz
2026-01-27 23:49:11 +00:00
committed by GitHub
co-authored by Copilot
parent ad6a79e74f
commit 6e771ce728
31 changed files with 2682 additions and 34 deletions
+3 -1
View File
@@ -1,4 +1,4 @@
/**
/**
* Main App component.
*/
@@ -8,6 +8,7 @@ import { CSSTransition, SwitchTransition } from "react-transition-group";
import { Toaster } from "@/components/ui/sonner";
import { OnboardingGate } from "./components/OnboardingGate";
import { JobPage } from "./pages/JobPage";
import { OrchestratorPage } from "./pages/OrchestratorPage";
import { SettingsPage } from "./pages/SettingsPage";
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
@@ -40,6 +41,7 @@ export const App: React.FC = () => {
<div ref={nodeRef}>
<Routes location={location}>
<Route path="/" element={<Navigate to="/ready" replace />} />
<Route path="/job/:id" element={<JobPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/ukvisajobs" element={<UkVisaJobsPage />} />
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
+72 -1
View File
@@ -5,9 +5,12 @@
import { trackEvent } from "@/lib/analytics";
import type {
ApiResponse,
ApplicationStage,
ApplicationTask,
AppSettings,
CreateJobInput,
Job,
JobOutcome,
JobSource,
JobsListResponse,
ManualJobDraft,
@@ -18,6 +21,9 @@ import type {
ResumeProfile,
ResumeProjectCatalogItem,
ResumeProjectsSettings,
StageEvent,
StageEventMetadata,
StageTransitionTarget,
UkVisaJobsImportResponse,
UkVisaJobsSearchResponse,
ValidationResult,
@@ -67,7 +73,7 @@ export async function getJobs(statuses?: string[]): Promise<JobsListResponse> {
}
export async function getJob(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}`);
return fetchApi<Job>(`/jobs/${id}?t=${Date.now()}`);
}
export async function updateJob(
@@ -130,6 +136,71 @@ export async function skipJob(id: string): Promise<Job> {
});
}
export async function getJobStageEvents(id: string): Promise<StageEvent[]> {
return fetchApi<StageEvent[]>(`/jobs/${id}/events?t=${Date.now()}`);
}
export async function getJobTasks(
id: string,
options?: { includeCompleted?: boolean },
): Promise<ApplicationTask[]> {
const params = new URLSearchParams();
if (options?.includeCompleted) params.set("includeCompleted", "1");
params.set("t", Date.now().toString());
const query = params.toString();
return fetchApi<ApplicationTask[]>(`/jobs/${id}/tasks?${query}`);
}
export async function transitionJobStage(
id: string,
input: {
toStage: StageTransitionTarget;
occurredAt?: number | null;
metadata?: StageEventMetadata | null;
outcome?: JobOutcome | null;
},
): Promise<StageEvent> {
return fetchApi<StageEvent>(`/jobs/${id}/stages`, {
method: "POST",
body: JSON.stringify(input),
});
}
export async function updateJobStageEvent(
id: string,
eventId: string,
input: {
toStage?: ApplicationStage;
occurredAt?: number | null;
metadata?: StageEventMetadata | null;
outcome?: JobOutcome | null;
},
): Promise<void> {
return fetchApi<void>(`/jobs/${id}/events/${eventId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export async function deleteJobStageEvent(
id: string,
eventId: string,
): Promise<void> {
return fetchApi<void>(`/jobs/${id}/events/${eventId}`, {
method: "DELETE",
});
}
export async function updateJobOutcome(
id: string,
input: { outcome: JobOutcome | null; closedAt?: number | null },
): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/outcome`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
// Pipeline API
export async function getPipelineStatus(): Promise<PipelineStatusResponse> {
return fetchApi<PipelineStatusResponse>("/pipeline/status");
@@ -0,0 +1,50 @@
import type React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface ConfirmDeleteProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title?: string;
description?: string;
}
export const ConfirmDelete: React.FC<ConfirmDeleteProps> = ({
isOpen,
onClose,
onConfirm,
title = "Are you sure?",
description = "This action cannot be undone. This will permanently delete this event from the timeline.",
}) => {
return (
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onClose}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
onConfirm();
onClose();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -1,5 +1,6 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type React from "react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "../../shared/types";
import { useSettings } from "../hooks/useSettings";
@@ -37,6 +38,8 @@ const mockJob: Job = {
salary: "£60,000",
deadline: "2025-12-31",
status: "discovered",
outcome: null,
closedAt: null,
source: "linkedin",
suitabilityScore: 85,
suitabilityReason: "Strong match",
@@ -46,6 +49,9 @@ const mockJob: Job = {
} as Job;
describe("JobHeader", () => {
const renderWithRouter = (ui: React.ReactElement) =>
render(<MemoryRouter>{ui}</MemoryRouter>);
beforeEach(() => {
vi.clearAllMocks();
(useSettings as any).mockReturnValue({
@@ -54,21 +60,37 @@ describe("JobHeader", () => {
});
it("renders basic job information", () => {
render(<JobHeader job={mockJob} />);
renderWithRouter(<JobHeader job={mockJob} />);
expect(screen.getByText("Software Engineer")).toBeInTheDocument();
expect(screen.getByText("Tech Corp")).toBeInTheDocument();
expect(screen.getByText("London")).toBeInTheDocument();
expect(screen.getByText("£60,000")).toBeInTheDocument();
});
it("links the title and view button to the job page", () => {
renderWithRouter(<JobHeader job={mockJob} />);
expect(
screen.getByRole("link", { name: "Software Engineer" }),
).toHaveAttribute("href", "/job/job-1");
expect(screen.getByRole("link", { name: /view/i })).toHaveAttribute(
"href",
"/job/job-1",
);
});
it("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => {
const onCheckSponsor = vi.fn().mockResolvedValue(undefined);
render(<JobHeader job={mockJob} onCheckSponsor={onCheckSponsor} />);
renderWithRouter(
<JobHeader job={mockJob} onCheckSponsor={onCheckSponsor} />,
);
const button = screen.getByText("Check Sponsorship Status");
expect(button).toBeInTheDocument();
fireEvent.click(button);
await act(async () => {
fireEvent.click(button);
});
expect(onCheckSponsor).toHaveBeenCalled();
});
@@ -79,7 +101,7 @@ describe("JobHeader", () => {
sponsorMatchScore: 98,
sponsorMatchNames: '["Tech Corp Ltd"]',
};
render(<JobHeader job={jobWithSponsor} />);
renderWithRouter(<JobHeader job={jobWithSponsor} />);
expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument();
});
@@ -90,7 +112,7 @@ describe("JobHeader", () => {
sponsorMatchScore: 85,
sponsorMatchNames: '["Techy Corp"]',
};
render(<JobHeader job={jobWithPotential} />);
renderWithRouter(<JobHeader job={jobWithPotential} />);
expect(screen.getByText("Potential Sponsor")).toBeInTheDocument();
});
@@ -101,7 +123,7 @@ describe("JobHeader", () => {
sponsorMatchScore: 40,
sponsorMatchNames: '["Other Corp"]',
};
render(<JobHeader job={jobNoSponsor} />);
renderWithRouter(<JobHeader job={jobNoSponsor} />);
expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument();
});
@@ -112,11 +134,23 @@ describe("JobHeader", () => {
});
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 };
render(<JobHeader job={jobWithSponsor} />);
renderWithRouter(<JobHeader job={jobWithSponsor} />);
expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument();
expect(
screen.queryByText("Check Sponsorship Status"),
).not.toBeInTheDocument();
});
it("hides the view button when already on a job page", () => {
render(
<MemoryRouter initialEntries={["/job/job-1"]}>
<JobHeader job={mockJob} />
</MemoryRouter>,
);
expect(
screen.queryByRole("link", { name: /view/i }),
).not.toBeInTheDocument();
});
});
@@ -1,6 +1,14 @@
import { Calendar, DollarSign, Loader2, MapPin, Search } from "lucide-react";
import {
ArrowUpRight,
Calendar,
DollarSign,
Loader2,
MapPin,
Search,
} from "lucide-react";
import type React from "react";
import { useMemo, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -172,6 +180,8 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
onCheckSponsor,
}) => {
const { showSponsorInfo } = useSettings();
const { pathname } = useLocation();
const isJobPage = pathname.startsWith("/job/");
const deadline = formatDate(job.deadline);
return (
@@ -179,19 +189,37 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
{/* Detail header: lighter weight than list items */}
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-base font-semibold text-foreground/90">
<Link
to={`/job/${job.id}`}
className="flex items-center gap-2 text-base font-semibold underline-offset-2 text-foreground/90 hover:underline"
>
{job.title}
</div>
</Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{job.employer}</span>
</div>
</div>
<Badge
variant="outline"
className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50"
>
{sourceLabel[job.source]}
</Badge>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50"
>
{sourceLabel[job.source]}
</Badge>
{!isJobPage && (
<Button
asChild
size="sm"
variant="ghost"
className="h-6 px-2 text-[10px] uppercase tracking-wide"
>
<Link to={`/job/${job.id}`}>
View
<ArrowUpRight className="h-3 w-3" />
</Link>
</Button>
)}
</div>
</div>
{/* Tertiary metadata - subdued */}
@@ -0,0 +1,113 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { LogEventModal } from "./LogEventModal";
vi.mock("@/components/ui/alert-dialog", () => ({
AlertDialog: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogFooter: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogCancel: ({
children,
...props
}: {
children: React.ReactNode;
}) => (
<button type="button" {...props}>
{children}
</button>
),
}));
vi.mock("@/components/ui/select", () => ({
Select: ({
children,
value,
onValueChange,
}: {
children: React.ReactNode;
value?: string;
onValueChange?: (value: string) => void;
}) => (
<select
data-testid="select"
value={value}
onChange={(event) => onValueChange?.(event.target.value)}
>
{children}
</select>
),
SelectContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
SelectItem: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => <option value={value}>{children}</option>,
SelectTrigger: () => null,
SelectValue: () => null,
}));
describe("LogEventModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows the rejection reason selector and submits the form", async () => {
const onLog = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn();
render(<LogEventModal isOpen onClose={onClose} onLog={onLog} />);
const stageSelect = screen.getAllByTestId("select")[0];
fireEvent.change(stageSelect, { target: { value: "rejected" } });
expect(screen.getByText("Reason")).toBeInTheDocument();
const reasonSelect = screen.getAllByTestId("select")[1];
fireEvent.change(reasonSelect, { target: { value: "Visa" } });
fireEvent.click(screen.getByRole("button", { name: /log event/i }));
await waitFor(() =>
expect(onLog).toHaveBeenCalledWith(
expect.objectContaining({ stage: "rejected", reasonCode: "Visa" }),
undefined,
),
);
});
it("blocks submit when the title is cleared", async () => {
const onLog = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn();
render(<LogEventModal isOpen onClose={onClose} onLog={onLog} />);
const titleInput = screen.getByPlaceholderText("e.g. Recruiter Screen");
fireEvent.change(titleInput, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: /log event/i }));
expect(await screen.findByText("Title is required")).toBeInTheDocument();
expect(onLog).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,241 @@
import { zodResolver } from "@hookform/resolvers/zod";
import React from "react";
import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { StageEvent } from "../../shared/types";
import { STAGE_LABELS } from "../../shared/types";
const logEventSchema = z.object({
stage: z.string(),
title: z.string().min(1, "Title is required"),
date: z.string().min(1, "Date is required"),
notes: z.string().optional(),
reasonCode: z.string().optional(),
salary: z.string().optional(),
});
export type LogEventFormValues = z.infer<typeof logEventSchema>;
interface LogEventModalProps {
isOpen: boolean;
onClose: () => void;
onLog: (values: LogEventFormValues, eventId?: string) => Promise<void>;
editingEvent?: StageEvent | null;
}
const STAGE_OPTIONS = [
{ label: "No Stage Change (Keep current status)", value: "no_change" },
{ label: STAGE_LABELS.applied, value: "applied" },
{ label: STAGE_LABELS.recruiter_screen, value: "recruiter_screen" },
{ label: STAGE_LABELS.assessment, value: "assessment" },
{ label: STAGE_LABELS.hiring_manager_screen, value: "hiring_manager_screen" },
{ label: STAGE_LABELS.technical_interview, value: "technical_interview" },
{ label: STAGE_LABELS.onsite, value: "onsite" },
{ label: STAGE_LABELS.offer, value: "offer" },
{ label: "Rejected", value: "rejected" },
{ label: "Withdrawn", value: "withdrawn" },
{ label: STAGE_LABELS.closed, value: "closed" },
];
const REASON_CODES = ["Skills", "Visa", "Timing", "Culture", "Unknown"];
const toDateTimeLocal = (value: Date) => {
const pad = (num: number) => String(num).padStart(2, "0");
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(
value.getMinutes(),
)}`;
};
export const LogEventModal: React.FC<LogEventModalProps> = ({
isOpen,
onClose,
onLog,
editingEvent,
}) => {
const {
register,
handleSubmit,
control,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<LogEventFormValues>({
resolver: zodResolver(logEventSchema),
defaultValues: {
stage: "no_change",
title: "Update",
date: toDateTimeLocal(new Date()),
notes: "",
},
});
const selectedStage = watch("stage");
React.useEffect(() => {
if (isOpen) {
if (editingEvent) {
reset({
stage: editingEvent.toStage,
title: editingEvent.metadata?.eventLabel || "",
date: toDateTimeLocal(new Date(editingEvent.occurredAt * 1000)),
notes: editingEvent.metadata?.note || "",
reasonCode: editingEvent.metadata?.reasonCode || undefined,
salary: editingEvent.metadata?.externalUrl?.startsWith("Salary: ")
? editingEvent.metadata.externalUrl.replace("Salary: ", "")
: undefined,
});
} else {
reset({
stage: "no_change",
title: "Update",
date: toDateTimeLocal(new Date()),
notes: "",
});
}
}
}, [isOpen, reset, editingEvent]);
React.useEffect(() => {
// Only auto-update title if we're not editing or if the title is empty/default
if (!editingEvent) {
if (selectedStage === "no_change") {
setValue("title", "Update");
} else {
const option = STAGE_OPTIONS.find((o) => o.value === selectedStage);
if (option) {
setValue("title", option.label);
}
}
}
}, [selectedStage, setValue, editingEvent]);
const onSubmit = async (values: LogEventFormValues) => {
await onLog(values, editingEvent?.id);
onClose();
};
return (
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>
{editingEvent ? "Edit Event" : "Log Event"}
</AlertDialogTitle>
<AlertDialogDescription>
{editingEvent
? "Update the details of this event."
: "Record a new update or stage change for this application."}
</AlertDialogDescription>
</AlertDialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Field>
<FieldLabel>New Stage</FieldLabel>
<Controller
name="stage"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select stage" />
</SelectTrigger>
<SelectContent>
{STAGE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[errors.stage]} />
</Field>
<Field>
<FieldLabel>Event Title</FieldLabel>
<Input {...register("title")} placeholder="e.g. Recruiter Screen" />
<FieldError errors={[errors.title]} />
</Field>
<Field>
<FieldLabel>Date</FieldLabel>
<Input type="datetime-local" {...register("date")} />
<FieldError errors={[errors.date]} />
</Field>
<Field>
<FieldLabel>Notes (Optional)</FieldLabel>
<Textarea {...register("notes")} placeholder="Add details..." />
<FieldError errors={[errors.notes]} />
</Field>
{selectedStage === "rejected" && (
<Field className="animate-in fade-in slide-in-from-top-1 duration-200">
<FieldLabel>Reason</FieldLabel>
<Controller
name="reasonCode"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select reason" />
</SelectTrigger>
<SelectContent>
{REASON_CODES.map((code) => (
<SelectItem key={code} value={code}>
{code}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</Field>
)}
{selectedStage === "offer" && (
<Field className="animate-in fade-in slide-in-from-top-1 duration-200">
<FieldLabel>Salary / Details</FieldLabel>
<Input {...register("salary")} placeholder="e.g. £50k + bonus" />
</Field>
)}
<AlertDialogFooter className="pt-4">
<AlertDialogCancel type="button" onClick={onClose}>
Cancel
</AlertDialogCancel>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? "Saving..."
: editingEvent
? "Save Changes"
: "Log Event"}
</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -0,0 +1,126 @@
import * as api from "@client/api";
import { useSettings } from "@client/hooks/useSettings";
import { render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { OnboardingGate } from "./OnboardingGate";
vi.mock("@client/api", () => ({
validateOpenrouter: vi.fn(),
validateRxresume: vi.fn(),
validateResumeConfig: vi.fn(),
updateSettings: vi.fn(),
}));
vi.mock("@client/hooks/useSettings", () => ({
useSettings: vi.fn(),
}));
vi.mock("@client/pages/settings/components/SettingsInput", () => ({
SettingsInput: ({ label }: { label: string }) => <div>{label}</div>,
}));
vi.mock("@client/pages/settings/components/BaseResumeSelection", () => ({
BaseResumeSelection: () => <div>Base resume selection</div>,
}));
vi.mock("@/components/ui/alert-dialog", () => ({
AlertDialog: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("@/components/ui/tabs", () => ({
Tabs: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TabsContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
TabsList: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
TabsTrigger: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
}));
vi.mock("@/components/ui/progress", () => ({
Progress: () => <div>Progress</div>,
}));
vi.mock("sonner", () => ({
toast: {
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
const settingsResponse = {
settings: {
openrouterApiKeyHint: null,
rxresumeEmail: "",
rxresumePasswordHint: null,
rxresumeBaseResumeId: null,
},
isLoading: false,
refreshSettings: vi.fn(),
};
describe("OnboardingGate", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useSettings).mockReturnValue(settingsResponse as any);
});
it("renders the gate once validations complete and any fail", async () => {
vi.mocked(api.validateOpenrouter).mockResolvedValue({
valid: false,
message: "Invalid",
});
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: true,
message: null,
});
vi.mocked(api.validateResumeConfig).mockResolvedValue({
valid: true,
message: null,
});
render(<OnboardingGate />);
await waitFor(() => expect(api.validateOpenrouter).toHaveBeenCalled());
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
});
it("hides the gate when all validations succeed", async () => {
vi.mocked(api.validateOpenrouter).mockResolvedValue({
valid: true,
message: null,
});
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: true,
message: null,
});
vi.mocked(api.validateResumeConfig).mockResolvedValue({
valid: true,
message: null,
});
render(<OnboardingGate />);
await waitFor(() => expect(api.validateOpenrouter).toHaveBeenCalled());
expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument();
});
});
@@ -126,8 +126,13 @@ export const OnboardingGate: React.FC = () => {
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint);
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim());
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint);
const hasCheckedValidations =
openrouterValidation.checked &&
rxresumeValidation.checked &&
baseResumeValidation.checked;
const shouldOpen =
Boolean(settings && !settingsLoading) &&
hasCheckedValidations &&
!(
openrouterValidation.valid &&
rxresumeValidation.valid &&
@@ -1,5 +1,6 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { MemoryRouter } from "react-router-dom";
import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "../../shared/types";
@@ -122,6 +123,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
outcome: null,
closedAt: null,
...overrides,
});
@@ -136,7 +139,13 @@ describe("ReadyPanel", () => {
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
render(
<ReadyPanel job={job} onJobUpdated={onJobUpdated} onJobMoved={vi.fn()} />,
<MemoryRouter>
<ReadyPanel
job={job}
onJobUpdated={onJobUpdated}
onJobMoved={vi.fn()}
/>
</MemoryRouter>,
);
fireEvent.click(
@@ -1,5 +1,6 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { MemoryRouter } from "react-router-dom";
import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "../../../shared/types";
@@ -115,6 +116,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
outcome: null,
closedAt: null,
...overrides,
});
@@ -129,11 +132,13 @@ describe("DiscoveredPanel", () => {
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
render(
<DiscoveredPanel
job={job}
onJobUpdated={onJobUpdated}
onJobMoved={vi.fn()}
/>,
<MemoryRouter>
<DiscoveredPanel
job={job}
onJobUpdated={onJobUpdated}
onJobMoved={vi.fn()}
/>
</MemoryRouter>,
);
fireEvent.click(
+375
View File
@@ -0,0 +1,375 @@
import confetti from "canvas-confetti";
import {
ArrowLeft,
CalendarClock,
ClipboardList,
DollarSign,
PlusCircle,
} from "lucide-react";
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatTimestamp } from "@/lib/utils";
import {
type ApplicationStage,
type ApplicationTask,
type Job,
type JobOutcome,
STAGE_LABELS,
type StageEvent,
} from "../../shared/types";
import * as api from "../api";
import { ConfirmDelete } from "../components/ConfirmDelete";
import { JobHeader } from "../components/JobHeader";
import {
type LogEventFormValues,
LogEventModal,
} from "../components/LogEventModal";
import { JobTimeline } from "./job/Timeline";
export const JobPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [job, setJob] = React.useState<Job | null>(null);
const [events, setEvents] = React.useState<StageEvent[]>([]);
const [tasks, setTasks] = React.useState<ApplicationTask[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [isLogModalOpen, setIsLogModalOpen] = React.useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false);
const [eventToDelete, setEventToDelete] = React.useState<string | null>(null);
const [editingEvent, setEditingEvent] = React.useState<StageEvent | null>(
null,
);
const pendingEventRef = React.useRef<StageEvent | null>(null);
const loadData = React.useCallback(async () => {
if (!id) return;
setIsLoading(true);
try {
const jobData = await api.getJob(id);
setJob(jobData);
api
.getJobStageEvents(id)
.then((data) => setEvents(mergeEvents(data, pendingEventRef.current)))
.catch(() => toast.error("Failed to load stage events"));
api
.getJobTasks(id)
.then((data) => setTasks(data))
.catch(() => toast.error("Failed to load tasks"));
} finally {
setIsLoading(false);
}
}, [id]);
React.useEffect(() => {
loadData();
}, [loadData]);
const handleLogEvent = async (
values: LogEventFormValues,
eventId?: string,
) => {
if (!job) return;
let toStage: ApplicationStage | "no_change" = values.stage as
| ApplicationStage
| "no_change";
let outcome: JobOutcome | null = null;
if (values.stage === "rejected") {
toStage = "closed";
outcome = "rejected";
} else if (values.stage === "withdrawn") {
toStage = "closed";
outcome = "withdrawn";
}
const currentStage =
events.at(-1)?.toStage ?? (job.status === "applied" ? "applied" : null);
const effectiveStage =
toStage === "no_change" ? (currentStage ?? "applied") : toStage;
try {
if (eventId) {
await api.updateJobStageEvent(job.id, eventId, {
toStage: toStage === "no_change" ? undefined : toStage,
occurredAt: toTimestamp(values.date) ?? undefined,
metadata: {
note: values.notes?.trim() || undefined,
eventLabel: values.title.trim() || undefined,
reasonCode: values.reasonCode || undefined,
actor: "user",
eventType: values.stage === "no_change" ? "note" : "status_update",
externalUrl: values.salary ? `Salary: ${values.salary}` : undefined,
},
outcome,
});
} else {
const newEvent = await api.transitionJobStage(job.id, {
toStage: effectiveStage,
occurredAt: toTimestamp(values.date),
metadata: {
note: values.notes?.trim() || undefined,
eventLabel: values.title.trim() || undefined,
reasonCode: values.reasonCode || undefined,
actor: "user",
eventType: values.stage === "no_change" ? "note" : "status_update",
externalUrl: values.salary ? `Salary: ${values.salary}` : undefined,
},
outcome,
});
pendingEventRef.current = newEvent;
}
const [jobData, eventData] = await Promise.all([
api.getJob(job.id),
api.getJobStageEvents(job.id),
]);
setJob(jobData);
setEvents(eventData);
pendingEventRef.current = null;
setEditingEvent(null);
toast.success(eventId ? "Event updated" : "Event logged");
if (effectiveStage === "offer") {
confetti({
particleCount: 150,
spread: 70,
origin: { y: 0.6 },
colors: ["#10b981", "#34d399", "#6ee7b7", "#ffffff"],
});
}
} catch (error) {
console.error("Failed to log event:", error);
toast.error("Failed to log event");
}
};
const confirmDeleteEvent = (eventId: string) => {
setEventToDelete(eventId);
setIsDeleteModalOpen(true);
};
const handleDeleteEvent = async () => {
if (!job || !eventToDelete) return;
try {
await api.deleteJobStageEvent(job.id, eventToDelete);
const [jobData, eventData] = await Promise.all([
api.getJob(job.id),
api.getJobStageEvents(job.id),
]);
setJob(jobData);
setEvents(eventData);
toast.success("Event deleted");
} catch (error) {
console.error("Failed to delete event:", error);
toast.error("Failed to delete event");
} finally {
setIsDeleteModalOpen(false);
setEventToDelete(null);
}
};
const handleEditEvent = (event: StageEvent) => {
setEditingEvent(event);
setIsLogModalOpen(true);
};
const currentStage = job
? (events.at(-1)?.toStage ?? (job.status === "applied" ? "applied" : null))
: null;
if (!id) {
return null;
}
return (
<main className="container mx-auto max-w-6xl space-y-6 px-4 py-6 pb-12">
<div className="flex items-center justify-between gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<div className="flex items-center gap-3">
<Button
size="sm"
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setIsLogModalOpen(true)}
disabled={!job}
>
<PlusCircle className="mr-2 h-4 w-4" />
Log Event
</Button>
</div>
</div>
{job ? (
<JobHeader
job={job}
className="rounded-lg border border-border/40 bg-muted/5 p-4"
/>
) : (
<div className="rounded-lg border border-dashed border-border/40 p-6 text-sm text-muted-foreground">
{isLoading ? "Loading application..." : "Application not found."}
</div>
)}
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<Card className="border-border/50">
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-4">
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardList className="h-4 w-4" />
Stage timeline
</CardTitle>
<div className="flex items-center gap-2">
{job?.salary && (
<Badge
variant="outline"
className="border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400"
>
<DollarSign className="mr-1 h-3.5 w-3.5" />
{job.salary}
</Badge>
)}
{currentStage && (
<Badge
variant="secondary"
className="px-3 py-1 text-xs font-medium uppercase tracking-wider"
>
{STAGE_LABELS[currentStage as ApplicationStage] ||
currentStage}
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent>
<JobTimeline
events={events}
onEdit={handleEditEvent}
onDelete={confirmDeleteEvent}
/>
</CardContent>
</Card>
<div className="space-y-4">
<Card className="border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarClock className="h-4 w-4" />
Application details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
Current Stage
</div>
<div className="mt-1 text-sm font-medium">
{currentStage
? STAGE_LABELS[currentStage as ApplicationStage] ||
currentStage
: job?.status}
</div>
</div>
<div>
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
Outcome
</div>
<div className="mt-1 text-sm font-medium">
{job?.outcome ? job.outcome.replace(/_/g, " ") : "Open"}
</div>
</div>
{job?.closedAt && (
<div>
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
Closed On
</div>
<div className="mt-1 text-sm font-medium">
{formatTimestamp(job.closedAt)}
</div>
</div>
)}
</CardContent>
</Card>
{tasks.length > 0 && (
<Card className="border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarClock className="h-4 w-4" />
Upcoming tasks
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{tasks.map((task) => (
<div
key={task.id}
className="flex items-start justify-between gap-4"
>
<div className="space-y-1">
<div className="text-sm font-medium text-foreground/90">
{task.title}
</div>
{task.notes && (
<div className="text-xs text-muted-foreground">
{task.notes}
</div>
)}
</div>
<Badge
variant="outline"
className="text-[10px] uppercase tracking-wide"
>
{formatTimestamp(task.dueDate)}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
<LogEventModal
isOpen={isLogModalOpen}
onClose={() => {
setIsLogModalOpen(false);
setEditingEvent(null);
}}
onLog={handleLogEvent}
editingEvent={editingEvent}
/>
<ConfirmDelete
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false);
setEventToDelete(null);
}}
onConfirm={handleDeleteEvent}
/>
</main>
);
};
const toTimestamp = (value: string) => {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return Math.floor(date.getTime() / 1000);
};
const mergeEvents = (events: StageEvent[], pending: StageEvent | null) => {
if (!pending) return events;
if (events.some((event) => event.id === pending.id)) return events;
return [...events, pending].sort((a, b) => a.occurredAt - b.occurredAt);
};
@@ -24,6 +24,8 @@ const jobFixture: Job = {
starting: null,
jobDescription: "Build APIs",
status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 90,
suitabilityReason: null,
tailoredSummary: null,
@@ -0,0 +1,45 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { StageEvent } from "../../../shared/types";
import { JobTimeline } from "./Timeline";
const baseEvent: StageEvent = {
id: "event-1",
applicationId: "app-1",
fromStage: null,
toStage: "applied",
title: "Applied",
groupId: null,
occurredAt: 1735689600,
metadata: {
eventLabel: "Applied",
},
outcome: null,
};
describe("JobTimeline", () => {
it("renders edit and delete controls when callbacks are provided", () => {
const onEdit = vi.fn();
const onDelete = vi.fn();
render(
<JobTimeline events={[baseEvent]} onEdit={onEdit} onDelete={onDelete} />,
);
const editButton = screen.getByTitle("Edit event");
const deleteButton = screen.getByTitle("Delete event");
fireEvent.click(editButton);
fireEvent.click(deleteButton);
expect(onEdit).toHaveBeenCalledWith(baseEvent);
expect(onDelete).toHaveBeenCalledWith("event-1");
});
it("omits edit and delete controls when callbacks are missing", () => {
render(<JobTimeline events={[baseEvent]} />);
expect(screen.queryByTitle("Edit event")).not.toBeInTheDocument();
expect(screen.queryByTitle("Delete event")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,334 @@
import {
CheckCircle2,
ClipboardList,
Edit2,
FileText,
MailCheck,
PhoneCall,
Presentation,
Trash2,
UserRound,
Video,
} from "lucide-react";
import React from "react";
import { Badge } from "@/components/ui/badge";
import { cn, formatTimestamp, formatTimestampWithTime } from "@/lib/utils";
import {
type ApplicationStage,
STAGE_LABELS,
type StageEvent,
} from "../../../shared/types";
import { CollapsibleSection } from "../../components/discovered-panel/CollapsibleSection";
const stageIcons: Record<ApplicationStage, React.ReactNode> = {
applied: <CheckCircle2 className="h-4 w-4" />,
recruiter_screen: <PhoneCall className="h-4 w-4" />,
assessment: <FileText className="h-4 w-4" />,
hiring_manager_screen: <UserRound className="h-4 w-4" />,
technical_interview: <Video className="h-4 w-4" />,
onsite: <Presentation className="h-4 w-4" />,
offer: <MailCheck className="h-4 w-4" />,
closed: <ClipboardList className="h-4 w-4" />,
};
const formatRange = (start: number, end: number) => {
const startLabel = formatTimestamp(start);
const endLabel = formatTimestamp(end);
return startLabel === endLabel ? startLabel : `${startLabel} - ${endLabel}`;
};
type TimelineEntry =
| { kind: "event"; event: StageEvent }
| {
kind: "group";
id: string;
label: string;
events: StageEvent[];
occurredAt: number;
};
interface JobTimelineProps {
events: StageEvent[];
onEdit?: (event: StageEvent) => void;
onDelete?: (eventId: string) => void;
}
export const JobTimeline: React.FC<JobTimelineProps> = ({
events,
onEdit,
onDelete,
}) => {
const [openGroups, setOpenGroups] = React.useState<Record<string, boolean>>(
{},
);
const lastEvent = events.at(-1);
const currentStage = lastEvent?.toStage ?? null;
const entries = React.useMemo(() => {
const groups = new Map<string, { label: string; events: StageEvent[] }>();
const standalone: StageEvent[] = [];
events.forEach((event) => {
const groupId = event.groupId;
if (!groupId) {
standalone.push(event);
return;
}
const label = event.metadata?.groupLabel || "Grouped events";
const group = groups.get(groupId) ?? { label, events: [] };
group.events.push(event);
groups.set(groupId, group);
});
const mapped: TimelineEntry[] = standalone.map((event) => ({
kind: "event",
event,
}));
groups.forEach((value, id) => {
const sorted = [...value.events].sort(
(a, b) => a.occurredAt - b.occurredAt,
);
mapped.push({
kind: "group",
id,
label: value.label,
events: sorted,
occurredAt: sorted[0]?.occurredAt ?? 0,
});
});
return mapped.sort((a, b) => {
const timeA = a.kind === "event" ? a.event.occurredAt : a.occurredAt;
const timeB = b.kind === "event" ? b.event.occurredAt : b.occurredAt;
return timeA - timeB;
});
}, [events]);
if (entries.length === 0) {
return (
<div className="rounded-md border border-dashed border-border/50 p-6 text-sm text-muted-foreground">
No stage events yet.
</div>
);
}
return (
<div className="space-y-6">
{entries.map((entry, entryIndex) => {
if (entry.kind === "event") {
const title = entry.event.title || STAGE_LABELS[entry.event.toStage];
const note = entry.event.metadata?.note;
const reason = entry.event.metadata?.reasonCode;
const isCurrent =
currentStage === entry.event.toStage &&
entryIndex === entries.length - 1 &&
entry.event.toStage !== "applied";
const isOffer = entry.event.toStage === "offer";
const salary = entry.event.metadata?.externalUrl?.startsWith(
"Salary: ",
)
? entry.event.metadata.externalUrl.replace("Salary: ", "")
: null;
return (
<TimelineRow
key={entry.event.id}
date={formatTimestampWithTime(entry.event.occurredAt)}
title={title}
icon={stageIcons[entry.event.toStage]}
isCurrent={isCurrent}
isOffer={isOffer}
isLast={entryIndex === entries.length - 1}
onEdit={onEdit ? () => onEdit(entry.event) : undefined}
onDelete={onDelete ? () => onDelete(entry.event.id) : undefined}
>
{note && (
<div className="text-sm text-muted-foreground">{note}</div>
)}
{salary && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
{salary}
</div>
)}
{reason && (
<Badge
variant="outline"
className="mt-2 text-[10px] uppercase tracking-wide"
>
{reason}
</Badge>
)}
</TimelineRow>
);
}
const groupOpen = Boolean(openGroups[entry.id]);
const toggleGroup = () =>
setOpenGroups((prev) => ({ ...prev, [entry.id]: !prev[entry.id] }));
const groupStart = entry.events[0]?.occurredAt ?? entry.occurredAt;
const groupEnd = entry.events.at(-1)?.occurredAt ?? entry.occurredAt;
const groupCompleted = entry.events.some((event) =>
/submitted|completed|finished/i.test(event.title || ""),
);
const isCurrentGroup =
currentStage === entry.events.at(-1)?.toStage &&
entryIndex === entries.length - 1;
return (
<div key={entry.id} className="space-y-2">
<TimelineRow
date={formatRange(groupStart, groupEnd)}
title={entry.label}
icon={<ClipboardList className="h-4 w-4" />}
isCurrent={isCurrentGroup && !groupCompleted}
isCompleted={groupCompleted}
isLast={entryIndex === entries.length - 1}
>
<CollapsibleSection
isOpen={groupOpen}
label={groupOpen ? "Hide details" : "View details"}
onToggle={toggleGroup}
>
<div className="space-y-4">
{entry.events.map((event) => (
<TimelineRow
key={event.id}
date={formatTimestampWithTime(event.occurredAt)}
title={event.title || STAGE_LABELS[event.toStage]}
icon={stageIcons[event.toStage]}
isCompact
isLast={false}
onEdit={onEdit ? () => onEdit(event) : undefined}
onDelete={onDelete ? () => onDelete(event.id) : undefined}
>
{event.metadata?.note && (
<div className="text-xs text-muted-foreground">
{event.metadata.note}
</div>
)}
</TimelineRow>
))}
</div>
</CollapsibleSection>
</TimelineRow>
</div>
);
})}
</div>
);
};
interface TimelineRowProps {
date: string;
title: string;
icon: React.ReactNode;
isCurrent?: boolean;
isOffer?: boolean;
isCompleted?: boolean;
isLast?: boolean;
isCompact?: boolean;
onEdit?: () => void;
onDelete?: () => void;
children?: React.ReactNode;
}
const TimelineRow: React.FC<TimelineRowProps> = ({
date,
title,
icon,
isCurrent,
isOffer,
isCompleted,
isLast,
isCompact,
onEdit,
onDelete,
children,
}) => {
const isHollow = Boolean(isCurrent) && !isCompleted;
const isFilled = !isHollow;
return (
<div
className={cn(
"group relative",
isCompact ? "pl-8" : "",
isOffer && "rounded-lg border border-amber-500/20 bg-amber-500/5 p-4",
)}
>
<div
className={
isCompact
? "grid grid-cols-[80px_20px_1fr] gap-4"
: "grid grid-cols-[100px_24px_1fr] gap-4"
}
>
<div className="text-right text-xs font-medium text-muted-foreground">
{date}
</div>
<div className="relative flex flex-col items-center">
<span className="absolute inset-y-0 w-px bg-border" />
<div
className={
isCompact
? "relative z-10 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500 text-white"
: isHollow
? "relative z-10 flex h-6 w-6 items-center justify-center rounded-full border-2 border-emerald-500 bg-background text-emerald-600 animate-pulse"
: isOffer
? "relative z-10 flex h-6 w-6 items-center justify-center rounded-full bg-amber-500 text-white shadow-[0_0_15px_rgba(245,158,11,0.5)]"
: "relative z-10 flex h-6 w-6 items-center justify-center rounded-full bg-emerald-500 text-white"
}
>
{isFilled && icon}
</div>
{isLast && (
<span className="absolute bottom-0 h-4 w-px bg-background" />
)}
</div>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1 min-w-0 flex-1">
<div
className={
isCompact ? "text-xs font-semibold" : "text-sm font-semibold"
}
>
{title}
</div>
{children}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity pr-2">
{onEdit && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="p-2 cursor-pointer rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title="Edit event"
>
<Edit2 className="size-4" />
</button>
)}
{onDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-2 cursor-pointer rounded-md hover:bg-muted text-destructive/70 hover:text-destructive transition-colors"
title="Delete event"
>
<Trash2 className="size-4" />
</button>
)}
</div>
</div>
</div>
</div>
);
};
@@ -106,6 +106,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
starting: null,
jobDescription: "Build APIs",
status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 82,
suitabilityReason: "Strong fit",
tailoredSummary: null,
@@ -22,6 +22,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
starting: null,
jobDescription: "Build APIs",
status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 72,
suitabilityReason: null,
tailoredSummary: null,