mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-21 14:01:42 +10:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user