mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 14:52: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:
@@ -4,6 +4,7 @@ import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
BrainIcon,
|
||||
BriefcaseIcon,
|
||||
ChatCircleDotsIcon,
|
||||
GearSixIcon,
|
||||
KeyIcon,
|
||||
@@ -50,6 +51,11 @@ const appSidebarItems = [
|
||||
label: msg`Resumes`,
|
||||
href: "/dashboard/resumes",
|
||||
},
|
||||
{
|
||||
icon: <BriefcaseIcon />,
|
||||
label: msg`Applications`,
|
||||
href: "/dashboard/applications",
|
||||
},
|
||||
{
|
||||
icon: <ChatCircleDotsIcon />,
|
||||
label: msg`Agents`,
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import type { Application } from "@/features/applications/types";
|
||||
import { msg, t } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
BriefcaseIcon,
|
||||
ChartBarIcon,
|
||||
DownloadSimpleIcon,
|
||||
FunnelIcon,
|
||||
KanbanIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PlusIcon,
|
||||
RowsIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link, stripSearchParams, useNavigate } from "@tanstack/react-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@reactive-resume/ui/components/input-group";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { ApplicationDetailSheet } from "@/features/applications/components/application-detail-sheet";
|
||||
import { ApplicationFormSheet } from "@/features/applications/components/application-form-sheet";
|
||||
import { ApplicationBoard } from "@/features/applications/components/board";
|
||||
import { ImportApplicationsSheet } from "@/features/applications/components/import-applications-sheet";
|
||||
import { ApplicationInsights } from "@/features/applications/components/insights-view";
|
||||
import { ApplicationTable } from "@/features/applications/components/table-view";
|
||||
import { applicationsListQueryOptions } from "@/features/applications/queries";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "updated", label: msg`Last updated` },
|
||||
{ value: "applied", label: msg`Date applied` },
|
||||
{ value: "company", label: msg`Company A–Z` },
|
||||
{ value: "role", label: msg`Role A–Z` },
|
||||
] as const;
|
||||
|
||||
type SortKey = (typeof SORT_OPTIONS)[number]["value"];
|
||||
|
||||
const searchSchema = z.object({
|
||||
search: z.string().default(""),
|
||||
view: z.enum(["board", "table", "insights"]).default("board"),
|
||||
tags: z.array(z.string()).default([]),
|
||||
sort: z.enum(["updated", "applied", "company", "role"]).default("updated"),
|
||||
archived: z.boolean().default(false),
|
||||
});
|
||||
type Search = z.output<typeof searchSchema>;
|
||||
const defaultSearch: Search = { search: "", view: "board", tags: [], sort: "updated", archived: false };
|
||||
|
||||
export const Route = createFileRoute("/dashboard/applications/")({
|
||||
component: RouteComponent,
|
||||
validateSearch: searchSchema,
|
||||
search: { middlewares: [stripSearchParams(defaultSearch)] },
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { i18n } = useLingui();
|
||||
const { search, view, tags, sort, archived } = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Application | null>(null);
|
||||
const [selected, setSelected] = useState<Application | null>(null);
|
||||
|
||||
// Editing from the detail panel: close the panel, open the edit form on the same application.
|
||||
const startEdit = (application: Application) => {
|
||||
setSelected(null);
|
||||
setEditing(application);
|
||||
};
|
||||
|
||||
const { data: applications } = useQuery(applicationsListQueryOptions());
|
||||
const { data: allTags } = useQuery(orpc.applications.tags.queryOptions());
|
||||
|
||||
// Board & table hide archived; tag/search filters + sort are applied client-side.
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
const rows = (applications ?? [])
|
||||
.filter((app) => archived || !app.archived)
|
||||
.filter((app) => tags.length === 0 || tags.every((tag: string) => app.tags.includes(tag)))
|
||||
.filter((app) => !query || app.company.toLowerCase().includes(query) || app.role.toLowerCase().includes(query));
|
||||
|
||||
const compare: Record<SortKey, (a: Application, b: Application) => number> = {
|
||||
updated: (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
applied: (a, b) => new Date(b.appliedAt).getTime() - new Date(a.appliedAt).getTime(),
|
||||
company: (a, b) => a.company.localeCompare(b.company),
|
||||
role: (a, b) => a.role.localeCompare(b.role),
|
||||
};
|
||||
return rows.sort(compare[sort as SortKey]);
|
||||
}, [applications, search, tags, sort, archived]);
|
||||
|
||||
const archivedCount = (applications ?? []).filter((app) => app.archived).length;
|
||||
|
||||
const isEmpty = (applications?.length ?? 0) === 0;
|
||||
|
||||
const setSearch = (patch: Partial<Search>) => void navigate({ search: (prev: Search) => ({ ...prev, ...patch }) });
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-2rem)] flex-col gap-4">
|
||||
<DashboardHeader
|
||||
icon={BriefcaseIcon}
|
||||
title={t`Applications`}
|
||||
actions={
|
||||
!isEmpty ? (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Import CSV</Trans>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
<PlusIcon />
|
||||
<Trans>Add application</Trans>
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
{isEmpty ? (
|
||||
<EmptyState onAdd={() => setAddOpen(true)} onImport={() => setImportOpen(true)} />
|
||||
) : (
|
||||
<>
|
||||
{/* One row: search grows, filters stay fixed, icon-only view switcher on the right. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<InputGroup className="min-w-24 max-w-72 flex-1">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<MagnifyingGlassIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={search}
|
||||
placeholder={t`Search applications…`}
|
||||
onChange={(event) => setSearch({ search: event.target.value })}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{/* Desktop: filters inline. Mobile: collapsed into the Filters popover below. */}
|
||||
{(allTags?.length ?? 0) > 0 && (
|
||||
<Combobox
|
||||
multiple
|
||||
className="w-40 min-w-0 shrink max-sm:hidden"
|
||||
value={tags}
|
||||
placeholder={t`Filter by tags`}
|
||||
options={(allTags ?? []).map((tag) => ({ value: tag, label: tag }))}
|
||||
onValueChange={(value) => setSearch({ tags: value ?? [] })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view !== "insights" && (
|
||||
<Combobox
|
||||
className="w-40 min-w-0 shrink max-sm:hidden"
|
||||
value={sort}
|
||||
placeholder={t`Sort by…`}
|
||||
options={SORT_OPTIONS.map((option) => ({ value: option.value, label: i18n.t(option.label) }))}
|
||||
onValueChange={(value) => value && setSearch({ sort: value as SortKey })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{archivedCount > 0 && view !== "insights" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={archived ? "secondary" : "outline"}
|
||||
className="shrink-0 max-sm:hidden"
|
||||
onClick={() => setSearch({ archived: !archived })}
|
||||
>
|
||||
<ArchiveIcon />
|
||||
<Trans>Archived</Trans> ({archivedCount})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Mobile-only: one button holds every filter so the row never overflows on a phone. */}
|
||||
{view !== "insights" && (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button size="icon-sm" variant="outline" className="relative shrink-0 sm:hidden">
|
||||
<FunnelIcon />
|
||||
{(tags.length > 0 || archived) && (
|
||||
<span className="absolute end-1 top-1 size-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-64 p-3">
|
||||
{(allTags?.length ?? 0) > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
<Trans>Filter by tags</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
multiple
|
||||
className="w-full"
|
||||
value={tags}
|
||||
placeholder={t`Any tag`}
|
||||
options={(allTags ?? []).map((tag) => ({ value: tag, label: tag }))}
|
||||
onValueChange={(value) => setSearch({ tags: value ?? [] })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
<Trans>Sort by</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
className="w-full"
|
||||
value={sort}
|
||||
options={SORT_OPTIONS.map((option) => ({ value: option.value, label: i18n.t(option.label) }))}
|
||||
onValueChange={(value) => value && setSearch({ sort: value as SortKey })}
|
||||
/>
|
||||
</div>
|
||||
{archivedCount > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={archived ? "secondary" : "outline"}
|
||||
className="w-full"
|
||||
onClick={() => setSearch({ archived: !archived })}
|
||||
>
|
||||
<ArchiveIcon />
|
||||
<Trans>Archived</Trans> ({archivedCount})
|
||||
</Button>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<Tabs className="ms-auto shrink-0" value={view}>
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
value="board"
|
||||
title={i18n.t(msg`Board`)}
|
||||
nativeButton={false}
|
||||
render={<Link to="." search={(p: Search) => ({ ...p, view: "board" })} />}
|
||||
>
|
||||
<KanbanIcon />
|
||||
<span className="sr-only">{i18n.t(msg`Board`)}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="table"
|
||||
title={i18n.t(msg`Table`)}
|
||||
nativeButton={false}
|
||||
render={<Link to="." search={(p: Search) => ({ ...p, view: "table" })} />}
|
||||
>
|
||||
<RowsIcon />
|
||||
<span className="sr-only">{i18n.t(msg`Table`)}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="insights"
|
||||
title={i18n.t(msg`Insights`)}
|
||||
nativeButton={false}
|
||||
render={<Link to="." search={(p: Search) => ({ ...p, view: "insights" })} />}
|
||||
>
|
||||
<ChartBarIcon />
|
||||
<span className="sr-only">{i18n.t(msg`Insights`)}</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{view !== "insights" && filtered.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
|
||||
<p className="font-medium text-sm">
|
||||
<Trans>No applications match your filters.</Trans>
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSearch({ search: "", tags: [], archived: false })}
|
||||
>
|
||||
<Trans>Clear filters</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{view === "board" && (
|
||||
<ApplicationBoard applications={filtered} onOpen={setSelected} onEdit={setEditing} />
|
||||
)}
|
||||
{view === "table" && (
|
||||
<ApplicationTable applications={filtered} onOpen={setSelected} onEdit={setEditing} />
|
||||
)}
|
||||
{view === "insights" && <ApplicationInsights applications={applications ?? []} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ApplicationFormSheet open={addOpen} onOpenChange={setAddOpen} />
|
||||
<ApplicationFormSheet open={!!editing} application={editing} onOpenChange={(open) => !open && setEditing(null)} />
|
||||
<ImportApplicationsSheet open={importOpen} onOpenChange={setImportOpen} />
|
||||
<ApplicationDetailSheet
|
||||
application={selected}
|
||||
onOpenChange={(open) => !open && setSelected(null)}
|
||||
onEdit={startEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ onAdd, onImport }: { onAdd: () => void; onImport: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<BriefcaseIcon className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<h2 className="font-semibold text-lg">
|
||||
<Trans>Track your first application</Trans>
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Add a job you're applying to, link the resume you sent, and move it through your pipeline as things
|
||||
progress.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onAdd}>
|
||||
<PlusIcon />
|
||||
<Trans>Add application</Trans>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onImport}>
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Import from CSV</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,11 @@ const createResumeThumbnailUrl = async (data: ResumeData, signal: AbortSignal) =
|
||||
};
|
||||
|
||||
function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | undefined): ThumbnailState {
|
||||
const thumbnailQuery = useQuery({
|
||||
const {
|
||||
data: thumbnailData,
|
||||
error: thumbnailError,
|
||||
isError: thumbnailIsError,
|
||||
} = useQuery({
|
||||
queryKey: ["resume-thumbnail", cacheKey],
|
||||
queryFn: ({ signal }) => {
|
||||
if (!data) throw new Error("Resume data is required to generate a thumbnail.");
|
||||
@@ -50,20 +54,20 @@ function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | und
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (thumbnailQuery.error) console.error("Failed to generate resume thumbnail", thumbnailQuery.error);
|
||||
}, [thumbnailQuery.error]);
|
||||
if (thumbnailError) console.error("Failed to generate resume thumbnail", thumbnailError);
|
||||
}, [thumbnailError]);
|
||||
|
||||
useEffect(() => {
|
||||
const url = thumbnailQuery.data;
|
||||
const url = thumbnailData;
|
||||
|
||||
return () => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [thumbnailQuery.data]);
|
||||
}, [thumbnailData]);
|
||||
|
||||
if (!data || !cacheKey) return { status: "idle" };
|
||||
if (thumbnailQuery.isError) return { status: "error" };
|
||||
if (thumbnailQuery.data) return { status: "ready", url: thumbnailQuery.data };
|
||||
if (thumbnailIsError) return { status: "error" };
|
||||
if (thumbnailData) return { status: "ready", url: thumbnailData };
|
||||
|
||||
return { status: "loading" };
|
||||
}
|
||||
@@ -71,15 +75,15 @@ function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | und
|
||||
export function ResumeThumbnail({ isLocked, resume }: ResumeThumbnailProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(containerRef, { amount: 0.1, margin: "240px", once: true });
|
||||
const resumeQuery = useQuery({
|
||||
const { data: resumeData, isError: resumeIsError } = useQuery({
|
||||
...orpc.resume.getById.queryOptions({ input: { id: resume.id } }),
|
||||
enabled: isInView,
|
||||
});
|
||||
const thumbnail = useResumeThumbnail(
|
||||
resumeQuery.data?.data,
|
||||
resumeData?.data,
|
||||
isInView ? getResumeThumbnailCacheKey(resume.id, resume.updatedAt) : undefined,
|
||||
);
|
||||
const hasFailed = resumeQuery.isError || thumbnail.status === "error";
|
||||
const hasFailed = resumeIsError || thumbnail.status === "error";
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user