mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 23:02:17 +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,393 @@
|
||||
import type { Application } from "../types";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArchiveIcon, ArrowRightIcon, TagIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { STAGES } from "@reactive-resume/schema/applications/data";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Checkbox } from "@reactive-resume/ui/components/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
|
||||
import { getInitials } from "@reactive-resume/utils/string";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { applicationsListQueryKey } from "../queries";
|
||||
import { tileColor } from "../tile-color";
|
||||
import { ApplicationActionsMenu } from "./application-actions-menu";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const stageOf = (status: string) => STAGES.find((s) => s.value === status);
|
||||
|
||||
type Props = {
|
||||
applications: Application[];
|
||||
onOpen: (application: Application) => void;
|
||||
onEdit: (application: Application) => void;
|
||||
};
|
||||
|
||||
export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
// Drop selected rows that are no longer in the current (filtered) set, so the bulk-action bar
|
||||
// never reports a count for rows the user can't see.
|
||||
useEffect(() => {
|
||||
setSelected((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const visible = new Set(applications.map((app) => app.id));
|
||||
const next = new Set([...prev].filter((id) => visible.has(id)));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [applications]);
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.applications.tags.queryKey() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
|
||||
};
|
||||
|
||||
const clearSelection = () => setSelected(new Set());
|
||||
|
||||
const bulkUpdate = useMutation(
|
||||
orpc.applications.bulkUpdate.mutationOptions({
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
clearSelection();
|
||||
},
|
||||
onError: () => toast.error(t`Bulk update failed. Please try again.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const bulkDelete = useMutation(
|
||||
orpc.applications.bulkDelete.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
invalidate();
|
||||
clearSelection();
|
||||
toast.success(t`Deleted ${result.deleted} application(s).`);
|
||||
},
|
||||
onError: () => toast.error(t`Bulk delete failed. Please try again.`),
|
||||
}),
|
||||
);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(applications.length / PAGE_SIZE));
|
||||
const safePage = Math.min(page, pageCount - 1);
|
||||
const rows = useMemo(
|
||||
() => applications.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE),
|
||||
[applications, safePage],
|
||||
);
|
||||
|
||||
const pageIds = rows.map((row) => row.id);
|
||||
const allChecked = pageIds.length > 0 && pageIds.every((id) => selected.has(id));
|
||||
const someChecked = pageIds.some((id) => selected.has(id));
|
||||
|
||||
const toggleAll = () => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (allChecked) for (const id of pageIds) next.delete(id);
|
||||
else for (const id of pageIds) next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleOne = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const ids = [...selected];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
{selected.size > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl bg-foreground px-3 py-2 text-background">
|
||||
<span className="font-semibold text-sm">
|
||||
{selected.size} <Trans>selected</Trans>
|
||||
</span>
|
||||
<span className="mx-1 h-4 w-px bg-background/25" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size="sm" variant="secondary" className="h-7">
|
||||
<ArrowRightIcon />
|
||||
<Trans>Move stage</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start">
|
||||
{STAGES.map((stage) => (
|
||||
<DropdownMenuItem key={stage.value} onClick={() => bulkUpdate.mutate({ ids, status: stage.value })}>
|
||||
<span className="size-2 rounded-sm" style={{ background: stage.color }} />
|
||||
{stage.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AddTagPopover onAdd={(tag) => bulkUpdate.mutate({ ids, addTags: [tag] })} />
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-7"
|
||||
onClick={() => bulkUpdate.mutate({ ids, archived: true })}
|
||||
>
|
||||
<ArchiveIcon />
|
||||
<Trans>Archive</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-7 text-destructive"
|
||||
onClick={() => bulkDelete.mutate({ ids })}
|
||||
>
|
||||
<TrashIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ms-auto h-7 text-background hover:bg-background/15"
|
||||
onClick={clearSelection}
|
||||
>
|
||||
<Trans>Clear</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop: full table. Mobile: a stacked card list (below) instead of a 900px h-scroll. */}
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-xl border border-border max-sm:hidden">
|
||||
<table className="w-full min-w-[900px] border-collapse text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-muted/50 backdrop-blur">
|
||||
<tr className="[&>th]:whitespace-nowrap [&>th]:px-3 [&>th]:py-2.5 [&>th]:text-left [&>th]:font-medium [&>th]:text-muted-foreground [&>th]:text-xs [&>th]:uppercase [&>th]:tracking-wide">
|
||||
<th className="w-10">
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
indeterminate={someChecked && !allChecked}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label={t`Select all`}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Company / Role</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Stage</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Location</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Salary</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Tags</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Source</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Applied</Trans>
|
||||
</th>
|
||||
<th className="w-10">
|
||||
<span className="sr-only">
|
||||
<Trans>Actions</Trans>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((app) => {
|
||||
const stage = stageOf(app.status);
|
||||
return (
|
||||
<tr
|
||||
key={app.id}
|
||||
className={cn(
|
||||
"border-border border-t transition-colors hover:bg-muted/40",
|
||||
selected.has(app.id) && "bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<Checkbox
|
||||
checked={selected.has(app.id)}
|
||||
onCheckedChange={() => toggleOne(app.id)}
|
||||
aria-label={t`Select ${app.company}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<button type="button" className="flex items-center gap-2.5 text-left" onClick={() => onOpen(app)}>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-md font-bold text-[10px] text-white",
|
||||
tileColor(app.company),
|
||||
)}
|
||||
>
|
||||
{getInitials(app.company)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium hover:underline">{app.role}</div>
|
||||
<div className="truncate text-muted-foreground text-xs">{app.company}</div>
|
||||
</div>
|
||||
</button>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-sm" style={{ background: stage?.color }} />
|
||||
{stage?.label ?? app.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">{app.location || "—"}</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 font-medium">{app.salary || "—"}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex max-w-40 flex-wrap gap-1">
|
||||
{app.tags.slice(0, 2).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px]">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{app.tags.length > 2 && (
|
||||
<span className="text-muted-foreground text-xs">+{app.tags.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
</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()}
|
||||
</td>
|
||||
<td className="px-1 py-2">
|
||||
<ApplicationActionsMenu application={app} onEdit={onEdit} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile: stacked cards (same paginated rows), tap to open. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-auto sm:hidden">
|
||||
{rows.map((app) => {
|
||||
const stage = stageOf(app.status);
|
||||
return (
|
||||
<div
|
||||
key={app.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl border border-border p-3",
|
||||
selected.has(app.id) && "bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.has(app.id)}
|
||||
onCheckedChange={() => toggleOne(app.id)}
|
||||
aria-label={t`Select ${app.company}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||
onClick={() => onOpen(app)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-md font-bold text-[10px] text-white",
|
||||
tileColor(app.company),
|
||||
)}
|
||||
>
|
||||
{getInitials(app.company)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-sm">{app.role}</div>
|
||||
<div className="truncate text-muted-foreground text-xs">{app.company}</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs">
|
||||
<span className="size-2 shrink-0 rounded-sm" style={{ background: stage?.color }} />
|
||||
<span className="text-muted-foreground">{stage?.label ?? app.status}</span>
|
||||
{app.salary && <span className="truncate font-medium">· {app.salary}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<ApplicationActionsMenu application={app} onEdit={onEdit} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-muted-foreground text-sm">
|
||||
<span>
|
||||
<Trans>
|
||||
Showing {rows.length} of {applications.length}
|
||||
</Trans>
|
||||
</span>
|
||||
{pageCount > 1 && (
|
||||
<div className="ms-auto flex items-center gap-1.5">
|
||||
<Button size="sm" variant="outline" disabled={safePage === 0} onClick={() => setPage(safePage - 1)}>
|
||||
‹
|
||||
</Button>
|
||||
<span className="text-xs">
|
||||
{safePage + 1} / {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={safePage >= pageCount - 1}
|
||||
onClick={() => setPage(safePage + 1)}
|
||||
>
|
||||
›
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddTagPopover({ onAdd }: { onAdd: (tag: string) => void }) {
|
||||
const [value, setValue] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const submit = () => {
|
||||
const tag = value.trim();
|
||||
if (!tag) return;
|
||||
onAdd(tag);
|
||||
setValue("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button size="sm" variant="secondary" className="h-7">
|
||||
<TagIcon />
|
||||
<Trans>Add tag</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-56 p-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder={t`New tag…`}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => event.key === "Enter" && submit()}
|
||||
/>
|
||||
<Button size="sm" onClick={submit}>
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user