mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-15 15:26:59 +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:
@@ -11,6 +11,15 @@ type CountUpProps = {
|
||||
"aria-atomic"?: boolean | "true" | "false";
|
||||
};
|
||||
|
||||
const getDecimalPlaces = (num: number): number => {
|
||||
const str = num.toString();
|
||||
if (str.includes(".")) {
|
||||
const decimals = str.split(".")[1];
|
||||
if (Number.parseInt(decimals, 10) !== 0) return decimals.length;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// ponytail: from/direction/delay/startWhen/onStart/onEnd removed — no production caller passes them
|
||||
export function CountUp({
|
||||
to,
|
||||
@@ -31,15 +40,6 @@ export function CountUp({
|
||||
|
||||
const isInView = useInView(ref, { once: true, margin: "0px" });
|
||||
|
||||
const getDecimalPlaces = (num: number): number => {
|
||||
const str = num.toString();
|
||||
if (str.includes(".")) {
|
||||
const decimals = str.split(".")[1];
|
||||
if (Number.parseInt(decimals, 10) !== 0) return decimals.length;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const maxDecimals = getDecimalPlaces(to);
|
||||
|
||||
const formatValue = useCallback(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveHighlightToolbarState } from "./rich-input";
|
||||
import { resolveHighlightToolbarState } from "./rich-input.utils";
|
||||
|
||||
describe("resolveHighlightToolbarState", () => {
|
||||
it("shows legacy colorless highlights as default yellow and clearable", () => {
|
||||
|
||||
@@ -57,9 +57,9 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
import { isRTL } from "@/libs/locale";
|
||||
import { ColorPicker } from "./color-picker";
|
||||
import { defaultHighlightColor, resolveHighlightToolbarState } from "./rich-input.utils";
|
||||
|
||||
const defaultTextColor = "rgba(0, 0, 0, 1)";
|
||||
const defaultHighlightColor = "rgba(255, 255, 0, 1)";
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
@@ -351,12 +351,6 @@ function useEditorToolbarState(editor: Editor) {
|
||||
|
||||
type EditorToolbarState = ReturnType<typeof useEditorToolbarState>;
|
||||
|
||||
export function resolveHighlightToolbarState(isHighlight: boolean, highlightColor: string | null) {
|
||||
const visibleHighlightColor = highlightColor ?? (isHighlight ? defaultHighlightColor : undefined);
|
||||
|
||||
return { visibleHighlightColor, canClearHighlight: isHighlight };
|
||||
}
|
||||
|
||||
type EditorToolbarProps = {
|
||||
editor: Editor;
|
||||
isFullscreen: boolean;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const defaultHighlightColor = "rgba(255, 255, 0, 1)";
|
||||
|
||||
export function resolveHighlightToolbarState(isHighlight: boolean, highlightColor: string | null) {
|
||||
const visibleHighlightColor = highlightColor ?? (isHighlight ? defaultHighlightColor : undefined);
|
||||
|
||||
return { visibleHighlightColor, canClearHighlight: isHighlight };
|
||||
}
|
||||
@@ -5,36 +5,6 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { FontDisplay } from "./font-display";
|
||||
|
||||
type Weight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
|
||||
|
||||
export function getNextWeights(fontFamily: string): Weight[] | null {
|
||||
const fontData = getFont(fontFamily);
|
||||
if (!fontData || !Array.isArray(fontData.weights) || fontData.weights.length === 0) return null;
|
||||
|
||||
const uniqueWeights = Array.from(new Set(fontData.weights)) as Weight[];
|
||||
|
||||
// Try to pick 400 and 600 if available
|
||||
const weights: Weight[] = [];
|
||||
|
||||
if (uniqueWeights.includes("400")) weights.push("400");
|
||||
if (uniqueWeights.includes("600")) weights.push("600");
|
||||
|
||||
const selectedWeights = new Set(weights);
|
||||
|
||||
// If we didn't find both, fill in with first/last, ensuring uniqueness
|
||||
while (weights.length < 2 && uniqueWeights.length > 0) {
|
||||
// candidateIndex: 0 (first), 1 (last)
|
||||
const lastIndex = uniqueWeights.length - 1;
|
||||
const candidate = weights.length === 0 ? uniqueWeights[0] : uniqueWeights[lastIndex];
|
||||
if (!selectedWeights.has(candidate)) {
|
||||
weights.push(candidate);
|
||||
selectedWeights.add(candidate);
|
||||
} else break;
|
||||
}
|
||||
|
||||
return weights.length > 0 ? weights : null;
|
||||
}
|
||||
|
||||
type FontFamilyComboboxProps = Omit<SingleComboboxProps, "options">;
|
||||
|
||||
export function FontFamilyCombobox({ className, ...props }: FontFamilyComboboxProps) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getNextWeights } from "./combobox";
|
||||
import { getNextWeights } from "./get-next-weights";
|
||||
|
||||
describe("getNextWeights", () => {
|
||||
it("returns 400 and 600 when both are available (the preferred default)", () => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getFont } from "@reactive-resume/fonts";
|
||||
|
||||
type Weight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
|
||||
|
||||
export function getNextWeights(fontFamily: string): Weight[] | null {
|
||||
const fontData = getFont(fontFamily);
|
||||
if (!fontData || !Array.isArray(fontData.weights) || fontData.weights.length === 0) return null;
|
||||
|
||||
const uniqueWeights = Array.from(new Set(fontData.weights)) as Weight[];
|
||||
|
||||
// Try to pick 400 and 600 if available
|
||||
const weights: Weight[] = [];
|
||||
|
||||
if (uniqueWeights.includes("400")) weights.push("400");
|
||||
if (uniqueWeights.includes("600")) weights.push("600");
|
||||
|
||||
const selectedWeights = new Set(weights);
|
||||
|
||||
// If we didn't find both, fill in with first/last, ensuring uniqueness
|
||||
while (weights.length < 2 && uniqueWeights.length > 0) {
|
||||
// candidateIndex: 0 (first), 1 (last)
|
||||
const lastIndex = uniqueWeights.length - 1;
|
||||
const candidate = weights.length === 0 ? uniqueWeights[0] : uniqueWeights[lastIndex];
|
||||
if (!selectedWeights.has(candidate)) {
|
||||
weights.push(candidate);
|
||||
selectedWeights.add(candidate);
|
||||
} else break;
|
||||
}
|
||||
|
||||
return weights.length > 0 ? weights : null;
|
||||
}
|
||||
@@ -79,6 +79,19 @@ type MultiComboboxProps<TValue extends string | number = string> = {
|
||||
|
||||
type ComboboxProps<TValue extends string | number = string> = SingleComboboxProps<TValue> | MultiComboboxProps<TValue>;
|
||||
|
||||
const listContent = <TValue extends string | number>(item: ComboboxOption<TValue>) => (
|
||||
<ComboboxItem key={String(item.value)} value={item} disabled={item.disabled}>
|
||||
{item.label}
|
||||
</ComboboxItem>
|
||||
);
|
||||
|
||||
const groupedListContent = <TValue extends string | number>(group: GroupedComboboxOption<TValue>) => (
|
||||
<ComboboxGroup key={group.key} items={group.items}>
|
||||
{group.label !== null && group.label !== undefined ? <ComboboxLabel>{group.label}</ComboboxLabel> : null}
|
||||
<ComboboxCollection>{listContent}</ComboboxCollection>
|
||||
</ComboboxGroup>
|
||||
);
|
||||
|
||||
function Combobox<TValue extends string | number = string>(props: ComboboxProps<TValue>) {
|
||||
const {
|
||||
options,
|
||||
@@ -203,19 +216,6 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
|
||||
? Array.isArray(selectedValue) && selectedValue.length > 0
|
||||
: selectedValue !== null && selectedValue !== undefined;
|
||||
|
||||
const listContent = (item: ComboboxOption<TValue>) => (
|
||||
<ComboboxItem key={String(item.value)} value={item} disabled={item.disabled}>
|
||||
{item.label}
|
||||
</ComboboxItem>
|
||||
);
|
||||
|
||||
const groupedListContent = (group: GroupedComboboxOption<TValue>) => (
|
||||
<ComboboxGroup key={group.key} items={group.items}>
|
||||
{group.label !== null && group.label !== undefined ? <ComboboxLabel>{group.label}</ComboboxLabel> : null}
|
||||
<ComboboxCollection>{listContent}</ComboboxCollection>
|
||||
</ComboboxGroup>
|
||||
);
|
||||
|
||||
const triggerNode = (
|
||||
<ComboboxTrigger
|
||||
id={id}
|
||||
|
||||
Reference in New Issue
Block a user