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
@@ -135,7 +135,9 @@ function CustomStylesSectionForm() {
const nextIntent = compactIntent({ ...currentIntent, ...patch });
updateResumeData((draft) => {
draft.metadata.styleRules ??= [];
// Plain `?? ` assignment (not `??=`) so React Compiler can memoize this component;
// the compiler bails on logical-assignment operators today. Behavior is identical.
draft.metadata.styleRules = draft.metadata.styleRules ?? [];
const rules = draft.metadata.styleRules;
const existingIndex = rules.findIndex((rule) => rule.id === ruleId);
const existingRule = rules[existingIndex];
@@ -36,6 +36,11 @@ function useColorSectionForm(colors: ColorValues, persist: (data: ColorValues) =
const form = useAppForm({
defaultValues: colors,
validators: { onChange: colorDesignSchema },
listeners: {
onChange: ({ formApi }) => {
persist(formApi.state.values);
},
},
onSubmit: ({ value }) => {
persist(value);
},
@@ -59,10 +64,6 @@ function ColorSectionForm() {
const form = useColorSectionForm(colors, persist);
const handleAutoSave = () => {
persist(form.state.values);
};
return (
<form
className="space-y-4"
@@ -85,7 +86,6 @@ function ColorSectionForm() {
active={color === field.state.value}
onSelect={(color) => {
field.handleChange(color as string);
handleAutoSave();
}}
/>
))}
@@ -93,20 +93,9 @@ function ColorSectionForm() {
)}
</form.Field>
<ColorFormField
form={form}
name="primary"
label={<Trans>Primary Color</Trans>}
controlled
handleAutoSave={handleAutoSave}
/>
<ColorFormField form={form} name="text" label={<Trans>Text Color</Trans>} handleAutoSave={handleAutoSave} />
<ColorFormField
form={form}
name="background"
label={<Trans>Background Color</Trans>}
handleAutoSave={handleAutoSave}
/>
<ColorFormField form={form} name="primary" label={<Trans>Primary Color</Trans>} controlled />
<ColorFormField form={form} name="text" label={<Trans>Text Color</Trans>} />
<ColorFormField form={form} name="background" label={<Trans>Background Color</Trans>} />
</form>
);
}
@@ -116,10 +105,9 @@ type ColorFormFieldProps = {
name: keyof ColorValues;
label: ReactNode;
controlled?: boolean;
handleAutoSave: () => void;
};
function ColorFormField({ form, name, label, controlled, handleAutoSave }: ColorFormFieldProps) {
function ColorFormField({ form, name, label, controlled }: ColorFormFieldProps) {
return (
<form.Field name={name}>
{(field) => (
@@ -130,7 +118,6 @@ function ColorFormField({ form, name, label, controlled, handleAutoSave }: Color
{...(controlled ? { value: field.state.value } : { defaultValue: field.state.value })}
onChange={(color) => {
field.handleChange(color);
handleAutoSave();
}}
/>
<FormControl
@@ -141,7 +128,6 @@ function ColorFormField({ form, name, label, controlled, handleAutoSave }: Color
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
handleAutoSave();
}}
/>
}
@@ -235,16 +221,17 @@ function LevelSectionForm() {
const form = useAppForm({
defaultValues: levelDesign,
validators: { onChange: levelDesignSchema },
listeners: {
onChange: ({ formApi }) => {
persist(formApi.state.values);
},
},
onSubmit: ({ value }) => {
persist(value);
},
});
useSyncFormValues(form, levelDesign);
const handleAutoSave = () => {
persist(form.state.values);
};
const previewType = useStore(form.store, (s) => s.values.type);
const previewIcon = useStore(form.store, (s) => s.values.icon);
const iconFontSize = resolveStyleRuleFontSize(resume.data, { slot: "icon" });
@@ -296,7 +283,6 @@ function LevelSectionForm() {
value={field.state.value}
onChange={(value) => {
field.handleChange(value);
handleAutoSave();
}}
/>
}
@@ -318,7 +304,6 @@ function LevelSectionForm() {
onValueChange={(value) => {
if (!value) return;
field.handleChange(value as LevelType);
handleAutoSave();
}}
/>
}
@@ -68,7 +68,7 @@ const renderExport = () =>
const openDialog = () => {
const trigger = screen.getByText(
"Choose PDF, DOCX, or JSON. Export your resume and cover letter separately when available.",
"Choose PDF, DOCX, Markdown, or JSON. Export your resume and cover letter separately when available.",
);
fireEvent.click(trigger.closest("button") as HTMLButtonElement);
};
@@ -26,7 +26,9 @@ export function ExportSectionBuilder() {
<Trans>Download</Trans>
</h6>
<p className="text-muted-foreground text-xs leading-normal">
<Trans>Choose PDF, DOCX, or JSON. Export your resume and cover letter separately when available.</Trans>
<Trans>
Choose PDF, DOCX, Markdown, or JSON. Export your resume and cover letter separately when available.
</Trans>
</p>
</div>
</Button>
@@ -11,7 +11,7 @@ import {
} from "@reactive-resume/ui/components/input-group";
import { Switch } from "@reactive-resume/ui/components/switch";
import { Combobox } from "@/components/ui/combobox";
import { getLocaleOptions } from "@/features/locale/combobox";
import { getLocaleOptions } from "@/features/locale/locale-options";
import { useResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
import { useAppForm } from "@/libs/tanstack-form";
@@ -3,7 +3,7 @@ import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, InfoIcon, LightningIcon, SparkleIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import { toast } from "sonner";
import { match } from "ts-pattern";
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
@@ -51,11 +51,10 @@ export function ResumeAnalysisSectionBuilder() {
const resume = useResume();
const resumeId = resume?.id ?? "";
const providersQuery = useQuery(orpc.aiProviders.list.queryOptions());
const aiEnabled =
providersQuery.data?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false;
const { data: providers } = useQuery(orpc.aiProviders.list.queryOptions());
const aiEnabled = providers?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false;
const analysisQuery = useQuery({
const { data: analysis, isFetched: analysisFetched } = useQuery({
...orpc.resume.analysis.getById.queryOptions({ input: { id: resumeId } }),
enabled: !!resume,
});
@@ -88,10 +87,11 @@ export function ResumeAnalysisSectionBuilder() {
},
});
const analysis = analysisQuery.data;
const score = analysis?.overallScore ?? null;
const updatedAt = analysis?.updatedAt ?? null;
const [updatedAtLabel, setUpdatedAtLabel] = useState<string | null>(null);
// Derived during render (not via state+effect): the analysis comes from a client-fetched query,
// so the server render has no date and there's no hydration mismatch to defer around.
const updatedAtLabel = updatedAt ? new Date(updatedAt).toLocaleString() : null;
const analyzeLabel = isPending ? t`Analyzing…` : t`Analyze Resume`;
const scoreTone = useMemo(() => {
@@ -101,10 +101,6 @@ export function ResumeAnalysisSectionBuilder() {
return "bg-rose-600";
}, [score]);
useEffect(() => {
setUpdatedAtLabel(updatedAt ? new Date(updatedAt).toLocaleString() : null);
}, [updatedAt]);
const onAnalyze = () => {
if (!resume) return;
@@ -168,7 +164,7 @@ export function ResumeAnalysisSectionBuilder() {
</div>
</div>
{analysisQuery.isFetched && !analysis && !isPending && (
{analysisFetched && !analysis && !isPending && (
<div className="rounded-md border border-dashed p-3">
<p className="max-w-xs text-muted-foreground text-sm">
<Trans>Run your first analysis to get a scorecard, strengths, and prioritized suggestions.</Trans>
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { computeDelta, getSparklinePoints } from "./statistics";
import { computeDelta, getSparklinePoints } from "./statistics.utils";
describe("computeDelta", () => {
it("returns null when the prior period had no activity", () => {
@@ -8,33 +8,12 @@ import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/compone
import { cn } from "@reactive-resume/utils/style";
import { orpc } from "@/libs/orpc/client";
import { SectionBase } from "../shared/section-base";
import { computeDelta, getSparklinePoints } from "./statistics.utils";
// Fetch 60 days so we can render a 30-day sparkline and compare it against the prior 30 days.
const TREND_DAYS = 60;
const WINDOW = 30;
// Percent change of the most recent `window` days vs the `window` days before it.
// Returns null when the prior period had no activity (division by zero / no baseline).
export function computeDelta(series: number[], window: number): number | null {
const recent = series.slice(-window);
const previous = series.slice(-window * 2, -window);
const recentSum = recent.reduce((sum, n) => sum + n, 0);
const previousSum = previous.reduce((sum, n) => sum + n, 0);
if (previousSum === 0) return null;
return Math.round(((recentSum - previousSum) / previousSum) * 100);
}
// Polyline points for the sparkline, or null for degenerate inputs (fewer than two
// points, or an all-zero series) where there is nothing meaningful to draw.
export function getSparklinePoints(values: number[], width: number, height: number): string | null {
if (values.length < 2 || values.every((n) => n === 0)) return null;
const max = Math.max(...values, 1);
const step = width / (values.length - 1);
return values
.map((value, index) => `${(index * step).toFixed(1)},${(height - (value / max) * height).toFixed(1)}`)
.join(" ");
}
export function StatisticsSectionBuilder() {
const params = useParams({ from: "/builder/$resumeId" });
const { data: statistics } = useQuery(
@@ -0,0 +1,21 @@
// Percent change of the most recent `window` days vs the `window` days before it.
// Returns null when the prior period had no activity (division by zero / no baseline).
export function computeDelta(series: number[], window: number): number | null {
const recent = series.slice(-window);
const previous = series.slice(-window * 2, -window);
const recentSum = recent.reduce((sum, n) => sum + n, 0);
const previousSum = previous.reduce((sum, n) => sum + n, 0);
if (previousSum === 0) return null;
return Math.round(((recentSum - previousSum) / previousSum) * 100);
}
// Polyline points for the sparkline, or null for degenerate inputs (fewer than two
// points, or an all-zero series) where there is nothing meaningful to draw.
export function getSparklinePoints(values: number[], width: number, height: number): string | null {
if (values.length < 2 || values.every((n) => n === 0)) return null;
const max = Math.max(...values, 1);
const step = width / (values.length - 1);
return values
.map((value, index) => `${(index * step).toFixed(1)},${(height - (value / max) * height).toFixed(1)}`)
.join(" ");
}
@@ -11,7 +11,8 @@ import {
InputGroupText,
} from "@reactive-resume/ui/components/input-group";
import { Separator } from "@reactive-resume/ui/components/separator";
import { FontFamilyCombobox, FontWeightCombobox, getNextWeights } from "@/components/typography/combobox";
import { FontFamilyCombobox, FontWeightCombobox } from "@/components/typography/combobox";
import { getNextWeights } from "@/components/typography/get-next-weights";
import { useResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
import { useAppForm } from "@/libs/tanstack-form";