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;
});
@@ -0,0 +1,116 @@
WITH entries AS (
SELECT
a.id AS application_id,
entry.value AS entry,
entry.ordinality,
coalesce(nullif(entry.value ->> 'id', ''), md5(a.id || ':' || entry.ordinality::text)) AS entry_id,
coalesce(nullif(entry.value ->> 'at', ''), a.applied_at::text, a.created_at::text, now()::text) AS entry_at,
lower(coalesce(entry.value ->> 'text', '')) AS entry_text
FROM "application" a
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(a.activity) = 'array' THEN a.activity ELSE '[]'::jsonb END
) WITH ORDINALITY AS entry(value, ordinality)
),
normalized AS (
SELECT
application_id,
ordinality,
CASE
WHEN entry ->> 'type' = 'stage'
AND entry ->> 'stage' IN ('saved', 'applied', 'screening', 'interview', 'offer', 'rejected') THEN
jsonb_build_object('id', entry_id, 'type', 'stage', 'stage', entry ->> 'stage', 'at', entry_at)
WHEN entry ->> 'type' IN ('created', 'stage')
AND CASE
WHEN entry_text LIKE '%screening%' THEN 'screening'
WHEN entry_text LIKE '%interview%' THEN 'interview'
WHEN entry_text LIKE '%offer%' THEN 'offer'
WHEN entry_text LIKE '%rejected%' THEN 'rejected'
WHEN entry_text LIKE '%applied%' THEN 'applied'
WHEN entry_text LIKE '%saved%' THEN 'saved'
END IS NOT NULL THEN
jsonb_build_object(
'id', entry_id,
'type', 'stage',
'stage', CASE
WHEN entry_text LIKE '%screening%' THEN 'screening'
WHEN entry_text LIKE '%interview%' THEN 'interview'
WHEN entry_text LIKE '%offer%' THEN 'offer'
WHEN entry_text LIKE '%rejected%' THEN 'rejected'
WHEN entry_text LIKE '%applied%' THEN 'applied'
WHEN entry_text LIKE '%saved%' THEN 'saved'
END,
'at', entry_at
)
ELSE
jsonb_build_object(
'id', entry_id,
'type', 'note',
'text', coalesce(nullif(btrim(entry ->> 'text'), ''), 'Imported timeline entry'),
'at', entry_at
)
END AS normalized_entry
FROM entries
),
grouped AS (
SELECT application_id, jsonb_agg(normalized_entry ORDER BY ordinality) AS activity
FROM normalized
GROUP BY application_id
)
UPDATE "application" a
SET activity = grouped.activity
FROM grouped
WHERE a.id = grouped.application_id;
--> statement-breakpoint
UPDATE "application"
SET activity = jsonb_build_array(
jsonb_build_object(
'id', md5(id || ':stage'),
'type', 'stage',
'stage', status,
'at', coalesce(applied_at::text, created_at::text, now()::text)
)
)
WHERE jsonb_typeof(activity) IS DISTINCT FROM 'array' OR activity = '[]'::jsonb;
--> statement-breakpoint
WITH stage_stats AS (
SELECT
a.id AS application_id,
max((entry.value ->> 'at')::timestamptz) FILTER (WHERE entry.value ->> 'type' = 'stage') AS latest_stage_at,
max((entry.value ->> 'at')::timestamptz) FILTER (
WHERE entry.value ->> 'type' = 'stage' AND entry.value ->> 'stage' = a.status
) AS current_stage_at
FROM "application" a
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(a.activity) = 'array' THEN a.activity ELSE '[]'::jsonb END
) AS entry(value)
GROUP BY a.id
),
anchors AS (
SELECT
a.id,
a.status,
greatest(
coalesce(stage_stats.latest_stage_at, a.applied_at, a.created_at, now()),
coalesce(a.applied_at, a.created_at, now())
) AS anchor_at,
stage_stats.latest_stage_at,
stage_stats.current_stage_at
FROM "application" a
LEFT JOIN stage_stats ON stage_stats.application_id = a.id
WHERE jsonb_typeof(a.activity) = 'array'
)
UPDATE "application" a
SET activity = a.activity || jsonb_build_array(
jsonb_build_object(
'id', md5(a.id || ':stage:' || a.status || ':anchor'),
'type', 'stage',
'stage', a.status,
'at', anchors.anchor_at::text
)
)
FROM anchors
WHERE a.id = anchors.id
AND (
anchors.current_stage_at IS NULL
OR (anchors.latest_stage_at IS NOT NULL AND anchors.current_stage_at < anchors.latest_stage_at)
);
File diff suppressed because it is too large Load Diff
+22 -3
View File
@@ -2,9 +2,9 @@ import { createSelectSchema } from "drizzle-zod";
import z from "zod";
import * as schema from "@reactive-resume/db/schema";
import {
activityEventSchema,
aiMetadataSchema,
applicationStatusSchema,
applicationTimelineEntrySchema,
contactSchema,
} from "@reactive-resume/schema/applications/data";
@@ -12,6 +12,7 @@ const MAX_APPLICATION_JOB_DESCRIPTION_CHARS = 20_000;
const MAX_APPLICATION_DOCUMENT_BYTES = 10 * 1024 * 1024;
const applicationDocumentKindSchema = z.enum(["resume", "cover-letter"]);
const timelineDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must use YYYY-MM-DD format.");
const applicationDocumentFileSchema = z
.file()
@@ -61,7 +62,7 @@ const applicationSchema = createSelectSchema(schema.application, {
followUpNote: z.string().trim().nullable(),
tags: z.array(z.string()),
contacts: z.array(contactSchema),
activity: z.array(activityEventSchema),
activity: z.array(applicationTimelineEntrySchema),
appliedAt: z.date(),
createdAt: z.date(),
updatedAt: z.date(),
@@ -94,6 +95,7 @@ const createInputSchema = editableSchema.partial().extend({
company: applicationSchema.shape.company,
role: applicationSchema.shape.role,
status: applicationStatusSchema.optional(),
stageEnteredAt: timelineDateSchema.optional(),
});
export const applicationDto = {
@@ -150,7 +152,24 @@ export const applicationDto = {
},
addNote: {
input: z.object({ id: z.string(), text: z.string().trim().min(1) }),
input: z.object({ id: z.string(), text: z.string().trim().min(1), date: timelineDateSchema.optional() }),
output: applicationSchema.omit({ userId: true }),
},
updateTimelineEntry: {
input: z
.object({
id: z.string(),
entryId: z.string(),
date: timelineDateSchema.optional(),
text: z.string().trim().min(1).optional(),
})
.refine((value) => value.date !== undefined || value.text !== undefined, "Provide date or text to update."),
output: applicationSchema.omit({ userId: true }),
},
deleteTimelineEntry: {
input: z.object({ id: z.string(), entryId: z.string() }),
output: applicationSchema.omit({ userId: true }),
},
+36 -1
View File
@@ -171,7 +171,42 @@ export const crudRouter = {
.use(resumeMutationRateLimit)
.output(applicationDto.addNote.output)
.handler(async ({ input, context }) => {
return applicationService.addNote({ id: input.id, userId: context.user.id, text: input.text });
return applicationService.addNote({ id: input.id, userId: context.user.id, text: input.text, date: input.date });
}),
updateTimelineEntry: protectedProcedure
.route({
method: "PUT",
path: "/applications/{id}/timeline/{entryId}",
tags: ["Applications"],
operationId: "updateApplicationTimelineEntry",
summary: "Update a timeline entry",
description: "Updates a timeline entry date, or note text for note entries. Requires authentication.",
successDescription: "The updated application.",
})
.input(applicationDto.updateTimelineEntry.input)
.use(resumeMutationRateLimit)
.output(applicationDto.updateTimelineEntry.output)
.handler(async ({ input, context }) => {
return applicationService.updateTimelineEntry({ ...input, userId: context.user.id });
}),
deleteTimelineEntry: protectedProcedure
.route({
method: "DELETE",
path: "/applications/{id}/timeline/{entryId}",
tags: ["Applications"],
operationId: "deleteApplicationTimelineEntry",
summary: "Delete a timeline entry",
description:
"Deletes a note or older stage entry. The current stage entry cannot be deleted. Requires authentication.",
successDescription: "The updated application.",
})
.input(applicationDto.deleteTimelineEntry.input)
.use(resumeMutationRateLimit)
.output(applicationDto.deleteTimelineEntry.output)
.handler(async ({ input, context }) => {
return applicationService.deleteTimelineEntry({ ...input, userId: context.user.id });
}),
delete: protectedProcedure
@@ -10,6 +10,8 @@ export const applicationsRouter = {
attachDocument: crudRouter.attachDocument,
removeDocument: crudRouter.removeDocument,
addNote: crudRouter.addNote,
updateTimelineEntry: crudRouter.updateTimelineEntry,
deleteTimelineEntry: crudRouter.deleteTimelineEntry,
delete: crudRouter.delete,
bulkUpdate: crudRouter.bulkUpdate,
bulkDelete: crudRouter.bulkDelete,
@@ -6,6 +6,8 @@ const dbMock = vi.hoisted(() => ({
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
execute: vi.fn(),
transaction: vi.fn(),
}));
const resumeGetByIdMock = vi.hoisted(() => vi.fn());
const storageDeleteMock = vi.hoisted(() => vi.fn());
@@ -17,6 +19,8 @@ vi.mock("@reactive-resume/db/schema", () => ({
id: "id",
userId: "user_id",
status: "status",
activity: "activity",
appliedAt: "applied_at",
updatedAt: "updated_at",
resumeFileUrl: "resume_file_url",
coverLetterUrl: "cover_letter_url",
@@ -48,7 +52,9 @@ const existing = {
company: "Stripe",
role: "Engineer",
status: "saved" as const,
activity: [{ id: "e0", type: "created" as const, text: "Added to Saved", at: new Date() }],
activity: [{ id: "e0", type: "stage" as const, stage: "saved" as const, at: new Date("2026-07-01T12:00:00.000Z") }],
appliedAt: new Date("2026-07-01T12:00:00.000Z"),
createdAt: new Date("2026-07-01T12:00:00.000Z"),
resumeFileUrl: "http://localhost:3000/api/uploads/user-1/pictures/resume.pdf",
coverLetterUrl: "/api/uploads/user-1/pictures/cover.pdf",
};
@@ -71,6 +77,9 @@ beforeEach(() => {
dbMock.insert.mockReset();
dbMock.update.mockReset();
dbMock.delete.mockReset();
dbMock.execute.mockReset();
dbMock.transaction.mockReset();
dbMock.transaction.mockImplementation((callback) => callback(dbMock));
resumeGetByIdMock.mockReset();
storageDeleteMock.mockReset();
uploadFileMock.mockReset();
@@ -84,15 +93,22 @@ beforeEach(() => {
});
describe("applicationService.create", () => {
it("seeds a 'created' activity event", async () => {
it("seeds an initial stage timeline entry with the chosen date", async () => {
const values = vi.fn(() => Promise.resolve());
dbMock.insert.mockReturnValue({ values });
await applicationService.create({ userId: "user-1", company: "Stripe", role: "Engineer", status: "applied" });
await applicationService.create({
userId: "user-1",
company: "Stripe",
role: "Engineer",
status: "applied",
stageEnteredAt: "2026-07-10",
} as never);
const [[inserted]] = values.mock.calls as unknown as [[{ activity: { type: string }[] }]];
const [[inserted]] = values.mock.calls as unknown as [[{ activity: { type: string; stage: string; at: Date }[] }]];
expect(inserted.activity).toHaveLength(1);
expect(inserted.activity.at(0)?.type).toBe("created");
expect(inserted.activity.at(0)).toMatchObject({ type: "stage", stage: "applied" });
expect(inserted.activity.at(0)?.at.toISOString()).toBe("2026-07-10T12:00:00.000Z");
});
it("checks linked resume ownership before inserting", async () => {
@@ -118,12 +134,17 @@ describe("applicationService.update", () => {
return set;
};
it("appends a 'stage' event when the status changes", async () => {
const appendedEvent = (activity: { values: unknown[] }) => {
const value = activity.values.find((item) => typeof item === "string" && item.includes('"type":"stage"'));
return JSON.parse(String(value))[0] as { type: string; stage: string };
};
it("appends a typed stage timeline entry when the status changes", async () => {
const set = captureSet();
await applicationService.update({ id: "app-1", userId: "user-1", status: "applied" });
const [[arg]] = set.mock.calls as unknown as [[{ activity: unknown }]];
expect(arg.activity).toBeDefined();
const [[arg]] = set.mock.calls as unknown as [[{ activity: { values: unknown[] } }]];
expect(appendedEvent(arg.activity)).toMatchObject({ type: "stage", stage: "applied" });
});
it("does not rewrite activity when the status is unchanged", async () => {
@@ -142,6 +163,221 @@ describe("applicationService.update", () => {
});
});
describe("applicationService timeline entries", () => {
const captureSet = (returning = [{ ...existing }]) => {
const set = vi.fn(() => ({ where: () => ({ returning: () => Promise.resolve(returning) }) }));
dbMock.update.mockReturnValue({ set });
return set;
};
it("adds dated note timeline entries", async () => {
const set = captureSet();
await applicationService.addNote({
id: "app-1",
userId: "user-1",
text: "Recruiter replied",
date: "2026-07-12",
} as never);
const [[arg]] = set.mock.calls as unknown as [[{ activity: { values: unknown[] } }]];
const value = arg.activity.values.find((item) => typeof item === "string" && item.includes('"type":"note"'));
const [entry] = JSON.parse(String(value)) as [{ type: string; text: string; at: string }];
expect(entry).toMatchObject({ type: "note", text: "Recruiter replied" });
expect(new Date(entry.at).toISOString()).toBe("2026-07-12T12:00:00.000Z");
});
it("rejects invalid calendar dates instead of letting Date overflow", async () => {
await expect(
applicationService.addNote({
id: "app-1",
userId: "user-1",
text: "Impossible date",
date: "2026-99-99",
} as never),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("updates note text and timeline dates", async () => {
const activity = [
{ id: "stage-1", type: "stage" as const, stage: "saved" as const, at: new Date("2026-07-01T09:30:00.000Z") },
{ id: "note-1", type: "note" as const, text: "Old note", at: new Date("2026-07-02T15:45:00.000Z") },
];
setSelectResults([{ ...existing, activity }]);
const set = captureSet();
await (
applicationService as unknown as {
updateTimelineEntry: (input: {
id: string;
userId: string;
entryId: string;
date?: string;
text?: string;
}) => Promise<unknown>;
}
).updateTimelineEntry({
id: "app-1",
userId: "user-1",
entryId: "note-1",
date: "2026-07-10",
text: "Updated note",
});
const [[arg]] = set.mock.calls as unknown as [[{ activity: typeof activity }]];
expect(arg.activity.find((entry) => entry.id === "note-1")).toMatchObject({
text: "Updated note",
at: new Date("2026-07-10T15:45:00.000Z"),
});
expect(dbMock.transaction).toHaveBeenCalled();
expect(dbMock.execute).toHaveBeenCalled();
});
it("normalizes JSONB date strings when editing timeline dates", async () => {
const activity = [
{ id: "stage-1", type: "stage" as const, stage: "saved" as const, at: "2026-07-01T09:30:00.000Z" },
];
setSelectResults([{ ...existing, activity }]);
const set = captureSet();
await (
applicationService as unknown as {
updateTimelineEntry: (input: { id: string; userId: string; entryId: string; date: string }) => Promise<unknown>;
}
).updateTimelineEntry({
id: "app-1",
userId: "user-1",
entryId: "stage-1",
date: "2026-07-05",
});
const [[arg]] = set.mock.calls as unknown as [[{ activity: { id: string; at: Date }[] }]];
expect(arg.activity.find((entry) => entry.id === "stage-1")?.at.toISOString()).toBe("2026-07-05T09:30:00.000Z");
});
it("allows notes to be newer than the current-stage anchor", async () => {
const activity = [
{ id: "stage-1", type: "stage" as const, stage: "saved" as const, at: new Date("2026-07-01T12:00:00.000Z") },
{ id: "note-1", type: "note" as const, text: "Followed up", at: new Date("2026-07-10T12:00:00.000Z") },
];
setSelectResults([{ ...existing, activity }]);
const set = captureSet();
await (
applicationService as unknown as {
updateTimelineEntry: (input: { id: string; userId: string; entryId: string; date: string }) => Promise<unknown>;
}
).updateTimelineEntry({ id: "app-1", userId: "user-1", entryId: "stage-1", date: "2026-07-02" });
const [[arg]] = set.mock.calls as unknown as [[{ activity: { id: string; at: Date }[] }]];
expect(arg.activity.find((entry) => entry.id === "stage-1")?.at.toISOString()).toBe("2026-07-02T12:00:00.000Z");
});
it("blocks moving the current-stage anchor older than another stage", async () => {
setSelectResults([
{
...existing,
status: "screening",
activity: [
{
id: "stage-1",
type: "stage" as const,
stage: "applied" as const,
at: new Date("2026-07-03T12:00:00.000Z"),
},
{
id: "stage-2",
type: "stage" as const,
stage: "screening" as const,
at: new Date("2026-07-04T12:00:00.000Z"),
},
],
},
]);
await expect(
(
applicationService as unknown as {
updateTimelineEntry: (input: {
id: string;
userId: string;
entryId: string;
date: string;
}) => Promise<unknown>;
}
).updateTimelineEntry({ id: "app-1", userId: "user-1", entryId: "stage-2", date: "2026-07-01" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("blocks deleting the current-stage anchor", async () => {
setSelectResults([
{
...existing,
status: "screening",
activity: [
{
id: "stage-1",
type: "stage" as const,
stage: "applied" as const,
at: new Date("2026-07-01T12:00:00.000Z"),
},
{
id: "stage-2",
type: "stage" as const,
stage: "screening" as const,
at: new Date("2026-07-03T12:00:00.000Z"),
},
],
},
]);
await expect(
(
applicationService as unknown as {
deleteTimelineEntry: (input: { id: string; userId: string; entryId: string }) => Promise<unknown>;
}
).deleteTimelineEntry({ id: "app-1", userId: "user-1", entryId: "stage-2" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(dbMock.transaction).toHaveBeenCalled();
expect(dbMock.execute).toHaveBeenCalled();
});
it("deletes older stage entries", async () => {
setSelectResults([
{
...existing,
status: "screening",
activity: [
{
id: "stage-1",
type: "stage" as const,
stage: "applied" as const,
at: new Date("2026-07-01T12:00:00.000Z"),
},
{
id: "stage-2",
type: "stage" as const,
stage: "screening" as const,
at: new Date("2026-07-03T12:00:00.000Z"),
},
],
},
]);
const set = captureSet();
await (
applicationService as unknown as {
deleteTimelineEntry: (input: { id: string; userId: string; entryId: string }) => Promise<unknown>;
}
).deleteTimelineEntry({ id: "app-1", userId: "user-1", entryId: "stage-1" });
const [[arg]] = set.mock.calls as unknown as [[{ activity: { id: string }[] }]];
expect(arg.activity).toEqual([
{ id: "stage-2", type: "stage", stage: "screening", at: new Date("2026-07-03T12:00:00.000Z") },
]);
});
});
describe("applicationService.delete", () => {
it("deletes owned uploaded attachments after deleting the application", async () => {
dbMock.delete.mockReturnValue({
+221 -27
View File
@@ -1,18 +1,87 @@
import type { ActivityEvent, AiMetadata, ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data";
import type {
AiMetadata,
ApplicationStatus,
ApplicationTimelineEntry,
Contact,
} from "@reactive-resume/schema/applications/data";
import type { ApplicationDocumentKind } from "../../dto/application";
import { ORPCError } from "@orpc/client";
import { and, arrayContains, desc, eq, inArray, sql } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { STAGES } from "@reactive-resume/schema/applications/data";
import { generateId } from "@reactive-resume/utils/string";
import { resumeService } from "../resume/service";
import { getStorageService, uploadFile } from "../storage/service";
const stageLabel = (status: ApplicationStatus) => STAGES.find((s) => s.value === status)?.label ?? status;
function timelineDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
function activityEvent(type: ActivityEvent["type"], text: string): ActivityEvent {
return { id: generateId(), type, text, at: new Date() };
function atFromDateString(date: string, existing?: Date | string): Date {
const [year, month, day] = date.split("-").map(Number);
if (!year || !month || !day) throw new ORPCError("BAD_REQUEST", { message: "Date must use YYYY-MM-DD format." });
const existingDate = existing ? timelineDate(existing) : undefined;
const parsed = new Date(
Date.UTC(
year,
month - 1,
day,
existingDate?.getUTCHours() ?? 12,
existingDate?.getUTCMinutes() ?? 0,
existingDate?.getUTCSeconds() ?? 0,
existingDate?.getUTCMilliseconds() ?? 0,
),
);
if (timelineDay(parsed) !== date) throw new ORPCError("BAD_REQUEST", { message: "Date must use YYYY-MM-DD format." });
return parsed;
}
function stageEntry(stage: ApplicationStatus, date?: string): ApplicationTimelineEntry {
return { id: generateId(), type: "stage", stage, at: date ? atFromDateString(date) : new Date() };
}
function noteEntry(text: string, date?: string): ApplicationTimelineEntry {
return { id: generateId(), type: "note", text, at: date ? atFromDateString(date) : new Date() };
}
function byNewest(a: ApplicationTimelineEntry, b: ApplicationTimelineEntry) {
return new Date(b.at).getTime() - new Date(a.at).getTime();
}
function timelineDay(value: Date | string) {
return timelineDate(value).toISOString().slice(0, 10);
}
function sortTimeline(activity: ApplicationTimelineEntry[]): ApplicationTimelineEntry[] {
return [...activity].sort(byNewest);
}
function currentStageAnchor(activity: ApplicationTimelineEntry[], status: ApplicationStatus) {
return sortTimeline(activity).find((entry) => entry.type === "stage" && entry.stage === status);
}
function assertCurrentStageAnchorLatest(activity: ApplicationTimelineEntry[], status: ApplicationStatus) {
const anchor = currentStageAnchor(activity, status);
if (!anchor)
throw new ORPCError("BAD_REQUEST", { message: "Application timeline is missing its current stage entry." });
const anchorDay = timelineDay(anchor.at);
const newerStage = activity.some((entry) => entry.type === "stage" && timelineDay(entry.at) > anchorDay);
if (newerStage) {
throw new ORPCError("BAD_REQUEST", {
message: "Current stage date cannot be older than another stage entry.",
});
}
}
function appliedAtFromTimeline(activity: ApplicationTimelineEntry[], fallback: Date): Date {
const sorted = sortTimeline(activity);
const applied = sorted.find((entry) => entry.type === "stage" && entry.stage === "applied");
const fallbackEntry = sorted.at(-1);
return applied ? timelineDate(applied.at) : fallbackEntry ? timelineDate(fallbackEntry.at) : fallback;
}
// Editable fields shared by create/update. Kept explicit so Drizzle's typed insert/update
@@ -124,9 +193,9 @@ function documentFields(kind: ApplicationDocumentKind) {
} as const);
}
const stripUserId = <T extends { userId: string }>(row: T) => {
const stripUserId = <T extends { userId: string; activity?: ApplicationTimelineEntry[] }>(row: T) => {
const { userId: _userId, ...rest } = row;
return rest;
return rest.activity ? { ...rest, activity: sortTimeline(rest.activity) } : rest;
};
export const applicationService = {
@@ -151,18 +220,27 @@ export const applicationService = {
},
create: async (
input: EditableFields & { userId: string; company: string; role: string; status?: ApplicationStatus | undefined },
input: EditableFields & {
userId: string;
company: string;
role: string;
status?: ApplicationStatus | undefined;
stageEnteredAt?: string | undefined;
},
) => {
const { userId, status, ...fields } = input;
const { userId, status, stageEnteredAt, ...fields } = input;
const id = generateId();
const initialStatus = status ?? "saved";
const activity = [stageEntry(initialStatus, stageEnteredAt)];
await assertOwnedResume(userId, fields.resumeId);
await db.insert(schema.application).values({
id,
userId,
status: status ?? "saved",
activity: [activityEvent("created", `Added to ${stageLabel(status ?? "saved")}`)],
status: initialStatus,
activity,
appliedAt: appliedAtFromTimeline(activity, new Date()),
...fields,
});
@@ -171,7 +249,12 @@ export const applicationService = {
importMany: async (input: {
userId: string;
items: (EditableFields & { company: string; role: string; status?: ApplicationStatus | undefined })[];
items: (EditableFields & {
company: string;
role: string;
status?: ApplicationStatus | undefined;
stageEnteredAt?: string | undefined;
})[];
}) => {
if (input.items.length === 0) return { imported: 0 };
@@ -180,13 +263,18 @@ export const applicationService = {
input.items.map((item) => item.resumeId),
);
const values = input.items.map(({ status, ...fields }) => ({
id: generateId(),
userId: input.userId,
status: status ?? ("saved" as ApplicationStatus),
activity: [activityEvent("created", `Added to ${stageLabel(status ?? "saved")}`)],
...fields,
}));
const values = input.items.map(({ status, stageEnteredAt, ...fields }) => {
const initialStatus = status ?? ("saved" as ApplicationStatus);
const activity = [stageEntry(initialStatus, stageEnteredAt)];
return {
id: generateId(),
userId: input.userId,
status: initialStatus,
activity,
appliedAt: appliedAtFromTimeline(activity, new Date()),
...fields,
};
});
const rows = await db.insert(schema.application).values(values).returning({ id: schema.application.id });
return { imported: rows.length };
@@ -205,12 +293,19 @@ export const applicationService = {
const { id, userId, status, archived, ...fields } = input;
await assertOwnedResume(userId, fields.resumeId);
const statusEntry = status !== undefined ? stageEntry(status) : undefined;
// Append in SQL so concurrent notes/stage events are not overwritten by a stale array.
const activityExpr =
status !== undefined
statusEntry !== undefined
? sql`case when ${schema.application.status} <> ${status}
then ${schema.application.activity} || ${JSON.stringify([activityEvent("stage", `Moved to ${stageLabel(status)}`)])}::jsonb
else ${schema.application.activity} end`
then ${schema.application.activity} || ${JSON.stringify([statusEntry])}::jsonb
else ${schema.application.activity} end`
: undefined;
const appliedAtExpr =
statusEntry !== undefined && status === "applied"
? sql`case when ${schema.application.status} <> ${status}
then ${statusEntry.at}
else ${schema.application.appliedAt} end`
: undefined;
const [updated] = await db
@@ -218,6 +313,7 @@ export const applicationService = {
.set({
...fields,
...(status !== undefined ? { status } : {}),
...(appliedAtExpr ? { appliedAt: appliedAtExpr } : {}),
...(archived !== undefined ? { archived } : {}),
...(activityExpr ? { activity: activityExpr } : {}),
})
@@ -313,10 +409,10 @@ export const applicationService = {
return stripUserId(updated);
},
addNote: async (input: { id: string; userId: string; text: string }) => {
addNote: async (input: { id: string; userId: string; text: string; date?: string | undefined }) => {
// Append in a single statement (activity || [event]) so concurrent notes can't drop each
// other via read-then-write; ownership is enforced by the WHERE clause.
const event = activityEvent("note", input.text);
const event = noteEntry(input.text, input.date);
const [updated] = await db
.update(schema.application)
.set({ activity: sql`${schema.application.activity} || ${JSON.stringify([event])}::jsonb` })
@@ -327,6 +423,96 @@ export const applicationService = {
return stripUserId(updated);
},
updateTimelineEntry: async (input: {
id: string;
userId: string;
entryId: string;
date?: string | undefined;
text?: string | undefined;
}) => {
return db.transaction(async (tx) => {
await tx.execute(sql`
select 1 from ${schema.application}
where ${schema.application.id} = ${input.id} and ${schema.application.userId} = ${input.userId}
for update
`);
const [existing] = await tx
.select()
.from(schema.application)
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)));
if (!existing) throw new ORPCError("NOT_FOUND");
const activity = existing.activity.map((entry) => {
if (entry.id !== input.entryId) return entry;
if (entry.type === "stage" && input.text !== undefined) {
throw new ORPCError("BAD_REQUEST", { message: "Stage timeline text is derived and cannot be edited." });
}
return {
...entry,
...(input.date !== undefined ? { at: atFromDateString(input.date, entry.at) } : {}),
...(entry.type === "note" && input.text !== undefined ? { text: input.text } : {}),
};
});
if (!activity.some((entry) => entry.id === input.entryId)) throw new ORPCError("NOT_FOUND");
assertCurrentStageAnchorLatest(activity, existing.status);
const [updated] = await tx
.update(schema.application)
.set({
activity,
appliedAt: appliedAtFromTimeline(activity, existing.appliedAt),
})
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)))
.returning();
if (!updated) throw new ORPCError("NOT_FOUND");
return stripUserId(updated);
});
},
deleteTimelineEntry: async (input: { id: string; userId: string; entryId: string }) => {
return db.transaction(async (tx) => {
await tx.execute(sql`
select 1 from ${schema.application}
where ${schema.application.id} = ${input.id} and ${schema.application.userId} = ${input.userId}
for update
`);
const [existing] = await tx
.select()
.from(schema.application)
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)));
if (!existing) throw new ORPCError("NOT_FOUND");
const entry = existing.activity.find((item) => item.id === input.entryId);
if (!entry) throw new ORPCError("NOT_FOUND");
const anchor = currentStageAnchor(existing.activity, existing.status);
if (entry.type === "stage" && anchor?.id === entry.id) {
throw new ORPCError("BAD_REQUEST", { message: "The current stage timeline entry cannot be deleted." });
}
const activity = existing.activity.filter((item) => item.id !== input.entryId);
assertCurrentStageAnchorLatest(activity, existing.status);
const [updated] = await tx
.update(schema.application)
.set({
activity,
appliedAt: appliedAtFromTimeline(activity, existing.appliedAt),
})
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)))
.returning();
if (!updated) throw new ORPCError("NOT_FOUND");
return stripUserId(updated);
});
},
delete: async (input: { id: string; userId: string }) => {
const existing = await requireOwned(input.id, input.userId);
const result = await db
@@ -359,17 +545,25 @@ export const applicationService = {
// Stage moves must log a timeline event on every row that actually changed — mirror the
// single-item update path. Append the event only where the current status differs.
const statusEntry = input.status !== undefined ? stageEntry(input.status) : undefined;
const activityExpr =
input.status !== undefined
statusEntry !== undefined
? sql`case when ${schema.application.status} <> ${input.status}
then ${schema.application.activity} || ${JSON.stringify([activityEvent("stage", `Moved to ${stageLabel(input.status)}`)])}::jsonb
else ${schema.application.activity} end`
then ${schema.application.activity} || ${JSON.stringify([statusEntry])}::jsonb
else ${schema.application.activity} end`
: undefined;
const appliedAtExpr =
statusEntry !== undefined && input.status === "applied"
? sql`case when ${schema.application.status} <> ${input.status}
then ${statusEntry.at}
else ${schema.application.appliedAt} end`
: undefined;
const rows = await db
.update(schema.application)
.set({
...(input.status !== undefined ? { status: input.status } : {}),
...(appliedAtExpr ? { appliedAt: appliedAtExpr } : {}),
...(activityExpr ? { activity: activityExpr } : {}),
...(input.archived !== undefined ? { archived: input.archived } : {}),
...(tagsExpr ? { tags: tagsExpr } : {}),
+7 -2
View File
@@ -1,4 +1,9 @@
import type { ActivityEvent, AiMetadata, ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data";
import type {
AiMetadata,
ApplicationStatus,
ApplicationTimelineEntry,
Contact,
} from "@reactive-resume/schema/applications/data";
import * as pg from "drizzle-orm/pg-core";
import { generateId } from "@reactive-resume/utils/string";
import { user } from "./auth";
@@ -47,7 +52,7 @@ export const application = pg.pgTable(
followUpAt: pg.timestamp("follow_up_at", { withTimezone: true }),
followUpNote: pg.text("follow_up_note"),
contacts: pg.jsonb("contacts").notNull().$type<Contact[]>().default([]),
activity: pg.jsonb("activity").notNull().$type<ActivityEvent[]>().default([]),
activity: pg.jsonb("activity").notNull().$type<ApplicationTimelineEntry[]>().default([]),
appliedAt: pg.timestamp("applied_at", { withTimezone: true }).notNull().defaultNow(),
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: pg
+2
View File
@@ -21,6 +21,8 @@ export const MCP_TOOL_NAME = {
createApplication: "create_application",
updateApplication: "update_application",
addApplicationNote: "add_application_note",
updateApplicationTimelineEntry: "update_application_timeline_entry",
deleteApplicationTimelineEntry: "delete_application_timeline_entry",
deleteApplication: "delete_application",
bulkUpdateApplications: "bulk_update_applications",
bulkDeleteApplications: "bulk_delete_applications",
+2
View File
@@ -64,6 +64,8 @@ export const TOOL_ANNOTATIONS: Record<McpRegisteredToolName, ToolAnnotations> =
[MCP_TOOL_NAME.createApplication]: WRITE_NON_IDEMPOTENT,
[MCP_TOOL_NAME.updateApplication]: WRITE_IDEMPOTENT,
[MCP_TOOL_NAME.addApplicationNote]: WRITE_NON_IDEMPOTENT,
[MCP_TOOL_NAME.updateApplicationTimelineEntry]: WRITE_IDEMPOTENT,
[MCP_TOOL_NAME.deleteApplicationTimelineEntry]: WRITE_DESTRUCTIVE,
[MCP_TOOL_NAME.deleteApplication]: WRITE_DESTRUCTIVE,
[MCP_TOOL_NAME.bulkUpdateApplications]: WRITE_IDEMPOTENT,
[MCP_TOOL_NAME.bulkDeleteApplications]: WRITE_DESTRUCTIVE,
+27 -1
View File
@@ -16,7 +16,9 @@ const applicationIdSchema = z
.string()
.min(1)
.describe(`Application ID. Use \`${T.listApplications}\` to find valid IDs.`);
const applicationTimelineEntryIdSchema = z.string().min(1).describe("Timeline entry ID from an application response.");
const applicationDocumentKindSchema = z.enum(["resume", "cover-letter"]);
const timelineDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must use YYYY-MM-DD format.");
const httpUrlSchema = z
.string()
.trim()
@@ -67,6 +69,7 @@ const createApplicationSchema = z
...applicationMutableFieldsSchema,
company: z.string().min(1).describe("Company name."),
role: z.string().min(1).describe("Role or job title."),
stageEnteredAt: timelineDateSchema.optional().describe("Initial stage date in YYYY-MM-DD format."),
})
.strict();
@@ -343,9 +346,32 @@ export const TOOL_META = {
[T.addApplicationNote]: {
title: "Add Application Note",
description: "Append a free-text note to an application's timeline.",
inputSchema: z.object({ id: applicationIdSchema, text: z.string().min(1) }),
inputSchema: z.object({
id: applicationIdSchema,
text: z.string().min(1),
date: timelineDateSchema.optional().describe("Optional note date in YYYY-MM-DD format."),
}),
annotations: TOOL_ANNOTATIONS[T.addApplicationNote],
},
[T.updateApplicationTimelineEntry]: {
title: "Update Application Timeline Entry",
description: "Update a timeline entry date, or note text for note entries.",
inputSchema: z
.object({
id: applicationIdSchema,
entryId: applicationTimelineEntryIdSchema,
date: timelineDateSchema.optional().describe("Replacement row date in YYYY-MM-DD format."),
text: z.string().min(1).optional().describe("Replacement note text. Only note entries can change text."),
})
.refine((value) => value.date !== undefined || value.text !== undefined, "Provide date or text to update."),
annotations: TOOL_ANNOTATIONS[T.updateApplicationTimelineEntry],
},
[T.deleteApplicationTimelineEntry]: {
title: "Delete Application Timeline Entry",
description: "Delete a note or older stage entry. The current stage entry cannot be deleted.",
inputSchema: z.object({ id: applicationIdSchema, entryId: applicationTimelineEntryIdSchema }),
annotations: TOOL_ANNOTATIONS[T.deleteApplicationTimelineEntry],
},
[T.deleteApplication]: {
title: "Delete Application",
description: "Permanently delete one job application and its owned uploaded documents.",
+47
View File
@@ -71,6 +71,8 @@ const clientMock = {
create: vi.fn(),
update: vi.fn(),
addNote: vi.fn(),
updateTimelineEntry: vi.fn(),
deleteTimelineEntry: vi.fn(),
delete: vi.fn(),
bulkUpdate: vi.fn(),
bulkDelete: vi.fn(),
@@ -185,6 +187,51 @@ describe("registerTools", () => {
});
});
it("adds dated application notes through the router client", async () => {
clientMock.applications.addNote.mockResolvedValueOnce({ id: "app-1", company: "Acme" });
const { server, registered } = makeFakeServer();
registerTools(server as never, clientMock as never, new Headers());
const tool = registered.find((item) => item.name === "add_application_note")!;
await tool.handler({ id: "app-1", text: "Recruiter replied", date: "2026-07-12" });
expect(clientMock.applications.addNote).toHaveBeenCalledWith({
id: "app-1",
text: "Recruiter replied",
date: "2026-07-12",
});
});
it("updates application timeline entries through the router client", async () => {
clientMock.applications.updateTimelineEntry.mockResolvedValueOnce({ id: "app-1", company: "Acme" });
const { server, registered } = makeFakeServer();
registerTools(server as never, clientMock as never, new Headers());
const tool = registered.find((item) => item.name === "update_application_timeline_entry")!;
await tool.handler({ id: "app-1", entryId: "entry-1", date: "2026-07-13", text: "Updated note" });
expect(clientMock.applications.updateTimelineEntry).toHaveBeenCalledWith({
id: "app-1",
entryId: "entry-1",
date: "2026-07-13",
text: "Updated note",
});
});
it("deletes application timeline entries through the router client", async () => {
clientMock.applications.deleteTimelineEntry.mockResolvedValueOnce({ id: "app-1", company: "Acme" });
const { server, registered } = makeFakeServer();
registerTools(server as never, clientMock as never, new Headers());
const tool = registered.find((item) => item.name === "delete_application_timeline_entry")!;
await tool.handler({ id: "app-1", entryId: "entry-1" });
expect(clientMock.applications.deleteTimelineEntry).toHaveBeenCalledWith({
id: "app-1",
entryId: "entry-1",
});
});
it("imports applications with followUpAt coerced to Date and null preserved", async () => {
clientMock.applications.import.mockResolvedValueOnce({ imported: 2 });
const { server, registered } = makeFakeServer();
+24 -2
View File
@@ -410,11 +410,33 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
server.registerTool(
T.addApplicationNote,
TOOL_META[T.addApplicationNote],
withErrorHandling("adding application note", async ({ id, text: noteText }: { id: string; text: string }) => {
return json(await client.applications.addNote({ id, text: noteText }));
withErrorHandling(
"adding application note",
async ({ id, text: noteText, date }: { id: string; text: string; date?: string | undefined }) => {
return json(await client.applications.addNote({ id, text: noteText, date }));
},
),
);
server.registerTool(
T.updateApplicationTimelineEntry,
TOOL_META[T.updateApplicationTimelineEntry],
withErrorHandling("updating application timeline entry", async (params) => {
return json(await client.applications.updateTimelineEntry(params as never));
}),
);
server.registerTool(
T.deleteApplicationTimelineEntry,
TOOL_META[T.deleteApplicationTimelineEntry],
withErrorHandling(
"deleting application timeline entry",
async ({ id, entryId }: { id: string; entryId: string }) => {
return json(await client.applications.deleteTimelineEntry({ id, entryId }));
},
),
);
server.registerTool(
T.deleteApplication,
TOOL_META[T.deleteApplication],
+14 -6
View File
@@ -26,16 +26,24 @@ export const contactSchema = z.object({
export type Contact = z.infer<typeof contactSchema>;
// Timeline events: `stage` entries are auto-appended when an application moves; `note`
// entries are added manually from the detail panel.
export const activityEventSchema = z.object({
const timelineBaseSchema = z.object({
id: z.string().min(1),
type: z.enum(["stage", "note", "created"]),
text: z.string().trim().min(1),
at: z.coerce.date(),
});
export type ActivityEvent = z.infer<typeof activityEventSchema>;
export const applicationTimelineEntrySchema = z.discriminatedUnion("type", [
timelineBaseSchema.extend({
type: z.literal("stage"),
stage: applicationStatusSchema,
}),
timelineBaseSchema.extend({
type: z.literal("note"),
text: z.string().trim().min(1),
}),
]);
export type ApplicationTimelineEntry = z.infer<typeof applicationTimelineEntrySchema>;
export type ActivityEvent = ApplicationTimelineEntry;
// Reserved for AI enrichment output (autofill / match-score). Free-form so the shape can
// evolve without a migration. See the AI roadmap in the applications feature.
+2 -2
View File
@@ -39,9 +39,9 @@ test("adds an application and logs stage changes and notes", async ({ authPage:
await expect(detail.getByText(company)).toBeVisible();
await detail.getByRole("button", { name: "Move to Applied" }).click();
await expect(detail.getByRole("button", { name: "Move to Screening" })).toBeVisible();
await expect(detail.getByText("Moved to Applied")).toBeVisible();
await expect(detail.locator('[data-timeline-entry="stage"][data-stage="applied"]')).toBeVisible();
await detail.getByPlaceholder("Add a note or log activity…").fill(note);
await detail.locator("[data-timeline-note-input]").fill(note);
await detail.getByRole("button", { name: "Add", exact: true }).click();
await expect(detail.getByText(note)).toBeVisible();
});