Files
Reactive-Resume/apps/web/src/features/applications/components/board.tsx
T
Amruth Pillai b404dbd42a 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
2026-07-05 23:44:04 +02:00

165 lines
5.6 KiB
TypeScript

import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import type { ApplicationStatus } from "@reactive-resume/schema/applications/data";
import type { Application } from "../types";
import {
DndContext,
DragOverlay,
PointerSensor,
pointerWithin,
useDraggable,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { t } from "@lingui/core/macro";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { STAGES } from "@reactive-resume/schema/applications/data";
import { cn } from "@reactive-resume/utils/style";
import { orpc } from "@/libs/orpc/client";
import { applicationsListQueryKey } from "../queries";
import { ApplicationCard } from "./application-card";
type Props = {
applications: Application[];
onOpen: (application: Application) => void;
onEdit: (application: Application) => void;
};
export function ApplicationBoard({ applications, onOpen, onEdit }: Props) {
const queryClient = useQueryClient();
const [activeId, setActiveId] = useState<string | null>(null);
// A small activation distance so a click still opens the detail panel instead of starting a drag.
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const listKey = applicationsListQueryKey();
const move = useMutation(
orpc.applications.update.mutationOptions({
onMutate: async ({ id, status }) => {
await queryClient.cancelQueries({ queryKey: listKey });
const previous = queryClient.getQueryData<Application[]>(listKey);
queryClient.setQueryData<Application[]>(listKey, (rows) =>
(rows ?? []).map((row) => (row.id === id && status ? { ...row, status } : row)),
);
return { previous };
},
onError: (_error, _vars, context) => {
if (context?.previous) queryClient.setQueryData(listKey, context.previous);
toast.error(t`Couldn't move the application. Please try again.`);
},
onSettled: () => void queryClient.invalidateQueries({ queryKey: listKey }),
}),
);
const byStage = useMemo(() => {
const map = new Map<ApplicationStatus, Application[]>(STAGES.map((s) => [s.value, []]));
for (const app of applications) map.get(app.status)?.push(app);
return map;
}, [applications]);
const activeApp = activeId ? applications.find((a) => a.id === activeId) : null;
const onDragStart = (event: DragStartEvent) => setActiveId(String(event.active.id));
const onDragEnd = (event: DragEndEvent) => {
setActiveId(null);
const { active, over } = event;
if (!over) return;
const target = over.id as ApplicationStatus;
const app = applications.find((a) => a.id === active.id);
if (!app || app.status === target) return;
move.mutate({ id: app.id, status: target });
};
return (
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={onDragStart} onDragEnd={onDragEnd}>
<div className="flex h-full min-h-0 gap-4 overflow-x-auto pb-4">
{STAGES.map((stage) => (
<Column
key={stage.value}
stage={stage}
applications={byStage.get(stage.value) ?? []}
onOpen={onOpen}
onEdit={onEdit}
/>
))}
</div>
<DragOverlay>{activeApp ? <ApplicationCard application={activeApp} dragging /> : null}</DragOverlay>
</DndContext>
);
}
type ColumnProps = {
stage: (typeof STAGES)[number];
applications: Application[];
onOpen: (application: Application) => void;
onEdit: (application: Application) => void;
};
// Cap the cards rendered per column so a stage with hundreds of applications doesn't mount
// hundreds of draggable nodes at once. Users reveal the rest in batches. Server-side paging
// isn't needed here — the list payload is small; the cost is DOM/drag nodes.
const COLUMN_PAGE_SIZE = 50;
function Column({ stage, applications, onOpen, onEdit }: ColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id: stage.value });
const [visible, setVisible] = useState(COLUMN_PAGE_SIZE);
const shown = applications.slice(0, visible);
const remaining = applications.length - shown.length;
return (
<div className="flex w-72 shrink-0 flex-col rounded-2xl border border-border bg-muted/30">
<div className="flex items-center gap-2 px-3.5 py-3">
<span className="size-2.5 rounded-sm" style={{ background: stage.color }} />
<span className="font-semibold text-sm tracking-tight">{stage.label}</span>
<span className="rounded-full bg-muted px-2 py-0.5 font-semibold text-muted-foreground text-xs">
{applications.length}
</span>
</div>
<div
ref={setNodeRef}
className={cn(
"flex min-h-24 flex-1 flex-col gap-2.5 overflow-y-auto px-2.5 pb-3 transition-colors",
isOver && "bg-muted/60",
)}
>
{shown.map((app) => (
<DraggableCard key={app.id} application={app} onOpen={() => onOpen(app)} onEdit={onEdit} />
))}
{remaining > 0 && (
<button
type="button"
onClick={() => setVisible((v) => v + COLUMN_PAGE_SIZE)}
className="rounded-lg border border-border border-dashed py-2 text-muted-foreground text-xs hover:bg-muted/60"
>
{t`Show ${Math.min(remaining, COLUMN_PAGE_SIZE)} more`}
</button>
)}
</div>
</div>
);
}
function DraggableCard({
application,
onOpen,
onEdit,
}: {
application: Application;
onOpen: () => void;
onEdit: (application: Application) => void;
}) {
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ id: application.id });
return (
<div ref={setNodeRef} {...attributes} {...listeners} className={cn(isDragging && "opacity-30")}>
<ApplicationCard application={application} onClick={onOpen} onEdit={onEdit} />
</div>
);
}