feat: add application timeline history (#3237)

* feat: add application timeline history

* fix: address application timeline review

* fix: keep application tracker e2e stable

* fix: use stable timeline e2e selector

* fix: target timeline note input in e2e
This commit is contained in:
Amruth Pillai
2026-07-09 00:36:45 +02:00
committed by GitHub
parent 1124d3dfda
commit 18d0c14aa1
22 changed files with 6651 additions and 115 deletions
@@ -1,4 +1,4 @@
import type { ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data";
import type { ApplicationStatus, ApplicationTimelineEntry, Contact } from "@reactive-resume/schema/applications/data";
import type { Application } from "../types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -17,8 +17,17 @@ import { useState } from "react";
import { toast } from "sonner";
import { STAGES } from "@reactive-resume/schema/applications/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { Input } from "@reactive-resume/ui/components/input";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@reactive-resume/ui/components/sheet";
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { cn } from "@reactive-resume/utils/style";
import { useConfirm } from "@/hooks/use-confirm";
import { orpc } from "@/libs/orpc/client";
@@ -27,6 +36,24 @@ import { ApplicationAiCopilot } from "./application-ai-copilot";
import { FileAttachmentField } from "./file-attachment-field";
const stageIndex = (status: ApplicationStatus) => STAGES.findIndex((s) => s.value === status);
const stageOf = (status: ApplicationStatus) => STAGES.find((s) => s.value === status);
const dateInputValue = (value: Date | string) => {
const date = new Date(value);
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
};
const formatDate = (value: Date | string) =>
new Date(value).toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC", year: "numeric" });
const byNewest = (a: ApplicationTimelineEntry, b: ApplicationTimelineEntry) =>
new Date(b.at).getTime() - new Date(a.at).getTime();
const currentStageAnchorId = (activity: ApplicationTimelineEntry[], status: ApplicationStatus) =>
[...activity].sort(byNewest).find((entry) => entry.type === "stage" && entry.stage === status)?.id;
const latestStageDate = (activity: ApplicationTimelineEntry[], status: ApplicationStatus) =>
[...activity].sort(byNewest).find((entry) => entry.type === "stage" && entry.stage === status)?.at;
type Props = {
application: Application | null;
@@ -37,17 +64,8 @@ type Props = {
export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Props) {
const queryClient = useQueryClient();
const confirm = useConfirm();
const [note, setNote] = useState("");
const id = application?.id;
// Reset the draft note when switching to a different application (React's "adjust state during
// render" pattern) so typed-but-unsent text doesn't leak across applications.
const [noteFor, setNoteFor] = useState(id);
if (id !== noteFor) {
setNoteFor(id);
setNote("");
}
const { data } = useQuery({
...orpc.applications.getById.queryOptions({ input: { id: id ?? "" } }),
enabled: !!id,
@@ -71,14 +89,25 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr
const addNote = useMutation(
orpc.applications.addNote.mutationOptions({
onSuccess: () => {
setNote("");
invalidate();
},
onSuccess: invalidate,
onError: () => toast.error(t`Couldn't save the note.`),
}),
);
const updateTimelineEntry = useMutation(
orpc.applications.updateTimelineEntry.mutationOptions({
onSuccess: invalidate,
onError: () => toast.error(t`Couldn't update the timeline entry.`),
}),
);
const deleteTimelineEntry = useMutation(
orpc.applications.deleteTimelineEntry.mutationOptions({
onSuccess: invalidate,
onError: () => toast.error(t`Couldn't delete the timeline entry.`),
}),
);
const remove = useMutation(
orpc.applications.delete.mutationOptions({
onSuccess: () => {
@@ -145,7 +174,10 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<Fact label={t`Salary`} value={current.salary} />
<Fact label={t`Source`} value={current.source} />
<Fact label={t`Applied on`} value={new Date(current.appliedAt).toLocaleDateString()} />
<Fact
label={t`Applied on`}
value={formatDate(latestStageDate(current.activity, "applied") ?? current.appliedAt)}
/>
</dl>
{current.sourceUrl && (
@@ -240,40 +272,21 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr
</Section>
)}
{/* activity timeline */}
<Section title={t`Timeline & activity`}>
<div className="flex flex-col gap-3">
{[...current.activity]
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
.map((event) => (
<div key={event.id} className="flex gap-2.5 text-sm">
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-muted-foreground/40" />
<div className="min-w-0 flex-1">
<div>{event.text}</div>
<div className="text-muted-foreground text-xs">{new Date(event.at).toLocaleString()}</div>
</div>
</div>
))}
</div>
<div className="mt-3 flex gap-2">
<Input
value={note}
placeholder={t`Add a note or log activity…`}
onChange={(event) => setNote(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && note.trim()) addNote.mutate({ id: current.id, text: note.trim() });
}}
/>
<Button
type="button"
variant="outline"
disabled={!note.trim() || addNote.isPending}
onClick={() => addNote.mutate({ id: current.id, text: note.trim() })}
>
<Trans>Add</Trans>
</Button>
</div>
</Section>
<ApplicationTimeline
key={current.id}
application={current}
pending={addNote.isPending || updateTimelineEntry.isPending || deleteTimelineEntry.isPending}
onAddNote={(text) => addNote.mutateAsync({ id: current.id, text })}
onUpdateEntry={(entryId, input) => updateTimelineEntry.mutateAsync({ id: current.id, entryId, ...input })}
onDeleteEntry={(entryId) => {
void confirm(t`Delete this timeline entry?`, {
description: t`This entry will be permanently deleted. This can't be undone.`,
confirmText: t`Delete`,
}).then((confirmed) => {
if (confirmed) deleteTimelineEntry.mutate({ id: current.id, entryId });
});
}}
/>
</div>
<div className="flex items-center gap-1 border-border border-t p-4">
@@ -317,6 +330,206 @@ export function ApplicationDetailSheet({ application, onOpenChange, onEdit }: Pr
);
}
type ApplicationTimelineProps = {
application: Application;
pending: boolean;
onAddNote: (text: string) => Promise<unknown>;
onUpdateEntry: (entryId: string, input: { date?: string; text?: string }) => Promise<unknown>;
onDeleteEntry: (entryId: string) => void;
};
function ApplicationTimeline({
application,
pending,
onAddNote,
onUpdateEntry,
onDeleteEntry,
}: ApplicationTimelineProps) {
const [note, setNote] = useState("");
const [editingDate, setEditingDate] = useState<ApplicationTimelineEntry | null>(null);
const [editingNote, setEditingNote] = useState<(ApplicationTimelineEntry & { type: "note" }) | null>(null);
const [dateDraft, setDateDraft] = useState("");
const [noteDraft, setNoteDraft] = useState("");
const anchorId = currentStageAnchorId(application.activity, application.status);
const sorted = [...application.activity].sort(byNewest);
const openDate = (entry: ApplicationTimelineEntry) => {
setEditingDate(entry);
setDateDraft(dateInputValue(entry.at));
};
const openNote = (entry: ApplicationTimelineEntry & { type: "note" }) => {
setEditingNote(entry);
setNoteDraft(entry.text);
};
const add = () => {
if (pending) return;
const text = note.trim();
if (!text) return;
void onAddNote(text)
.then(() => setNote(""))
.catch(() => false);
};
return (
<Section title={t`Timeline`}>
<div className="flex flex-col gap-3">
<div className="flex gap-2">
<Input
data-timeline-note-input
value={note}
disabled={pending}
placeholder={t`Add a note…`}
onChange={(event) => setNote(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
add();
}
}}
/>
<Button type="button" variant="outline" disabled={!note.trim() || pending} onClick={add}>
<Trans>Add</Trans>
</Button>
</div>
<div className="relative flex flex-col gap-2 ps-4 before:absolute before:inset-y-2 before:start-1 before:w-px before:bg-border">
{sorted.map((entry) => {
const stage = entry.type === "stage" ? stageOf(entry.stage) : null;
const isAnchor = entry.id === anchorId;
return (
<div
key={entry.id}
data-timeline-entry={entry.type}
data-stage={entry.type === "stage" ? entry.stage : undefined}
className="group relative rounded-lg border border-border bg-card p-3 text-sm"
>
<span
className={cn(
"absolute start-[-17px] top-4 size-2.5 rounded-full border-2 border-card",
entry.type === "note" && "bg-primary/70",
)}
style={stage ? { background: stage.color } : undefined}
/>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{entry.type === "stage" ? (
<div className="font-medium">
<Trans>Moved to</Trans> {stage?.label ?? entry.stage}
</div>
) : (
<button
type="button"
className="block w-full rounded-sm text-left hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => openNote(entry)}
>
{entry.text}
</button>
)}
{isAnchor && (
<div className="mt-1 text-muted-foreground text-xs">
<Trans>Current stage</Trans>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
className="rounded-md border border-border px-2 py-1 text-muted-foreground text-xs hover:bg-muted hover:text-foreground"
onClick={() => openDate(entry)}
>
{formatDate(entry.at)}
</button>
{!isAnchor && (
<button
type="button"
title={t`Delete timeline entry`}
disabled={pending}
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 disabled:opacity-40 group-hover:opacity-100"
onClick={() => onDeleteEntry(entry.id)}
>
<TrashIcon className="size-4" />
</button>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
<Dialog open={!!editingDate} onOpenChange={(open) => !open && setEditingDate(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>
<Trans>Edit date</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Update the calendar date for this timeline entry.</Trans>
</DialogDescription>
</DialogHeader>
<Input
type="date"
value={dateDraft}
onInput={(event) => setDateDraft(event.currentTarget.value)}
onChange={(event) => setDateDraft(event.target.value)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditingDate(null)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
disabled={!dateDraft || pending}
onClick={() => {
if (!editingDate) return;
void onUpdateEntry(editingDate.id, { date: dateDraft })
.then(() => setEditingDate(null))
.catch(() => false);
}}
>
<Trans>Save</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!editingNote} onOpenChange={(open) => !open && setEditingNote(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Edit note</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Update this timeline note.</Trans>
</DialogDescription>
</DialogHeader>
<Textarea value={noteDraft} rows={4} onChange={(event) => setNoteDraft(event.target.value)} />
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditingNote(null)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
disabled={!noteDraft.trim() || pending}
onClick={() => {
if (!editingNote) return;
void onUpdateEntry(editingNote.id, { text: noteDraft.trim() })
.then(() => setEditingNote(null))
.catch(() => false);
}}
>
<Trans>Save</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Section>
);
}
function Fact({ label, value }: { label: string; value: string | null | undefined }) {
return (
<div>
@@ -27,8 +27,12 @@ import { FileAttachmentField } from "./file-attachment-field";
// Preset source suggestions surfaced via a <datalist>; the field itself stays free-text.
const SOURCE_OPTIONS = ["LinkedIn", "Indeed", "Company Website", "Referral", "Recruiter", "Other"];
const todayInputValue = () => {
const now = new Date();
return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")}`;
};
const EMPTY = {
const emptyForm = () => ({
company: "",
role: "",
location: "",
@@ -38,15 +42,16 @@ const EMPTY = {
resumeId: "",
tags: [] as string[],
sourceUrl: "",
stageEnteredAt: todayInputValue(),
jobDescription: "",
followUpAt: "",
followUpNote: "",
notes: "",
resumeFile: null as FileAttachment | null,
coverLetter: null as FileAttachment | null,
};
});
type FormState = typeof EMPTY;
type FormState = ReturnType<typeof emptyForm>;
const toAttachment = (url: string | null, name: string | null): FileAttachment | null =>
url ? { url, name: name ?? url } : null;
@@ -62,6 +67,7 @@ function toForm(app: Application): FormState {
resumeId: app.resumeId ?? "",
tags: app.tags,
sourceUrl: app.sourceUrl ?? "",
stageEnteredAt: "",
jobDescription: app.jobDescription ?? "",
followUpAt: app.followUpAt ? new Date(app.followUpAt).toISOString().slice(0, 10) : "",
followUpNote: app.followUpNote ?? "",
@@ -82,13 +88,13 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
const queryClient = useQueryClient();
const isEditing = !!application;
const [form, setForm] = useState<FormState>(application ? toForm(application) : EMPTY);
const [form, setForm] = useState<FormState>(() => (application ? toForm(application) : emptyForm()));
// Re-sync the form when the sheet's target changes (a different app, or create ↔ edit).
const [syncedId, setSyncedId] = useState(application?.id ?? null);
if ((application?.id ?? null) !== syncedId) {
setSyncedId(application?.id ?? null);
setForm(application ? toForm(application) : EMPTY);
setForm(application ? toForm(application) : emptyForm());
}
const { data: resumes } = useQuery(orpc.resume.list.queryOptions());
@@ -115,7 +121,7 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
onSuccess: () => {
invalidate();
toast.success(t`Application added to your pipeline.`);
setForm(EMPTY);
setForm(emptyForm());
onOpenChange(false);
},
onError: () => toast.error(t`Couldn't add the application. Please try again.`),
@@ -174,7 +180,7 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
coverLetterName: form.coverLetter?.name ?? null,
};
if (application) update.mutate({ id: application.id, ...payload });
else create.mutate(payload);
else create.mutate({ ...payload, stageEnteredAt: form.stageEnteredAt || undefined });
};
return (
@@ -270,6 +276,16 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
</Field>
</div>
{!isEditing && (
<Field label={t`Stage date`}>
<Input
type="date"
value={form.stageEnteredAt}
onChange={(event) => set("stageEnteredAt", event.target.value)}
/>
</Field>
)}
{/* Resume: link a live Reactive Resume (unlocks AI) or upload the exact PDF you sent. */}
<Field label={t`Resume`}>
<div className="flex flex-col gap-2">
@@ -22,7 +22,7 @@ import { applicationsListQueryKey } from "../queries";
const MAX_IMPORT = 500;
const SAMPLE =
"Company,Role,Stage,Location,Salary,Source,Tags\nStripe,Frontend Engineer,applied,Remote,$180k,LinkedIn,remote;react";
"Company,Role,Stage,Stage Date,Location,Salary,Source,Tags\nStripe,Frontend Engineer,applied,2026-07-01,Remote,$180k,LinkedIn,remote;react";
type Props = {
open: boolean;
@@ -74,7 +74,8 @@ export function ImportApplicationsSheet({ open, onOpenChange }: Props) {
</SheetTitle>
<SheetDescription>
<Trans>
Paste rows or upload a .csv. We map columns like Company, Role, Stage, Salary, Source and Tags.
Paste rows or upload a .csv. We map columns like Company, Role, Stage, Stage Date, Salary, Source and
Tags.
</Trans>
</SheetDescription>
</SheetHeader>
@@ -1,3 +1,4 @@
import type { ApplicationTimelineEntry } from "@reactive-resume/schema/applications/data";
import type { Application } from "../types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -9,13 +10,20 @@ import { Button } from "@reactive-resume/ui/components/button";
import { orpc } from "@/libs/orpc/client";
import { computeInsights, computeTimeline } from "../insights";
const byNewest = (a: ApplicationTimelineEntry, b: ApplicationTimelineEntry) =>
new Date(b.at).getTime() - new Date(a.at).getTime();
const appliedDate = (app: Application) =>
[...app.activity].sort(byNewest).find((entry) => entry.type === "stage" && entry.stage === "applied")?.at ??
app.appliedAt;
export function ApplicationInsights({ applications }: { applications: Application[] }) {
const { data } = useQuery(orpc.applications.stats.queryOptions({}));
// Weekly application velocity — derived from the already-loaded list, matching the stats
// population (archived excluded), so no extra endpoint is needed.
const timeline = useMemo(
() => computeTimeline(applications.filter((app) => !app.archived).map((app) => new Date(app.appliedAt))),
() => computeTimeline(applications.filter((app) => !app.archived).map((app) => new Date(appliedDate(app)))),
[applications],
);
const maxWeek = Math.max(1, ...timeline.map((bucket) => bucket.count));
@@ -1,3 +1,4 @@
import type { ApplicationStatus, ApplicationTimelineEntry } from "@reactive-resume/schema/applications/data";
import type { Application } from "../types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -26,6 +27,12 @@ import { ApplicationActionsMenu } from "./application-actions-menu";
const PAGE_SIZE = 25;
const stageOf = (status: string) => STAGES.find((s) => s.value === status);
const byNewest = (a: ApplicationTimelineEntry, b: ApplicationTimelineEntry) =>
new Date(b.at).getTime() - new Date(a.at).getTime();
const latestStageDate = (activity: ApplicationTimelineEntry[], status: ApplicationStatus) =>
[...activity].sort(byNewest).find((entry) => entry.type === "stage" && entry.stage === status)?.at;
const formatDate = (value: Date | string) =>
new Date(value).toLocaleDateString(undefined, { month: "numeric", day: "numeric", timeZone: "UTC", year: "numeric" });
type Props = {
applications: Application[];
@@ -265,7 +272,7 @@ export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
</td>
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">{app.source || "—"}</td>
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">
{new Date(app.appliedAt).toLocaleDateString()}
{formatDate(latestStageDate(app.activity, "applied") ?? app.appliedAt)}
</td>
<td className="px-1 py-2">
<ApplicationActionsMenu application={app} onEdit={onEdit} />
@@ -26,24 +26,30 @@ describe("parseCsv", () => {
describe("mapCsvToApplications", () => {
it("maps aliased headers and coerces status/tags", () => {
const csv = 'Company,Job Title,Stage,Salary,Tags\nStripe,Frontend,Interview,$180k,"remote;react"';
const csv =
'Company,Job Title,Stage,Stage Date,Salary,Tags\nStripe,Frontend,Interview,2026-07-01,$180k,"remote;react"';
const { rows, recognized } = mapCsvToApplications(parseCsv(csv));
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
company: "Stripe",
role: "Frontend",
status: "interview",
stageEnteredAt: "2026-07-01",
salary: "$180k",
tags: ["remote", "react"],
});
expect(recognized).toEqual(expect.arrayContaining(["company", "role", "status", "salary", "tags"]));
expect(recognized).toEqual(
expect.arrayContaining(["company", "role", "status", "stageEnteredAt", "salary", "tags"]),
);
});
it("skips rows missing company or role and drops invalid status", () => {
const csv = "company,role,status\nStripe,Eng,bogus\n,NoCompany,applied\nAcme,,saved";
const csv =
"company,role,status,stage date\nStripe,Eng,bogus,2026-99-99\n,NoCompany,applied,2026-07-01\nAcme,,saved,2026-07-01";
const { rows, skipped } = mapCsvToApplications(parseCsv(csv));
expect(rows).toHaveLength(1);
expect(rows[0]?.status).toBeUndefined(); // "bogus" dropped
expect(rows[0]?.stageEnteredAt).toBeUndefined(); // invalid date dropped
expect(skipped).toBe(2);
});
});
+13
View File
@@ -61,9 +61,17 @@ type ParsedApplication = {
source?: string;
notes?: string;
sourceUrl?: string;
stageEnteredAt?: string;
tags?: string[];
};
function dateOnly(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
const [year, month, day] = value.split("-").map(Number);
const date = new Date(Date.UTC(year ?? 0, (month ?? 1) - 1, day ?? 0));
return date.toISOString().slice(0, 10) === value ? value : undefined;
}
// Header aliases → canonical field. Matched case-insensitively after trimming.
const HEADER_ALIASES: Record<string, keyof ParsedApplication> = {
company: "company",
@@ -75,6 +83,9 @@ const HEADER_ALIASES: Record<string, keyof ParsedApplication> = {
"job title": "role",
status: "status",
stage: "status",
"applied date": "stageEnteredAt",
"stage date": "stageEnteredAt",
"stage entered at": "stageEnteredAt",
location: "location",
salary: "salary",
"salary range": "salary",
@@ -123,6 +134,8 @@ export function mapCsvToApplications(table: string[][]): CsvMapResult {
else if (field === "status") {
const parsed = applicationStatusSchema.safeParse(value.toLowerCase());
if (parsed.success) record.status = parsed.data;
} else if (field === "stageEnteredAt") {
record.stageEnteredAt = dateOnly(value);
} else record[field] = value as never;
});