diff --git a/apps/server/package.json b/apps/server/package.json index 5d09c3149..cceea199e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -77,7 +77,7 @@ "@types/node": "^26.1.0", "@types/pg": "^8.20.0", "@types/react": "^19.2.17", - "@typescript/native-preview": "7.0.0-dev.20260704.1", + "@typescript/native-preview": "7.0.0-dev.20260705.1", "tsdown": "^0.22.3", "tsx": "^4.23.0", "typescript": "^6.0.3", diff --git a/apps/web/package.json b/apps/web/package.json index 242345739..d49889450 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -91,12 +91,16 @@ "@lingui/vite-plugin": "^6.4.0", "@reactive-resume/config": "workspace:*", "@rolldown/plugin-babel": "^0.2.3", + "@tanstack/devtools-vite": "^0.8.1", + "@tanstack/react-devtools": "^0.10.8", + "@tanstack/react-query-devtools": "^5.101.2", + "@tanstack/react-router-devtools": "^1.167.0", "@tanstack/router-plugin": "^1.168.19", "@types/babel__core": "^7.20.5", "@types/pg": "^8.20.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "@typescript/native-preview": "7.0.0-dev.20260704.1", + "@typescript/native-preview": "7.0.0-dev.20260705.1", "@vitejs/plugin-react": "^6.0.3", "babel-plugin-macros": "^3.1.0", "babel-plugin-react-compiler": "^1.0.0", diff --git a/apps/web/src/features/applications/components/application-actions-menu.tsx b/apps/web/src/features/applications/components/application-actions-menu.tsx new file mode 100644 index 000000000..5ae2bb735 --- /dev/null +++ b/apps/web/src/features/applications/components/application-actions-menu.tsx @@ -0,0 +1,129 @@ +import type { Application } from "../types"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { + ArchiveIcon, + ArrowRightIcon, + DotsThreeVerticalIcon, + PencilSimpleIcon, + TrashIcon, + TrayArrowUpIcon, +} from "@phosphor-icons/react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { STAGES } from "@reactive-resume/schema/applications/data"; +import { Button } from "@reactive-resume/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@reactive-resume/ui/components/dropdown-menu"; +import { cn } from "@reactive-resume/utils/style"; +import { orpc } from "@/libs/orpc/client"; +import { applicationsListQueryKey } from "../queries"; + +type Props = { + application: Application; + onEdit: (application: Application) => void; + // Whether the trigger should only appear on hover of the parent (cards). Rows keep it visible. + showOnHover?: boolean; + className?: string; +}; + +// Shared kebab menu for board cards and table rows: edit, move stage, archive, delete. +export function ApplicationActionsMenu({ application, onEdit, showOnHover, className }: Props) { + const queryClient = useQueryClient(); + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() }); + void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() }); + void queryClient.invalidateQueries({ queryKey: orpc.applications.tags.queryKey() }); + }; + + const update = useMutation( + orpc.applications.update.mutationOptions({ + onSuccess: invalidate, + onError: () => toast.error(t`Something went wrong. Please try again.`), + }), + ); + + const remove = useMutation( + orpc.applications.delete.mutationOptions({ + onSuccess: () => { + invalidate(); + toast.success(t`Application deleted.`); + }, + onError: () => toast.error(t`Couldn't delete the application.`), + }), + ); + + // Stop pointer/click from reaching the card (which would start a drag or open the detail panel). + const stop = (event: React.SyntheticEvent) => event.stopPropagation(); + + return ( +
+ + + + + } + /> + + onEdit(application)}> + + Edit + + + + + + Move to + + + {STAGES.map((stage) => ( + update.mutate({ id: application.id, status: stage.value })} + > + + {stage.label} + + ))} + + + + update.mutate({ id: application.id, archived: !application.archived })}> + {application.archived ? : } + {application.archived ? Unarchive : Archive} + + + + + remove.mutate({ id: application.id })}> + + Delete + + + +
+ ); +} diff --git a/apps/web/src/features/applications/components/application-ai-copilot.tsx b/apps/web/src/features/applications/components/application-ai-copilot.tsx new file mode 100644 index 000000000..8c145b569 --- /dev/null +++ b/apps/web/src/features/applications/components/application-ai-copilot.tsx @@ -0,0 +1,269 @@ +import type { Application } from "../types"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { + ArrowsClockwiseIcon, + CaretRightIcon, + CopyIcon, + EnvelopeSimpleIcon, + MagicWandIcon, + PaperPlaneTiltIcon, + SparkleIcon, + SpinnerGapIcon, +} from "@phosphor-icons/react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { cn } from "@reactive-resume/utils/style"; +import { orpc } from "@/libs/orpc/client"; +import { applicationsListQueryKey } from "../queries"; + +// Score bands drive the ring color and the label — a job-fit gauge, not a generic percentage. +function band(score: number) { + if (score >= 75) return { color: "#10b981", label: t`Strong fit` }; + if (score >= 50) return { color: "#f59e0b", label: t`Worth a shot` }; + return { color: "#f43f5e", label: t`A stretch` }; +} + +function aiGaps(app: Application): string[] { + const meta = app.aiMetadata as { matchScore?: { gaps?: unknown } } | null | undefined; + const gaps = meta?.matchScore?.gaps; + return Array.isArray(gaps) ? gaps.filter((gap): gap is string => typeof gap === "string") : []; +} + +// The signature element: a circular resume-fit gauge that animates to the score. +function FitRing({ score }: { score: number }) { + const size = 60; + const stroke = 5; + const r = (size - stroke) / 2; + const c = 2 * Math.PI * r; + const { color } = band(score); + return ( + + ); +} + +type ActionRowProps = { + icon: React.ReactNode; + title: React.ReactNode; + description: React.ReactNode; + disabled?: boolean; + pending?: boolean; + onClick: () => void; +}; + +function ActionRow({ icon, title, description, disabled, pending, onClick }: ActionRowProps) { + return ( + + ); +} + +type Props = { application: Application }; + +export function ApplicationAiCopilot({ application }: Props) { + const queryClient = useQueryClient(); + const [draft, setDraft] = useState<{ kind: string; text: string } | null>(null); + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() }); + void queryClient.invalidateQueries({ + queryKey: orpc.applications.getById.queryKey({ input: { id: application.id } }), + }); + }; + + const matchScore = useMutation( + orpc.applications.ai.matchScore.mutationOptions({ + onSuccess: invalidate, + onError: (error) => toast.error(error.message || t`Match scoring failed.`), + }), + ); + const tailorResume = useMutation( + orpc.applications.ai.tailorResume.mutationOptions({ + onSuccess: (result) => { + invalidate(); + toast.success(t`Created "${result.name}" and linked it to this application.`); + }, + onError: (error) => toast.error(error.message || t`Tailoring failed.`), + }), + ); + const draftMessage = useMutation( + orpc.applications.ai.draftMessage.mutationOptions({ + onSuccess: (result, variables) => setDraft({ kind: variables.kind, text: result.text }), + onError: (error) => toast.error(error.message || t`Drafting failed.`), + }), + ); + + const pending = matchScore.isPending || tailorResume.isPending || draftMessage.isPending; + const canScore = !!application.resumeId && !!application.jobDescription; + const score = application.matchScore; + const gaps = aiGaps(application); + + return ( +
+
+ + + + + Application Copilot + +
+ + {/* Fit gauge — the signature. Adapts to whether prerequisites are met and whether a score exists. */} +
+ {!canScore ? ( +

+ Link a resume and add a job description (Edit) to score your fit and tailor a copy. +

+ ) : score == null ? ( + + ) : ( +
+
+ + {score} +
+
+
+ + {band(score).label} + + +
+ {gaps.length > 0 && ( +

+ Gaps: {gaps.slice(0, 3).join(" · ")} +

+ )} +
+
+ )} +
+ +
+ } + title={Tailor my resume} + description={t`Create a copy tuned to this job`} + disabled={!canScore} + pending={tailorResume.isPending} + onClick={() => tailorResume.mutate({ id: application.id })} + /> + } + title={Draft a cover letter} + description={t`From your resume and the posting`} + pending={draftMessage.isPending && draft?.kind !== "follow-up"} + onClick={() => draftMessage.mutate({ id: application.id, kind: "cover-letter" })} + /> + } + title={Draft a follow-up} + description={t`A friendly nudge for the recruiter`} + pending={draftMessage.isPending && draft?.kind === "follow-up"} + onClick={() => draftMessage.mutate({ id: application.id, kind: "follow-up" })} + /> +
+ + {draft && ( +
+
+ + {draft.kind === "cover-letter" ? Cover letter draft : Follow-up draft} + +
+ + +
+
+

+ {draft.text} +

+
+ )} +
+ ); +} diff --git a/apps/web/src/features/applications/components/application-card.tsx b/apps/web/src/features/applications/components/application-card.tsx new file mode 100644 index 000000000..c1d15ee41 --- /dev/null +++ b/apps/web/src/features/applications/components/application-card.tsx @@ -0,0 +1,115 @@ +import type { Application } from "../types"; +import { Trans } from "@lingui/react/macro"; +import { FileTextIcon, MapPinIcon } from "@phosphor-icons/react"; +import { getInitials } from "@reactive-resume/utils/string"; +import { cn } from "@reactive-resume/utils/style"; +import { ApplicationActionsMenu } from "./application-actions-menu"; + +// Deterministic accent color for a company's logo tile (design shows colored initials tiles). +const TILE_COLORS = [ + "bg-rose-500", + "bg-orange-500", + "bg-amber-500", + "bg-emerald-500", + "bg-teal-500", + "bg-sky-500", + "bg-indigo-500", + "bg-violet-500", + "bg-fuchsia-500", +]; + +function tileColor(seed: string) { + let hash = 0; + for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) | 0; + return TILE_COLORS[Math.abs(hash) % TILE_COLORS.length]; +} + +type Props = { + application: Application; + onClick?: () => void; + onEdit?: (application: Application) => void; + className?: string; + dragging?: boolean; +}; + +export function ApplicationCard({ application, onClick, onEdit, className, dragging }: Props) { + const followUp = application.followUpAt && !application.archived; + + return ( + // biome-ignore lint/a11y/useSemanticElements: the card nests an actions-menu + + + {/* stage stepper */} +
+ {STAGES.map((stage, i) => ( + + ))} +
+
+ {STAGES[idx]?.label ?? current.status} + {nextStage && ( + + )} +
+ + +
+ {/* key facts */} +
+ + + + +
+ + {current.sourceUrl && ( + + + Job posting + + )} + + {/* linked resume */} +
+ {current.resumeId ? ( + + + RXR + + + Linked Reactive Resume + + + + ) : ( +

+ No resume linked. +

+ )} +
+ + {/* contacts */} + {current.contacts.length > 0 && ( +
+
+ {current.contacts.map((contact, i) => ( +
+ + {contact.name.slice(0, 2).toUpperCase()} + +
+
{contact.name}
+ {contact.role &&
{contact.role}
} +
+ {contact.type && ( + + {contact.type} + + )} +
+ ))} +
+
+ )} + + {/* follow-up */} + {current.followUpAt && ( +
+
+ {new Date(current.followUpAt).toLocaleDateString()} + {current.followUpNote ? ` — ${current.followUpNote}` : ""} +
+
+ )} + + {/* activity timeline */} +
+
+ {[...current.activity] + .sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()) + .map((event) => ( +
+ +
+
{event.text}
+
{new Date(event.at).toLocaleString()}
+
+
+ ))} +
+
+ setNote(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && note.trim()) addNote.mutate({ id: current.id, text: note.trim() }); + }} + /> + +
+
+ + +
+ +
+ {current.status !== "rejected" && ( + + )} + + +
+ + + ); +} + +function Fact({ label, value }: { label: string; value: string | null | undefined }) { + return ( +
+
{label}
+
{value || "—"}
+
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} diff --git a/apps/web/src/features/applications/components/application-form-sheet.tsx b/apps/web/src/features/applications/components/application-form-sheet.tsx new file mode 100644 index 000000000..ce8b2049d --- /dev/null +++ b/apps/web/src/features/applications/components/application-form-sheet.tsx @@ -0,0 +1,325 @@ +import type { ApplicationStatus } from "@reactive-resume/schema/applications/data"; +import type { Application } from "../types"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { SparkleIcon } 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"; + +const SOURCE_OPTIONS = ["LinkedIn", "Indeed", "Company website", "Referral", "Recruiter", "Other"].map((s) => ({ + value: s, + label: s, +})); + +const EMPTY = { + company: "", + role: "", + location: "", + salary: "", + source: "", + status: "saved" as ApplicationStatus, + resumeId: "", + campaign: "", + sourceUrl: "", + jobDescription: "", + followUpAt: "", + followUpNote: "", + notes: "", +}; + +type FormState = typeof EMPTY; + +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 ?? "", + campaign: app.campaign ?? "", + sourceUrl: app.sourceUrl ?? "", + jobDescription: app.jobDescription ?? "", + followUpAt: app.followUpAt ? new Date(app.followUpAt).toISOString().slice(0, 10) : "", + followUpNote: app.followUpNote ?? "", + notes: app.notes ?? "", + }; +} + +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(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: campaigns } = useQuery(orpc.applications.campaigns.queryOptions()); + + const set = (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.campaigns.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 || null, + resumeId: form.resumeId || null, + campaign: form.campaign.trim() || null, + 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, + }; + if (application) update.mutate({ id: application.id, ...payload }); + else create.mutate(payload); + }; + + return ( + + + + {isEditing ? Edit application : Add application} + + {isEditing ? ( + Update this application's details. + ) : ( + Track a job you're applying to and link the resume you sent. + )} + + + +
+ {/* AI job-posting autofill: extracts the fields below from a posting URL. */} + {!isEditing && ( +
+ +
+ set("sourceUrl", event.target.value)} + /> + +
+

+ Let AI read the posting and fill the fields below. +

+
+ )} + + + set("company", event.target.value)} /> + + + set("role", event.target.value)} /> + + +
+ + set("location", event.target.value)} /> + + + set("salary", event.target.value)} /> + +
+ +
+ + set("source", value ?? "")} + /> + + + ({ value: s.value, label: s.label }))} + onValueChange={(value) => value && set("status", value)} + /> + +
+ + + set("resumeId", value ?? "")} + /> + + + + set("campaign", event.target.value)} + /> + + {(campaigns ?? []).map((campaign) => ( + + + +
+ + set("followUpAt", event.target.value)} /> + + + set("followUpNote", event.target.value)} /> + +
+ + +