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
@@ -0,0 +1,108 @@
import type { Layout } from "react-resizable-panels";
import type { BuilderLayout } from "../-store/sidebar";
import { Trans } from "@lingui/react/macro";
import { Outlet } from "@tanstack/react-router";
import { useEffect, useRef } from "react";
import { usePanelRef } from "react-resizable-panels";
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
import { BuilderSidebarLeft } from "../-sidebar/left";
import { BuilderSidebarRight } from "../-sidebar/right";
import {
mapPanelLayoutToBuilderLayout,
setBuilderLayout,
useBuilderSidebar,
useBuilderSidebarStore,
} from "../-store/sidebar";
import { BuilderHeader } from "./header";
export type BuilderLayoutShellProps = {
initialLayout: BuilderLayout;
};
export function DesktopBuilderShell({ initialLayout }: BuilderLayoutShellProps) {
// Only rendered when `BuilderLayoutShell` has already decided we're on desktop, so sidebar sizing is unconditional.
const canPersistLayoutRef = useRef(false);
const leftSidebarRef = usePanelRef();
const rightSidebarRef = usePanelRef();
const setLeftSidebar = useBuilderSidebarStore((state) => state.setLeftSidebar);
const setRightSidebar = useBuilderSidebarStore((state) => state.setRightSidebar);
const setLayout = useBuilderSidebarStore((state) => state.setLayout);
const { maxSidebarSize, minSidebarSize, collapsedSidebarSize, groupResizeBehavior } = useBuilderSidebar();
useEffect(() => {
setLayout(initialLayout);
canPersistLayoutRef.current = true;
}, [initialLayout, setLayout]);
const onLayoutChanged = (layout: Layout) => {
const nextLayout = mapPanelLayoutToBuilderLayout(layout);
if (!canPersistLayoutRef.current) return;
setLayout(nextLayout);
setBuilderLayout(nextLayout);
};
useEffect(() => {
if (!leftSidebarRef || !rightSidebarRef) return;
setLeftSidebar(leftSidebarRef);
setRightSidebar(rightSidebarRef);
}, [leftSidebarRef, rightSidebarRef, setLeftSidebar, setRightSidebar]);
const sidebarMinSize = `${minSidebarSize}px`;
const sidebarCollapsedSize = `${collapsedSidebarSize}px`;
const leftSidebarSize = `${initialLayout.left}%`;
const rightSidebarSize = `${initialLayout.right}%`;
const artboardSize = `${initialLayout.artboard}%`;
return (
<div className="flex h-svh flex-col">
<a
href="#main-content"
className="sr-only rounded-md bg-popover px-4 py-2 text-sm ring-2 ring-ring focus:not-sr-only focus:absolute focus:inset-s-2 focus:top-2 focus:z-[100]"
>
<Trans>Skip to main content</Trans>
</a>
<BuilderHeader />
<ResizableGroup orientation="horizontal" className="mt-14 flex-1" onLayoutChanged={onLayoutChanged}>
<ResizablePanel
collapsible
id="left"
panelRef={leftSidebarRef}
groupResizeBehavior={groupResizeBehavior}
maxSize={maxSidebarSize}
minSize={sidebarMinSize}
collapsedSize={sidebarCollapsedSize}
defaultSize={leftSidebarSize}
className="z-20 h-[calc(100svh-3.5rem)]"
>
<BuilderSidebarLeft />
</ResizablePanel>
<ResizableSeparator withHandle className="z-50 border-s" />
<ResizablePanel id="artboard" defaultSize={artboardSize} className="h-[calc(100svh-3.5rem)]">
<main id="main-content" className="h-full">
<Outlet />
</main>
</ResizablePanel>
<ResizableSeparator withHandle className="z-50 border-e" />
<ResizablePanel
collapsible
id="right"
panelRef={rightSidebarRef}
groupResizeBehavior={groupResizeBehavior}
maxSize={maxSidebarSize}
minSize={sidebarMinSize}
collapsedSize={sidebarCollapsedSize}
defaultSize={rightSidebarSize}
className="z-20 h-[calc(100svh-3.5rem)]"
>
<BuilderSidebarRight />
</ResizablePanel>
</ResizableGroup>
</div>
);
}
@@ -28,7 +28,11 @@ import {
} from "@reactive-resume/ui/components/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { cn } from "@reactive-resume/utils/style";
import { isEditableElementFocused, useCurrentResume, useResumeStore } from "@/features/resume/builder/draft";
import {
isEditableElementFocused,
useCurrentBuilderResumeSelector,
useResumeStore,
} from "@/features/resume/builder/draft";
import { authClient } from "@/libs/auth/client";
type BuilderDockProps = {
@@ -38,7 +42,9 @@ type BuilderDockProps = {
export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps) {
const { data: session } = authClient.useSession();
const resume = useCurrentResume();
// Narrow slices: selecting the whole resume re-renders the dock on every keystroke.
const resumeSlug = useCurrentBuilderResumeSelector((resume) => resume.slug);
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
const navigate = useNavigate();
const [_, copyToClipboard] = useCopyToClipboard();
@@ -67,9 +73,9 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
});
const publicUrl = useMemo(() => {
if (!session?.user.username || !resume?.slug) return "";
return `${window.location.origin}/${session.user.username}/${resume.slug}`;
}, [session?.user.username, resume?.slug]);
if (!session?.user.username || !resumeSlug) return "";
return `${window.location.origin}/${session.user.username}/${resumeSlug}`;
}, [session?.user.username, resumeSlug]);
const onCopyUrl = useCallback(async () => {
await copyToClipboard(publicUrl);
@@ -100,8 +106,8 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
icon={ChatCircleDotsIcon}
title={t`Open AI agent`}
onClick={() => {
if (!resume) return;
void navigate({ to: "/agent/new", search: { resumeId: resume.id } });
if (!resumeId) return;
void navigate({ to: "/agent/new", search: { resumeId } });
}}
/>
<div className="mx-1 h-8 w-px bg-border" />
@@ -27,7 +27,12 @@ import {
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { useDialogStore } from "@/dialogs/store";
import { useCurrentResume, usePatchResume, useResumeStore } from "@/features/resume/builder/draft";
import {
useCurrentBuilderResumeSelector,
useCurrentResume,
usePatchResume,
useResumeStore,
} from "@/features/resume/builder/draft";
import { ResumeDownloadDialog } from "@/features/resume/export/download-dialog";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
@@ -37,9 +42,11 @@ import { BuilderAiAssistant } from "./ai-assistant";
import { BuilderVersionHistory } from "./version-history";
export function BuilderHeader() {
const resume = useCurrentResume();
const name = resume.name;
const isLocked = resume.isLocked;
// Subscribe to only the metadata fields this header renders. Selecting the whole resume re-renders
// the header on every keystroke (immer replaces the resume reference on each content edit).
const name = useCurrentBuilderResumeSelector((resume) => resume.name);
const isLocked = useCurrentBuilderResumeSelector((resume) => resume.isLocked);
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
const { toggleSidebar } = useBuilderSidebar();
// Equal-width flex-1 side groups keep the center title group truly centered regardless of the
@@ -77,8 +84,8 @@ export function BuilderHeader() {
<h2 className="min-w-0 truncate font-medium">{name}</h2>
{isLocked && <LockSimpleIcon className="ms-2 text-muted-foreground" />}
<SaveStatusIndicator />
<BuilderAiAssistant resumeId={resume.id} />
<BuilderVersionHistory resumeId={resume.id} />
<BuilderAiAssistant resumeId={resumeId} />
<BuilderVersionHistory resumeId={resumeId} />
<BuilderHeaderDropdown />
</div>
@@ -0,0 +1,110 @@
import type { Icon } from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { EyeIcon, NotePencilIcon, PaletteIcon } from "@phosphor-icons/react";
import { Outlet } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { cn } from "@reactive-resume/utils/style";
import { usePreviewPausedStore } from "@/features/resume/builder/draft";
import { BuilderSidebarLeft } from "../-sidebar/left";
import { BuilderSidebarRight } from "../-sidebar/right";
import { BuilderHeader } from "./header";
type MobileBuilderTab = "edit" | "preview" | "design";
const MOBILE_BUILDER_TABS = [
{ value: "edit", icon: NotePencilIcon },
{ value: "preview", icon: EyeIcon },
{ value: "design", icon: PaletteIcon },
] as const satisfies readonly { value: MobileBuilderTab; icon: Icon }[];
type MobileSidebarPanelProps = {
children: ReactNode;
};
function MobileSidebarPanel({ children }: MobileSidebarPanelProps) {
// Sits below the header (top-14) and above the tab bar (bottom-16). The sidebar's ScrollArea hardcodes
// `h-[calc(100svh-3.5rem)]`; override it to fill this panel so its last item isn't hidden under the tab bar.
return (
<div className="fixed inset-x-0 top-14 bottom-16 z-40 bg-background [&_[data-slot=scroll-area]]:h-full">
{children}
</div>
);
}
type MobileBuilderTabBarProps = {
activeTab: MobileBuilderTab;
onTabChange: (tab: MobileBuilderTab) => void;
};
function MobileBuilderTabBar({ activeTab, onTabChange }: MobileBuilderTabBarProps) {
const labels: Record<MobileBuilderTab, string> = { edit: t`Edit`, preview: t`Preview`, design: t`Design` };
return (
<nav className="fixed inset-x-0 bottom-0 z-50 flex border-t bg-popover pb-[env(safe-area-inset-bottom)]">
{MOBILE_BUILDER_TABS.map(({ value, icon: Icon }) => {
const isActive = value === activeTab;
return (
<button
key={value}
type="button"
aria-current={isActive ? "page" : undefined}
onClick={() => onTabChange(value)}
className={cn(
"flex min-h-16 flex-1 flex-col items-center justify-center gap-1 text-xs transition-colors",
isActive ? "text-primary" : "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-5" weight={isActive ? "fill" : "regular"} />
<span>{labels[value]}</span>
</button>
);
})}
</nav>
);
}
export function MobileBuilderShell() {
// Local state is enough — mobile view mode is shell-scoped and doesn't need to persist.
const [tab, setTab] = useState<MobileBuilderTab>("edit");
const setPreviewPaused = usePreviewPausedStore((state) => state.setPaused);
// The preview stays mounted under the Edit/Design overlay; pause its render while it's covered.
useEffect(() => {
setPreviewPaused(tab !== "preview");
return () => setPreviewPaused(false);
}, [tab, setPreviewPaused]);
return (
<div className="flex min-h-[100dvh] flex-col">
<a
href="#main-content"
className="sr-only rounded-md bg-popover px-4 py-2 text-sm ring-2 ring-ring focus:not-sr-only focus:absolute focus:inset-s-2 focus:top-2 focus:z-[100]"
>
<Trans>Skip to main content</Trans>
</a>
<BuilderHeader />
{/* The preview (fixed inset-0) stays mounted so zoom/pan state survives tab switches. */}
<main id="main-content" className="flex-1">
<Outlet />
</main>
{tab === "edit" && (
<MobileSidebarPanel>
<BuilderSidebarLeft />
</MobileSidebarPanel>
)}
{tab === "design" && (
<MobileSidebarPanel>
<BuilderSidebarRight />
</MobileSidebarPanel>
)}
<MobileBuilderTabBar activeTab={tab} onTabChange={setTab} />
</div>
);
}