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:
Amruth Pillai
2026-07-05 23:44:04 +02:00
committed by GitHub
parent be43b4556b
commit b404dbd42a
198 changed files with 48828 additions and 2246 deletions
+4 -188
View File
@@ -1,208 +1,24 @@
import type * as React from "react";
import type { PanelImperativeHandle } from "react-resizable-panels";
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
ArrowSquareOutIcon,
ChatCircleDotsIcon,
CircleNotchIcon,
FilePdfIcon,
MinusIcon,
PlusIcon,
SidebarSimpleIcon,
SquaresFourIcon,
} from "@phosphor-icons/react";
import { ChatCircleDotsIcon, SidebarSimpleIcon, SquaresFourIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useCallback, useRef, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { cn } from "@reactive-resume/utils/style";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { ResumePreview } from "@/features/resume/preview/preview";
import { orpc } from "@/libs/orpc/client";
import { AgentChat } from "./-components/agent-chat";
import { ResumePane } from "./-components/resume-pane";
import { AgentThreadSidebar } from "./-components/thread-sidebar";
import { useAgentResumeUpdateSubscription } from "./-hooks/use-agent-resume-updates";
type AgentThreadDetail = RouterOutput["agent"]["threads"]["get"];
type ToolbarButtonProps = React.ComponentProps<typeof Button> & {
label: string;
};
type ResumePaneProps = {
resume: AgentThreadDetail["resume"];
};
export const Route = createFileRoute("/agent/$threadId")({
component: RouteComponent,
});
const AGENT_PREVIEW_ZOOM_STORAGE_KEY = "reactive-resume:agent-preview-zoom:v3";
const MIN_PREVIEW_ZOOM = 0.4;
const MAX_PREVIEW_ZOOM = 1.5;
const PREVIEW_ZOOM_STEP = 0.05;
const DEFAULT_PREVIEW_ZOOM = 1;
function clampPreviewZoom(value: number) {
return Math.min(MAX_PREVIEW_ZOOM, Math.max(MIN_PREVIEW_ZOOM, value));
}
function getInitialPreviewZoom() {
if (typeof window === "undefined") return DEFAULT_PREVIEW_ZOOM;
// ponytail: Number(null) === 0, so guard with a null-check before parsing
const raw = window.localStorage.getItem(AGENT_PREVIEW_ZOOM_STORAGE_KEY);
if (raw === null) return DEFAULT_PREVIEW_ZOOM;
const stored = Number(raw);
return Number.isFinite(stored) ? clampPreviewZoom(stored) : DEFAULT_PREVIEW_ZOOM;
}
function ToolbarButton({ label, children, ...props }: ToolbarButtonProps) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button size="icon-sm" variant="ghost" aria-label={label} {...props}>
{children}
</Button>
}
/>
<TooltipContent side="bottom" align="center">
{label}
</TooltipContent>
</Tooltip>
);
}
function ResumePane({ resume }: ResumePaneProps) {
const [zoom, setZoom] = useState(getInitialPreviewZoom);
const [isPrinting, setIsPrinting] = useState(false);
useEffect(() => {
window.localStorage.setItem(AGENT_PREVIEW_ZOOM_STORAGE_KEY, String(zoom));
}, [zoom]);
const setClampedZoom = useCallback((value: number) => {
setZoom(clampPreviewZoom(Number(value.toFixed(2))));
}, []);
const onDownloadPDF = useCallback(async () => {
if (!resume) return;
const filename = generateFilename(resume.name || resume.data.basics.name || resume.id, "pdf");
const toastId = toast.loading(t`Please wait while your PDF is being generated…`);
setIsPrinting(true);
try {
const blob = await createResumePdfBlob(resume.data);
downloadWithAnchor(blob, filename);
} catch {
toast.error(t`There was a problem while generating the PDF, please try again.`);
} finally {
setIsPrinting(false);
toast.dismiss(toastId);
}
}, [resume]);
const zoomPercent = Math.round(zoom * 100);
return (
<section className="flex h-full min-h-0 flex-col bg-muted/30">
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
<div>
<div className="font-semibold">
<Trans>Resume</Trans>
</div>
<div className="text-muted-foreground text-xs">{resume?.name ?? t`Missing working resume`}</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<div className="sticky top-0 z-10 flex h-10 items-center justify-between border-b bg-background/90 px-2 backdrop-blur">
<div className="flex items-center gap-1">
<ToolbarButton
label={t`Decrease zoom`}
disabled={!resume}
onClick={() => setClampedZoom(zoom - PREVIEW_ZOOM_STEP)}
>
<MinusIcon />
</ToolbarButton>
<Tooltip>
<TooltipTrigger
render={
<input
type="text"
inputMode="numeric"
value={`${zoomPercent}%`}
disabled={!resume}
aria-label={t`Zoom level`}
className="h-8 w-14 rounded-md border bg-background px-1 text-center text-xs outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50"
onChange={(event) => {
const nextValue = Number(event.target.value.replace(/[^0-9.]/g, ""));
if (Number.isFinite(nextValue)) setClampedZoom(nextValue / 100);
}}
/>
}
/>
<TooltipContent side="bottom" align="center">
<Trans>Zoom level</Trans>
</TooltipContent>
</Tooltip>
<ToolbarButton
label={t`Increase zoom`}
disabled={!resume}
onClick={() => setClampedZoom(zoom + PREVIEW_ZOOM_STEP)}
>
<PlusIcon />
</ToolbarButton>
</div>
<div className="flex items-center gap-1">
<ToolbarButton
label={t`Open in builder`}
disabled={!resume}
nativeButton={false}
render={resume ? <Link to="/builder/$resumeId" params={{ resumeId: resume.id }} /> : undefined}
>
<ArrowSquareOutIcon />
</ToolbarButton>
<ToolbarButton
label={t`Download PDF`}
disabled={!resume || isPrinting}
onClick={() => void onDownloadPDF()}
>
{isPrinting ? <CircleNotchIcon className="animate-spin" /> : <FilePdfIcon />}
</ToolbarButton>
</div>
</div>
<div className="p-4">
{resume ? (
<ResumePreview
data={resume.data}
pageLayout="vertical"
pageScale={zoom}
showPageNumbers
className="mx-auto"
pageClassName="shadow-lg"
/>
) : (
<div className="rounded-md border border-dashed p-6 text-center text-muted-foreground">
<Trans>The working resume was deleted. This thread is read-only.</Trans>
</div>
)}
</div>
</div>
</section>
);
}
function RouteComponent() {
const { threadId } = Route.useParams();
const navigate = useNavigate();
@@ -0,0 +1,181 @@
import type * as React from "react";
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowSquareOutIcon, CircleNotchIcon, FilePdfIcon, MinusIcon, PlusIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@reactive-resume/ui/components/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { ResumePreview } from "@/features/resume/preview/preview";
type ToolbarButtonProps = React.ComponentProps<typeof Button> & {
label: string;
};
type AgentThreadDetail = RouterOutput["agent"]["threads"]["get"];
export type ResumePaneProps = {
resume: AgentThreadDetail["resume"];
};
const AGENT_PREVIEW_ZOOM_STORAGE_KEY = "reactive-resume:agent-preview-zoom:v3";
const MIN_PREVIEW_ZOOM = 0.4;
const MAX_PREVIEW_ZOOM = 1.5;
const PREVIEW_ZOOM_STEP = 0.05;
const DEFAULT_PREVIEW_ZOOM = 1;
function clampPreviewZoom(value: number) {
return Math.min(MAX_PREVIEW_ZOOM, Math.max(MIN_PREVIEW_ZOOM, value));
}
function getInitialPreviewZoom() {
if (typeof window === "undefined") return DEFAULT_PREVIEW_ZOOM;
// ponytail: Number(null) === 0, so guard with a null-check before parsing
const raw = window.localStorage.getItem(AGENT_PREVIEW_ZOOM_STORAGE_KEY);
if (raw === null) return DEFAULT_PREVIEW_ZOOM;
const stored = Number(raw);
return Number.isFinite(stored) ? clampPreviewZoom(stored) : DEFAULT_PREVIEW_ZOOM;
}
function ToolbarButton({ label, children, ...props }: ToolbarButtonProps) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button size="icon-sm" variant="ghost" aria-label={label} {...props}>
{children}
</Button>
}
/>
<TooltipContent side="bottom" align="center">
{label}
</TooltipContent>
</Tooltip>
);
}
export function ResumePane({ resume }: ResumePaneProps) {
const [zoom, setZoom] = useState(getInitialPreviewZoom);
const [isPrinting, setIsPrinting] = useState(false);
useEffect(() => {
window.localStorage.setItem(AGENT_PREVIEW_ZOOM_STORAGE_KEY, String(zoom));
}, [zoom]);
const setClampedZoom = useCallback((value: number) => {
setZoom(clampPreviewZoom(Number(value.toFixed(2))));
}, []);
const onDownloadPDF = useCallback(async () => {
if (!resume) return;
const filename = generateFilename(resume.name || resume.data.basics.name || resume.id, "pdf");
const toastId = toast.loading(t`Please wait while your PDF is being generated…`);
setIsPrinting(true);
try {
const blob = await createResumePdfBlob(resume.data);
downloadWithAnchor(blob, filename);
} catch {
toast.error(t`There was a problem while generating the PDF, please try again.`);
} finally {
setIsPrinting(false);
toast.dismiss(toastId);
}
}, [resume]);
const zoomPercent = Math.round(zoom * 100);
return (
<section className="flex h-full min-h-0 flex-col bg-muted/30">
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
<div>
<div className="font-semibold">
<Trans>Resume</Trans>
</div>
<div className="text-muted-foreground text-xs">{resume?.name ?? t`Missing working resume`}</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<div className="sticky top-0 z-10 flex h-10 items-center justify-between border-b bg-background/90 px-2 backdrop-blur">
<div className="flex items-center gap-1">
<ToolbarButton
label={t`Decrease zoom`}
disabled={!resume}
onClick={() => setClampedZoom(zoom - PREVIEW_ZOOM_STEP)}
>
<MinusIcon />
</ToolbarButton>
<Tooltip>
<TooltipTrigger
render={
<input
type="text"
inputMode="numeric"
value={`${zoomPercent}%`}
disabled={!resume}
aria-label={t`Zoom level`}
className="h-8 w-14 rounded-md border bg-background px-1 text-center text-xs outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50"
onChange={(event) => {
const nextValue = Number(event.target.value.replace(/[^0-9.]/g, ""));
if (Number.isFinite(nextValue)) setClampedZoom(nextValue / 100);
}}
/>
}
/>
<TooltipContent side="bottom" align="center">
<Trans>Zoom level</Trans>
</TooltipContent>
</Tooltip>
<ToolbarButton
label={t`Increase zoom`}
disabled={!resume}
onClick={() => setClampedZoom(zoom + PREVIEW_ZOOM_STEP)}
>
<PlusIcon />
</ToolbarButton>
</div>
<div className="flex items-center gap-1">
<ToolbarButton
label={t`Open in builder`}
disabled={!resume}
nativeButton={false}
render={resume ? <Link to="/builder/$resumeId" params={{ resumeId: resume.id }} /> : undefined}
>
<ArrowSquareOutIcon />
</ToolbarButton>
<ToolbarButton
label={t`Download PDF`}
disabled={!resume || isPrinting}
onClick={() => void onDownloadPDF()}
>
{isPrinting ? <CircleNotchIcon className="animate-spin" /> : <FilePdfIcon />}
</ToolbarButton>
</div>
</div>
<div className="p-4">
{resume ? (
<ResumePreview
data={resume.data}
pageLayout="vertical"
pageScale={zoom}
showPageNumbers
className="mx-auto"
pageClassName="shadow-lg"
/>
) : (
<div className="rounded-md border border-dashed p-6 text-center text-muted-foreground">
<Trans>The working resume was deleted. This thread is read-only.</Trans>
</div>
)}
</div>
</div>
</section>
);
}