mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 07:12:18 +10:00
Add application tracker (#3220)
* feat(applications): job application tracker with AI copilot Add an Applications module at /dashboard/applications: pipeline board (dnd-kit), table view with bulk actions, Insights (fit tiles, funnel, sources, shareable funnel-flow SVG), campaigns, tags, CSV import, and Add/Edit/Detail slide-overs. Each application links a live Reactive Resume. AI "Application Copilot" (applications.ai.*): job-posting autofill, resume↔job match score (fit ring), resume tailoring, and cover-letter / follow-up drafting — via the user's configured provider. Board cards + table rows get context menus (edit / move / archive / delete). Charts are CSS/SVG (no new chart dep); adds a UI Checkbox. Also includes local TanStack devtools setup and toolchain bumps. Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f * feat(applications): close follow-up gaps + squash migrations Finish the deferred/open items on the applications tracker: - Cover-letter upload re-enabled. Fix the storage blocker by deriving the key extension from content type (buildFileKey/EXTENSION_BY_CONTENT_TYPE) instead of hardcoding .jpeg, so PDFs serve correctly and non-JPEG image avatars keep working under FLAG_DISABLE_IMAGE_PROCESSING. Add coverLetterUrl/coverLetterName columns + Documents-section upload/remove. - Contacts editor in the detail sheet (add/edit/remove, keyed per app). - Board caps rendered cards per column (COLUMN_PAGE_SIZE=50 + "Show more"). - Extract new Lingui messages across locales. - Guard coverLetterUrl to http(s)/relative at the API boundary. Squash the five branch-only application-table migrations (create -> +tags -> +cover-letter -> drop -> re-add) into a single clean CREATE TABLE via drizzle-kit generate. Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f * chore: update dependencies * fix(web): address React Doctor findings — compiler, purity, query, component structure prefer-module-scope-pure-function: hoist buildSubtitle, getDecimalPlaces, handleLocaleChange, onLocaleChange, stop, listContent/groupedListContent to module scope so they aren't rebuilt on every render. react-compiler-todo (??=): rewrite draft.metadata.styleRules ??= [] to the non-assignment form to unblock auto-memoization. set-state-in-effect: derive updatedAtLabel at render time instead of syncing it through useState + useEffect. query-destructure-result: destructure useQuery results at call site in resume-analysis and resume-thumbnail to follow TanStack Query v5 convention. only-export-components: extract non-component exports to sibling .ts files so Fast Refresh can preserve component state: - getNextWeights → typography/get-next-weights.ts - detectJsonImportType + ImportType → dialogs/resume/import.utils.ts - getLocaleOptions → features/locale/locale-options.tsx - preview helpers + DEFAULT_PDF_PAGE_SIZE → preview.shared.utils.ts - resolveHighlightToolbarState + defaultHighlightColor → rich-input.utils.ts - computeDelta + getSparklinePoints → statistics.utils.ts no-multi-comp: split multi-component files into focused companions: - ResumePane + ToolbarButton → routes/agent/-components/resume-pane.tsx - DesktopBuilderShell → builder/$resumeId/-components/desktop-builder-shell.tsx - MobileBuilderShell + helpers → builder/$resumeId/-components/mobile-builder-shell.tsx - setBuilderLayout/getBuilderLayout moved to -store/sidebar.ts fix(tests): add Resume type import to section-builder mocks and cast partial mock data as unknown as Resume to satisfy stricter type checking; fix noExplicitAny Biome errors in the same mocks. * feat(applications): improve performance * chore: fix knip issues * perf(builder): halve per-keystroke render cost Section-form fields called `form.handleSubmit()` on every keystroke, which re-validated the whole form and toggled submit state — firing the render cascade twice per character (~6809 renders/keystroke, FPS dropping to 9). Persist via a form-level `listeners.onChange` instead and drop the per-field `handleSubmit()` (basics, custom-fields, design). Narrow header/dock resume subscriptions to metadata slices so they no longer re-render on content edits. Cuts renders 6809 -> 3403 per keystroke (50%), 0 frame drops. Save, preview, and design controls verified working; 449/449 web tests pass. * perf(home): eliminate hero CLS from unreserved video box The hero <section> is `flex items-center` (shrink-to-fit), so the video wrapper's width depended on the video's intrinsic size, which only resolves after the media loads. aspect-ratio couldn't reserve height without a definite width, so the video grew from ~190px to ~563px after first paint and shoved the centered hero text down ~373px (CLS ~0.095). Give the wrapper a definite width (w-full + mx-auto on the CometCard) and set an explicit aspect ratio + width/height on the video so its box is reserved before load. CLS 0.095 -> 0; hero stays visually centered at max-w-4xl. * docs: add application tracker guides * chore(db): squash application migrations * fix(email): import React in auth template for server-side rendering compatibility * chore(release): v5.2.1 * Refactor resume rendering and builder workflows * fix: address application tracker review findings
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
import type { ApplicationStatus } from "@reactive-resume/schema/applications/data";
|
||||
import type { Application } from "../types";
|
||||
import type { FileAttachment } from "./file-attachment-field";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { SparkleIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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 { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@reactive-resume/ui/components/sheet";
|
||||
import { Textarea } from "@reactive-resume/ui/components/textarea";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { applicationsListQueryKey } from "../queries";
|
||||
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 EMPTY = {
|
||||
company: "",
|
||||
role: "",
|
||||
location: "",
|
||||
salary: "",
|
||||
source: "",
|
||||
status: "saved" as ApplicationStatus,
|
||||
resumeId: "",
|
||||
tags: [] as string[],
|
||||
sourceUrl: "",
|
||||
jobDescription: "",
|
||||
followUpAt: "",
|
||||
followUpNote: "",
|
||||
notes: "",
|
||||
resumeFile: null as FileAttachment | null,
|
||||
coverLetter: null as FileAttachment | null,
|
||||
};
|
||||
|
||||
type FormState = typeof EMPTY;
|
||||
|
||||
const toAttachment = (url: string | null, name: string | null): FileAttachment | null =>
|
||||
url ? { url, name: name ?? url } : null;
|
||||
|
||||
function toForm(app: Application): FormState {
|
||||
return {
|
||||
company: app.company,
|
||||
role: app.role,
|
||||
location: app.location ?? "",
|
||||
salary: app.salary ?? "",
|
||||
source: app.source ?? "",
|
||||
status: app.status,
|
||||
resumeId: app.resumeId ?? "",
|
||||
tags: app.tags,
|
||||
sourceUrl: app.sourceUrl ?? "",
|
||||
jobDescription: app.jobDescription ?? "",
|
||||
followUpAt: app.followUpAt ? new Date(app.followUpAt).toISOString().slice(0, 10) : "",
|
||||
followUpNote: app.followUpNote ?? "",
|
||||
notes: app.notes ?? "",
|
||||
resumeFile: toAttachment(app.resumeFileUrl, app.resumeFileName),
|
||||
coverLetter: toAttachment(app.coverLetterUrl, app.coverLetterName),
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
// When provided, the sheet edits this application instead of creating a new one.
|
||||
application?: Application | null;
|
||||
};
|
||||
|
||||
export function ApplicationFormSheet({ open, onOpenChange, application }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!application;
|
||||
|
||||
const [form, setForm] = useState<FormState>(application ? toForm(application) : EMPTY);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
const { data: resumes } = useQuery(orpc.resume.list.queryOptions());
|
||||
const resumeOptions = (resumes ?? []).map((resume) => ({ value: resume.id, label: resume.name }));
|
||||
|
||||
const { data: allTags } = useQuery(orpc.applications.tags.queryOptions());
|
||||
|
||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.applications.tags.queryKey() });
|
||||
if (application) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: orpc.applications.getById.queryKey({ input: { id: application.id } }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const create = useMutation(
|
||||
orpc.applications.create.mutationOptions({
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(t`Application added to your pipeline.`);
|
||||
setForm(EMPTY);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => toast.error(t`Couldn't add the application. Please try again.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const update = useMutation(
|
||||
orpc.applications.update.mutationOptions({
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(t`Application updated.`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => toast.error(t`Couldn't save your changes. Please try again.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const autofill = useMutation(
|
||||
orpc.applications.ai.autofill.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
company: result.company || prev.company,
|
||||
role: result.role || prev.role,
|
||||
location: result.location || prev.location,
|
||||
salary: result.salary || prev.salary,
|
||||
jobDescription: result.jobDescription || prev.jobDescription,
|
||||
}));
|
||||
toast.success(t`Filled in what we could from the posting.`);
|
||||
},
|
||||
onError: (error) => toast.error(error.message || t`Auto-fill failed. Paste the description instead.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const pending = create.isPending || update.isPending;
|
||||
|
||||
const submit = () => {
|
||||
if (!form.company.trim() || !form.role.trim()) return;
|
||||
const payload = {
|
||||
company: form.company.trim(),
|
||||
role: form.role.trim(),
|
||||
status: form.status,
|
||||
location: form.location.trim() || null,
|
||||
salary: form.salary.trim() || null,
|
||||
source: form.source.trim() || null,
|
||||
resumeId: form.resumeId || null,
|
||||
tags: form.tags,
|
||||
sourceUrl: form.sourceUrl.trim() || null,
|
||||
jobDescription: form.jobDescription.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
followUpNote: form.followUpNote.trim() || null,
|
||||
followUpAt: form.followUpAt ? new Date(form.followUpAt) : null,
|
||||
resumeFileUrl: form.resumeFile?.url ?? null,
|
||||
resumeFileName: form.resumeFile?.name ?? null,
|
||||
coverLetterUrl: form.coverLetter?.url ?? null,
|
||||
coverLetterName: form.coverLetter?.name ?? null,
|
||||
};
|
||||
if (application) update.mutate({ id: application.id, ...payload });
|
||||
else create.mutate(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full gap-0 data-[side=right]:sm:max-w-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEditing ? <Trans>Edit application</Trans> : <Trans>Add application</Trans>}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{isEditing ? (
|
||||
<Trans>Update this application's details.</Trans>
|
||||
) : (
|
||||
<Trans>Track a job you're applying to and link the resume you sent.</Trans>
|
||||
)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4 [&>*]:shrink-0">
|
||||
{/* AI job-posting autofill: extracts the fields below from a posting URL. */}
|
||||
{!isEditing && (
|
||||
<div className="rounded-lg border border-border border-dashed p-3">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
<Trans>Paste a job posting URL</Trans>
|
||||
</Label>
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
<Input
|
||||
value={form.sourceUrl}
|
||||
placeholder="https://…"
|
||||
onChange={(event) => set("sourceUrl", event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!form.sourceUrl.trim() || autofill.isPending}
|
||||
onClick={() => autofill.mutate({ sourceUrl: form.sourceUrl.trim() })}
|
||||
>
|
||||
<SparkleIcon />
|
||||
{autofill.isPending ? <Trans>Reading…</Trans> : <Trans>Auto-fill</Trans>}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-muted-foreground">
|
||||
<Trans>Let AI read the posting and fill the fields below.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field label={t`Company`} required>
|
||||
<Input value={form.company} onChange={(event) => set("company", event.target.value)} />
|
||||
</Field>
|
||||
<Field label={t`Role / title`} required>
|
||||
<Input value={form.role} onChange={(event) => set("role", event.target.value)} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t`Location`}>
|
||||
<Input
|
||||
value={form.location}
|
||||
list="application-locations"
|
||||
placeholder={t`Remote, Hybrid, a city…`}
|
||||
onChange={(event) => set("location", event.target.value)}
|
||||
/>
|
||||
<datalist id="application-locations">
|
||||
<option value="Remote" />
|
||||
<option value="Hybrid" />
|
||||
<option value="In-office" />
|
||||
</datalist>
|
||||
</Field>
|
||||
<Field label={t`Salary range`}>
|
||||
<Input value={form.salary} onChange={(event) => set("salary", event.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t`Source`}>
|
||||
<Input
|
||||
value={form.source}
|
||||
list="application-sources"
|
||||
placeholder={t`LinkedIn, Referral…`}
|
||||
onChange={(event) => set("source", event.target.value)}
|
||||
/>
|
||||
<datalist id="application-sources">
|
||||
{SOURCE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
<Field label={t`Stage`}>
|
||||
<Combobox
|
||||
className="w-full"
|
||||
value={form.status}
|
||||
options={STAGES.map((s) => ({ value: s.value, label: s.label }))}
|
||||
onValueChange={(value) => value && set("status", value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<Combobox
|
||||
className="w-full"
|
||||
value={form.resumeId || null}
|
||||
options={resumeOptions}
|
||||
placeholder={t`Link a Reactive Resume (recommended)`}
|
||||
showClear
|
||||
emptyMessage={t`No resumes yet.`}
|
||||
onValueChange={(value) => set("resumeId", value ?? "")}
|
||||
/>
|
||||
<FileAttachmentField
|
||||
value={form.resumeFile}
|
||||
attachLabel={t`Or upload a resume PDF`}
|
||||
onChange={(value) => set("resumeFile", value)}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<Trans>Linking a Reactive Resume enables AI match scoring and tailoring.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label={t`Cover letter`}>
|
||||
<FileAttachmentField
|
||||
value={form.coverLetter}
|
||||
attachLabel={t`Attach a cover letter (PDF)`}
|
||||
onChange={(value) => set("coverLetter", value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t`Tags`}>
|
||||
<TagsField value={form.tags} suggestions={allTags ?? []} onChange={(tags) => set("tags", tags)} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t`Follow-up date`}>
|
||||
<Input type="date" value={form.followUpAt} onChange={(event) => set("followUpAt", event.target.value)} />
|
||||
</Field>
|
||||
<Field label={t`Follow-up note`}>
|
||||
<Input value={form.followUpNote} onChange={(event) => set("followUpNote", event.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label={t`Job description`}>
|
||||
<Textarea
|
||||
value={form.jobDescription}
|
||||
rows={3}
|
||||
placeholder={t`Paste the posting — powers AI match scoring and tailoring.`}
|
||||
onChange={(event) => set("jobDescription", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t`Notes`}>
|
||||
<Textarea
|
||||
value={form.notes}
|
||||
rows={3}
|
||||
placeholder={t`Referred by…, things to emphasize, etc.`}
|
||||
onChange={(event) => set("notes", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="flex-row justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="button" disabled={!form.company.trim() || !form.role.trim() || pending} onClick={submit}>
|
||||
{isEditing ? <Trans>Save changes</Trans> : <Trans>Add to pipeline</Trans>}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TagsFieldProps = {
|
||||
value: string[];
|
||||
suggestions: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
};
|
||||
|
||||
// Type-and-Enter tag input with chips + an autocomplete datalist of the user's existing tags.
|
||||
function TagsField({ value, suggestions, onChange }: TagsFieldProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const add = () => {
|
||||
const tag = draft.trim();
|
||||
if (!tag || value.includes(tag)) {
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
onChange([...value, tag]);
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
value={draft}
|
||||
list="application-tags"
|
||||
placeholder={t`Add a tag and press Enter…`}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
add();
|
||||
}
|
||||
}}
|
||||
onBlur={add}
|
||||
/>
|
||||
<datalist id="application-tags">
|
||||
{suggestions.map((tag) => (
|
||||
<option key={tag} value={tag} />
|
||||
))}
|
||||
</datalist>
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{value.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-muted-foreground text-xs"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
title={t`Remove tag`}
|
||||
className="hover:text-destructive"
|
||||
onClick={() => onChange(value.filter((t) => t !== tag))}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user