mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-26 00:02:32 +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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user